package miaou-core

  1. Overview
  2. Docs
Miaou core/widgets (no drivers, no SDL)

Install

dune-project
 Dependency

Authors

Maintainers

Sources

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

doc/src/miaou_widgets_input/textarea_widget.ml.html

Source file textarea_widget.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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
(******************************************************************************)
(*                                                                            *)
(* SPDX-License-Identifier: MIT                                               *)
(* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com>                 *)
(* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech>        *)
(*                                                                            *)
(******************************************************************************)

(** Multiline text input widget with cursor and scroll support. *)

open Miaou_widgets_display.Widgets

type edit_op = No_edit | Char_insert | Other_edit

type snapshot = {s_lines : string array; s_cursor_row : int; s_cursor_col : int}

type t = {
  lines : string array;  (** Content lines *)
  cursor_row : int;  (** Current line (0-indexed) *)
  cursor_col : int;  (** Column position in current line *)
  scroll_offset : int;  (** First visible line *)
  width : int;  (** Display width *)
  height : int;  (** Visible lines count *)
  title : string option;
  placeholder : string option;
  cancelled : bool;
  undo_stack : snapshot list;
  redo_stack : snapshot list;
  last_op : edit_op;
}

let undo_cap = 200

let snap_of t =
  {
    s_lines = Array.copy t.lines;
    s_cursor_row = t.cursor_row;
    s_cursor_col = t.cursor_col;
  }

let restore_snap t s =
  {
    t with
    lines = Array.copy s.s_lines;
    cursor_row = s.s_cursor_row;
    cursor_col = s.s_cursor_col;
  }

let cap_undo stack =
  let rec take n = function
    | [] -> []
    | _ when n = 0 -> []
    | x :: rest -> x :: take (n - 1) rest
  in
  if List.length stack > undo_cap then take undo_cap stack else stack

(* Push a snapshot before applying an edit, except when coalescing with
   a prior char-insert burst. Always clears redo stack. *)
let push_undo t ~op =
  if op = Char_insert && t.last_op = Char_insert then {t with redo_stack = []}
  else
    let stack = cap_undo (snap_of t :: t.undo_stack) in
    {t with undo_stack = stack; redo_stack = []}

let undo t =
  match t.undo_stack with
  | [] -> t
  | last :: rest ->
      let cur = snap_of t in
      let t' = restore_snap t last in
      {
        t' with
        undo_stack = rest;
        redo_stack = cap_undo (cur :: t.redo_stack);
        last_op = Other_edit;
      }

let redo t =
  match t.redo_stack with
  | [] -> t
  | last :: rest ->
      let cur = snap_of t in
      let t' = restore_snap t last in
      {
        t' with
        redo_stack = rest;
        undo_stack = cap_undo (cur :: t.undo_stack);
        last_op = Other_edit;
      }

let can_undo t = t.undo_stack <> []

let can_redo t = t.redo_stack <> []

let create ?title ?(width = 60) ?(height = 10) ?(initial = "") ?placeholder () =
  let lines =
    if String.length initial = 0 then [|""|]
    else
      let split = String.split_on_char '\n' initial in
      Array.of_list split
  in
  {
    lines;
    cursor_row = Array.length lines - 1;
    cursor_col = String.length lines.(Array.length lines - 1);
    scroll_offset = 0;
    width;
    height;
    title;
    placeholder;
    cancelled = false;
    undo_stack = [];
    redo_stack = [];
    last_op = No_edit;
  }

let open_centered ?title ?(width = 60) ?(height = 10) ?(initial = "")
    ?placeholder () =
  create ?title ~width ~height ~initial ?placeholder ()

(** Get current line content *)
let current_line t = t.lines.(t.cursor_row)

(** Update current line *)
let set_current_line t content =
  let lines = Array.copy t.lines in
  lines.(t.cursor_row) <- content ;
  {t with lines}

(** Insert a new line at cursor position *)
let insert_newline t =
  let line = current_line t in
  let left = String.sub line 0 t.cursor_col in
  let right =
    String.sub line t.cursor_col (String.length line - t.cursor_col)
  in
  let before = Array.sub t.lines 0 t.cursor_row in
  let after =
    Array.sub
      t.lines
      (t.cursor_row + 1)
      (Array.length t.lines - t.cursor_row - 1)
  in
  let new_lines = Array.concat [before; [|left; right|]; after] in
  let new_row = t.cursor_row + 1 in
  (* Adjust scroll if cursor would go below visible area *)
  let scroll_offset =
    if new_row >= t.scroll_offset + t.height then t.scroll_offset + 1
    else t.scroll_offset
  in
  {
    t with
    lines = new_lines;
    cursor_row = new_row;
    cursor_col = 0;
    scroll_offset;
  }

