package miaou-driver-matrix

  1. Overview
  2. Docs
Miaou high-performance terminal driver with diff rendering

Install

dune-project
 Dependency

Authors

Maintainers

Sources

v0.5.2.tar.gz
md5=60a3b9f181f24572a06a9492532bfdda
sha512=fcc35a275066be2900e6201782faf47503076fa4640f08cf78067835a6f447b74613009e55b2ac799adb7ca46f1bffa261fc5971753f2cc3c6bef327511c7ef6

doc/src/miaou-driver-matrix.driver/matrix_selection.ml.html

Source file matrix_selection.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
(*****************************************************************************)
(*                                                                           *)
(* SPDX-License-Identifier: MIT                                              *)
(* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com>                *)
(*                                                                           *)
(*****************************************************************************)

(** Terminal text selection for the Matrix driver.

    Handles mouse-based text selection with visual highlighting and
    clipboard integration. Selection is drawn as an overlay on the
    rendered buffer using reverse video style. *)

module Clipboard = Miaou_interfaces.Clipboard

type point = {row : int; col : int}

(** Click mode for double/triple click detection *)
type click_mode = Single | Word | Line

type t = {
  mutable anchor : point option;
      (** Starting point of selection (where mouse was pressed) *)
  mutable current : point option;
      (** Current end point of selection (where mouse is now) *)
  mutable active : bool;  (** Whether a selection drag is in progress *)
  mutable last_click_time : float;
      (** Time of last click for multi-click detection *)
  mutable last_click_pos : point option;  (** Position of last click *)
  mutable click_count : int;
      (** Number of rapid clicks (1=single, 2=double, 3=triple) *)
  mutable click_mode : click_mode;
      (** Current selection mode based on click count *)
}

(** Maximum time between clicks to count as multi-click (seconds) *)
let multi_click_threshold = 0.4

(** Maximum distance between clicks to count as multi-click *)
let multi_click_distance = 2

let create () =
  {
    anchor = None;
    current = None;
    active = false;
    last_click_time = 0.0;
    last_click_pos = None;
    click_count = 0;
    click_mode = Single;
  }

let is_active t = t.active

let has_selection t =
  match (t.anchor, t.current) with Some _, Some _ -> true | _ -> false

let is_single_point t =
  match (t.anchor, t.current) with
  | Some a, Some c ->
      (* Consider it a single point (click, not selection) if anchor == current,
         regardless of click_mode. This allows double-clicks to be passed to widgets
         instead of being captured for word selection. Word/line selection only
         makes sense when the user drags. *)
      a.row = c.row && a.col = c.col
  | _ -> false

let is_multi_click t = t.click_count >= 2

let click_count t = t.click_count

(** Normalize selection bounds to (start, end) where start <= end.
    Selection is treated as a linear range through the buffer. *)
let get_bounds t =
  match (t.anchor, t.current) with
  | Some a, Some c ->
      let a_linear = (a.row, a.col) in
      let c_linear = (c.row, c.col) in
      if a_linear <= c_linear then Some (a, c) else Some (c, a)
  | _ -> None

(** Check if a cell is within the current selection. *)
let is_selected t ~row ~col =
  match get_bounds t with
  | None -> false
  | Some (start, stop) ->
      let pos = (row, col) in
      let start_pos = (start.row, start.col) in
      let stop_pos = (stop.row, stop.col) in
      pos >= start_pos && pos <= stop_pos

(** Check if a character is part of a word (not whitespace or box-drawing) *)
let is_word_char ch =
  let len = String.length ch in
  if len = 0 then false
  else if len = 1 then
    let c = ch.[0] in
    (c >= 'a' && c <= 'z')
    || (c >= 'A' && c <= 'Z')
    || (c >= '0' && c <= '9')
    || c = '_' || c = '-'
  else
    (* Multi-byte UTF-8: assume it's a word char unless it's box-drawing *)
    let code = Char.code ch.[0] in
    (* Box-drawing chars are in U+2500-U+257F, which starts with 0xE2 0x94 or 0xE2 0x95 *)
    not
      (len >= 3 && code = 0xE2
      && (Char.code ch.[1] = 0x94 || Char.code ch.[1] = 0x95))

(** Check if a character is a box-drawing character *)
let is_box_char ch =
  let len = String.length ch in
  if len >= 3 then
    let c0 = Char.code ch.[0] in
    let c1 = Char.code ch.[1] in
    (* Box-drawing: U+2500-U+257F = E2 94 80 to E2 95 BF *)
    c0 = 0xE2 && (c1 = 0x94 || c1 = 0x95)
  else false

