package miaou-core
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>
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"] ()
sectionYPositions = computeSectionYPositions($el), 10)"
x-init="setTimeout(() => sectionYPositions = computeSectionYPositions($el), 10)"
>