(** Delete character before cursor (backspace) *)
let backspace t =
  if t.cursor_col > 0 then
    let line = current_line t in
    let left = String.sub line 0 (t.cursor_col - 1) in
    let right =
      String.sub line t.cursor_col (String.length line - t.cursor_col)
    in
    set_current_line {t with cursor_col = t.cursor_col - 1} (left ^ right)
  else if t.cursor_row > 0 then
    (* Join with previous line *)
    let prev_line = t.lines.(t.cursor_row - 1) in
    let curr_line = current_line t in
    let new_col = String.length prev_line in
    let before = Array.sub t.lines 0 (t.cursor_row - 1) in
    let after =
      Array.sub
        t.lines
        (t.cursor_row + 1)
        (Array.length t.lines - t.cursor_row - 1)
    in
    let new_lines = Array.concat [before; [|prev_line ^ curr_line|]; after] in
    let new_row = t.cursor_row - 1 in
    let scroll_offset =
      if new_row < t.scroll_offset then max 0 (t.scroll_offset - 1)
      else t.scroll_offset
    in
    {
      t with
      lines = new_lines;
      cursor_row = new_row;
      cursor_col = new_col;
      scroll_offset;
    }
  else t

(** Delete character at cursor (delete key) *)
let delete t =
  let line = current_line t in
  if t.cursor_col < String.length line then
    let left = String.sub line 0 t.cursor_col in
    let right =
      String.sub line (t.cursor_col + 1) (String.length line - t.cursor_col - 1)
    in
    set_current_line t (left ^ right)
  else if t.cursor_row < Array.length t.lines - 1 then
    (* Join with next line *)
    let next_line = t.lines.(t.cursor_row + 1) in
    let before = Array.sub t.lines 0 t.cursor_row in
    let after =
      Array.sub
        t.lines
        (t.cursor_row + 2)
        (Array.length t.lines - t.cursor_row - 2)
    in
    let new_lines = Array.concat [before; [|line ^ next_line|]; after] in
    {t with lines = new_lines}
  else t

(** Insert character at cursor *)
let insert_char t ch =
  let line = current_line t in
  let left = String.sub line 0 t.cursor_col in
  let right =
    String.sub line t.cursor_col (String.length line - t.cursor_col)
  in
  set_current_line {t with cursor_col = t.cursor_col + 1} (left ^ ch ^ right)

(** Move cursor left *)
let move_left t =
  if t.cursor_col > 0 then {t with cursor_col = t.cursor_col - 1}
  else if t.cursor_row > 0 then
    let new_row = t.cursor_row - 1 in
    let scroll_offset =
      if new_row < t.scroll_offset then max 0 (t.scroll_offset - 1)
      else t.scroll_offset
    in
    {
      t with
      cursor_row = new_row;
      cursor_col = String.length t.lines.(new_row);
      scroll_offset;
    }
  else t

(** Move cursor right *)
let move_right t =
  let line = current_line t in
  if t.cursor_col < String.length line then
    {t with cursor_col = t.cursor_col + 1}
  else if t.cursor_row < Array.length t.lines - 1 then
    let new_row = t.cursor_row + 1 in
    let scroll_offset =
      if new_row >= t.scroll_offset + t.height then t.scroll_offset + 1
      else t.scroll_offset
    in
    {t with cursor_row = new_row; cursor_col = 0; scroll_offset}
  else t

(** Move cursor up *)
let move_up t =
  if t.cursor_row > 0 then
    let new_row = t.cursor_row - 1 in
    let new_col = min t.cursor_col (String.length t.lines.(new_row)) in
    let scroll_offset =
      if new_row < t.scroll_offset then max 0 (t.scroll_offset - 1)
      else t.scroll_offset
    in
    {t with cursor_row = new_row; cursor_col = new_col; scroll_offset}
  else {t with cursor_col = 0}

(** Move cursor down *)
let move_down t =
  if t.cursor_row < Array.length t.lines - 1 then
    let new_row = t.cursor_row + 1 in
    let new_col = min t.cursor_col (String.length t.lines.(new_row)) in
    let scroll_offset =
      if new_row >= t.scroll_offset + t.height then t.scroll_offset + 1
      else t.scroll_offset
    in
    {t with cursor_row = new_row; cursor_col = new_col; scroll_offset}
  else {t with cursor_col = String.length (current_line t)}

(** Move to start of line *)
let move_home t = {t with cursor_col = 0}

(** Move to end of line *)
let move_end t = {t with cursor_col = String.length (current_line t)}