(** Find word boundaries at the given position *)
let find_word_bounds ~get_char ~col ~cols =
  let start_col = ref col in
  let end_col = ref col in
  (* Find start of word *)
  while !start_col > 0 && is_word_char (get_char ~col:(!start_col - 1)) do
    decr start_col
  done ;
  (* Find end of word *)
  while !end_col < cols - 1 && is_word_char (get_char ~col:(!end_col + 1)) do
    incr end_col
  done ;
  (!start_col, !end_col)

(** Find line boundaries, stopping at box-drawing characters *)
let find_line_bounds ~get_char ~col ~cols =
  let start_col = ref col in
  let end_col = ref col in
  (* Find start of line segment (stop at box chars) *)
  while !start_col > 0 && not (is_box_char (get_char ~col:(!start_col - 1))) do
    decr start_col
  done ;
  (* Find end of line segment (stop at box chars) *)
  while
    !end_col < cols - 1 && not (is_box_char (get_char ~col:(!end_col + 1)))
  do
    incr end_col
  done ;
  (!start_col, !end_col)

(** Start a new selection at the given position.
    Handles single/double/triple click for char/word/line selection. *)
let start_selection t ~row ~col ~get_char ~cols =
  let now = Unix.gettimeofday () in
  let point = {row; col} in

  (* Check for multi-click *)
  let is_nearby =
    match t.last_click_pos with
    | Some p ->
        abs (p.row - row) <= 1 && abs (p.col - col) <= multi_click_distance
    | None -> false
  in
  let time_ok = now -. t.last_click_time < multi_click_threshold in

  if is_nearby && time_ok then t.click_count <- min 3 (t.click_count + 1)
  else t.click_count <- 1 ;

  t.last_click_time <- now ;
  t.last_click_pos <- Some point ;

  (* Set selection mode based on click count *)
  t.click_mode <-
    (match t.click_count with 1 -> Single | 2 -> Word | _ -> Line) ;

  (* Set anchor and current based on mode *)
  (match t.click_mode with
  | Single ->
      t.anchor <- Some point ;
      t.current <- Some point
  | Word ->
      let get_char ~col = get_char ~row ~col in
      let start_col, end_col = find_word_bounds ~get_char ~col ~cols in
      t.anchor <- Some {row; col = start_col} ;
      t.current <- Some {row; col = end_col}
  | Line ->
      let get_char ~col = get_char ~row ~col in
      let start_col, end_col = find_line_bounds ~get_char ~col ~cols in
      t.anchor <- Some {row; col = start_col} ;
      t.current <- Some {row; col = end_col}) ;

  t.active <- true

(** Update the current selection endpoint during drag. *)
let update_selection t ~row ~col = if t.active then t.current <- Some {row; col}

(** Complete selection and return the selected text.
    Extracts characters from the buffer within the selection bounds. *)
let finish_selection t ~get_char ~cols =
  t.active <- false ;
  match get_bounds t with
  | None -> None
  | Some (start, stop) ->
      let buf = Buffer.create 256 in
      for row = start.row to stop.row do
        let col_start = if row = start.row then start.col else 0 in
        let col_end = if row = stop.row then stop.col else cols - 1 in
        for col = col_start to col_end do
          let ch = get_char ~row ~col in
          Buffer.add_string buf ch
        done ;
        (* Add newline between rows, but not after the last row *)
        if row < stop.row then Buffer.add_char buf '\n'
      done ;
      (* Trim trailing spaces from each line *)
      let text = Buffer.contents buf in
      let lines = String.split_on_char '\n' text in
      let trimmed =
        List.map
          (fun line ->
            let len = String.length line in
            let rec find_end i =
              if i <= 0 then 0
              else if line.[i - 1] <> ' ' then i
              else find_end (i - 1)
            in
            String.sub line 0 (find_end len))
          lines
      in
      Some (String.concat "\n" trimmed)

(** Clear the current selection. *)
let clear t =
  t.anchor <- None ;
  t.current <- None ;
  t.active <- false

(** Apply selection highlighting to the buffer.
    Modifies cells within selection to use reverse video style. *)
let apply_highlight t ~set_style ~rows ~cols =
  if has_selection t then
    for row = 0 to rows - 1 do
      for col = 0 to cols - 1 do
        if is_selected t ~row ~col then set_style ~row ~col ~reverse:true
      done
    done

(** Copy selection to clipboard and show optional toast. *)
let copy_to_clipboard text =
  match Clipboard.get () with Some clip -> clip.copy text | None -> ()