(** Render the textarea *)
let render t ~focus:(_ : bool) =
  let total_lines = Array.length t.lines in
  let is_empty = total_lines = 1 && String.length t.lines.(0) = 0 in
  let buf = Buffer.create 256 in
  (* Title *)
  (match t.title with
  | Some title ->
      Buffer.add_string buf (titleize title) ;
      Buffer.add_char buf '\n'
  | None -> ()) ;
  (* Top border *)
  Buffer.add_string
    buf
    (themed_border ("+" ^ String.make (t.width - 2) '-' ^ "+")) ;
  Buffer.add_char buf '\n' ;
  (* Content lines *)
  for i = 0 to t.height - 1 do
    let line_idx = t.scroll_offset + i in
    Buffer.add_string buf (themed_border "|") ;
    let content =
      if is_empty && i = 0 then
        match t.placeholder with Some p -> themed_muted p | None -> ""
      else if line_idx < total_lines then
        let line = t.lines.(line_idx) in
        if line_idx = t.cursor_row then
          (* Show cursor *)
          let left =
            String.sub line 0 (min t.cursor_col (String.length line))
          in
          let right =
            if t.cursor_col < String.length line then
              String.sub line t.cursor_col (String.length line - t.cursor_col)
            else ""
          in
          left ^ "_" ^ right
        else line
      else ""
    in
    (* Pad or truncate to width *)
    let visible_len = visible_chars_count content in
    let inner_width = t.width - 2 in
    let padded =
      if visible_len >= inner_width then
        let byte_idx = visible_byte_index_of_pos content (inner_width - 1) in
        String.sub content 0 byte_idx ^ "…"
      else content ^ String.make (inner_width - visible_len) ' '
    in
    Buffer.add_string buf padded ;
    Buffer.add_string buf (themed_border "|") ;
    if i < t.height - 1 then Buffer.add_char buf '\n'
  done ;
  Buffer.add_char buf '\n' ;
  (* Bottom border *)
  Buffer.add_string
    buf
    (themed_border ("+" ^ String.make (t.width - 2) '-' ^ "+")) ;
  (* Line indicator *)
  let indicator = Printf.sprintf " Line %d/%d" (t.cursor_row + 1) total_lines in
  Buffer.add_string buf (themed_muted indicator) ;
  Buffer.contents buf

(** Handle key input *)
let on_key t ~key =
  let open Miaou_interfaces.Key_event in
  let edit ~op f =
    let t = push_undo t ~op in
    let t = f t in
    {t with last_op = op}
  in
  match key with
  | "A-Enter" | "Alt-Enter" ->
      (* Alt+Enter inserts newline *)
      (edit ~op:Other_edit insert_newline, Handled)
  | "Backspace" -> (edit ~op:Other_edit backspace, Handled)
  | "Delete" -> (edit ~op:Other_edit delete, Handled)
  | "Left" -> (move_left t, Handled)
  | "Right" -> (move_right t, Handled)
  | "Up" -> (move_up t, Handled)
  | "Down" -> (move_down t, Handled)
  | "Home" -> (move_home t, Handled)
  | "End" -> (move_end t, Handled)
  | "Esc" | "Escape" -> ({t with cancelled = true}, Handled)
  | "C-z" -> (undo t, Handled)
  | "C-y" | "C-Z" -> (redo t, Handled)
  | "WheelUp" ->
      (* Scroll up by moving view, not cursor *)
      let new_scroll =
        max 0 (t.scroll_offset - Miaou_helpers.Mouse.wheel_scroll_lines)
      in
      ({t with scroll_offset = new_scroll}, Handled)
  | "WheelDown" ->
      let max_scroll = max 0 (Array.length t.lines - t.height + 2) in
      let new_scroll =
        min max_scroll (t.scroll_offset + Miaou_helpers.Mouse.wheel_scroll_lines)
      in
      ({t with scroll_offset = new_scroll}, Handled)
  | k when String.length k = 1 ->
      (edit ~op:Char_insert (fun t -> insert_char t k), Handled)
  | key -> (
      (* Check for mouse click to position cursor *)
      match Miaou_helpers.Mouse.parse_click key with
      | Some {row; col} ->
          (* Account for box border (1 row for top border) *)
          let text_row = row - 1 + t.scroll_offset in
          let text_col = col - 1 in
          (* 1 for left border *)
          let max_row = Array.length t.lines - 1 in
          let new_row = max 0 (min max_row text_row) in
          let max_col = String.length t.lines.(new_row) in
          let new_col = max 0 (min max_col text_col) in
          ({t with cursor_row = new_row; cursor_col = new_col}, Handled)
      | None -> (t, Bubble))

let handle_key t ~key =
  let t', _ = on_key t ~key in
  t'

(** Get all text as a single string *)
let get_text t = String.concat "\n" (Array.to_list t.lines)

let value t = get_text t

(** Set text content *)
let set_text t s =
  let lines =
    if String.length s = 0 then [|""|]
    else Array.of_list (String.split_on_char '\n' s)
  in
  let cursor_row = min t.cursor_row (Array.length lines - 1) in
  let cursor_col = min t.cursor_col (String.length lines.(cursor_row)) in
  {t with lines; cursor_row; cursor_col}

let is_cancelled t = t.cancelled

let reset_cancelled t = {t with cancelled = false}

let cursor_position t = (t.cursor_row, t.cursor_col)

let line_count t = Array.length t.lines

let width t = t.width

let height t = t.height

let with_dimensions t ~width ~height =
  {t with width = max 10 width; height = max 3 height}

let () =
  Miaou_registry.register ~name:"textarea" ~mli:[%blob "textarea_widget.mli"] ()