Legend:
Page
Library
Module
Module type
Parameter
Class
Class type
Source
Source file textarea_widget.ml
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437(******************************************************************************)(* *)(* 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. *)openMiaou_widgets_display.Widgetstypeedit_op=No_edit|Char_insert|Other_edittypesnapshot={s_lines:stringarray;s_cursor_row:int;s_cursor_col:int}typet={lines:stringarray;(** 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:stringoption;placeholder:stringoption;cancelled:bool;undo_stack:snapshotlist;redo_stack:snapshotlist;last_op:edit_op;}letundo_cap=200letsnap_oft={s_lines=Array.copyt.lines;s_cursor_row=t.cursor_row;s_cursor_col=t.cursor_col;}letrestore_snapts={twithlines=Array.copys.s_lines;cursor_row=s.s_cursor_row;cursor_col=s.s_cursor_col;}letcap_undostack=letrectaken=function|[]->[]|_whenn=0->[]|x::rest->x::take(n-1)restinifList.lengthstack>undo_capthentakeundo_capstackelsestack(* Push a snapshot before applying an edit, except when coalescing with
a prior char-insert burst. Always clears redo stack. *)letpush_undot~op=ifop=Char_insert&&t.last_op=Char_insertthen{twithredo_stack=[]}elseletstack=cap_undo(snap_oft::t.undo_stack)in{twithundo_stack=stack;redo_stack=[]}letundot=matcht.undo_stackwith|[]->t|last::rest->letcur=snap_oftinlett'=restore_snaptlastin{t'withundo_stack=rest;redo_stack=cap_undo(cur::t.redo_stack);last_op=Other_edit;}letredot=matcht.redo_stackwith|[]->t|last::rest->letcur=snap_oftinlett'=restore_snaptlastin{t'withredo_stack=rest;undo_stack=cap_undo(cur::t.undo_stack);last_op=Other_edit;}letcan_undot=t.undo_stack<>[]letcan_redot=t.redo_stack<>[]letcreate?title?(width=60)?(height=10)?(initial="")?placeholder()=letlines=ifString.lengthinitial=0then[|""|]elseletsplit=String.split_on_char'\n'initialinArray.of_listsplitin{lines;cursor_row=Array.lengthlines-1;cursor_col=String.lengthlines.(Array.lengthlines-1);scroll_offset=0;width;height;title;placeholder;cancelled=false;undo_stack=[];redo_stack=[];last_op=No_edit;}letopen_centered?title?(width=60)?(height=10)?(initial="")?placeholder()=create?title~width~height~initial?placeholder()(** Get current line content *)letcurrent_linet=t.lines.(t.cursor_row)(** Update current line *)letset_current_linetcontent=letlines=Array.copyt.linesinlines.(t.cursor_row)<-content;{twithlines}(** Insert a new line at cursor position *)letinsert_newlinet=letline=current_linetinletleft=String.subline0t.cursor_colinletright=String.sublinet.cursor_col(String.lengthline-t.cursor_col)inletbefore=Array.subt.lines0t.cursor_rowinletafter=Array.subt.lines(t.cursor_row+1)(Array.lengtht.lines-t.cursor_row-1)inletnew_lines=Array.concat[before;[|left;right|];after]inletnew_row=t.cursor_row+1in(* Adjust scroll if cursor would go below visible area *)letscroll_offset=ifnew_row>=t.scroll_offset+t.heightthent.scroll_offset+1elset.scroll_offsetin{twithlines=new_lines;cursor_row=new_row;cursor_col=0;scroll_offset;}(** Delete character before cursor (backspace) *)letbackspacet=ift.cursor_col>0thenletline=current_linetinletleft=String.subline0(t.cursor_col-1)inletright=String.sublinet.cursor_col(String.lengthline-t.cursor_col)inset_current_line{twithcursor_col=t.cursor_col-1}(left^right)elseift.cursor_row>0then(* Join with previous line *)letprev_line=t.lines.(t.cursor_row-1)inletcurr_line=current_linetinletnew_col=String.lengthprev_lineinletbefore=Array.subt.lines0(t.cursor_row-1)inletafter=Array.subt.lines(t.cursor_row+1)(Array.lengtht.lines-t.cursor_row-1)inletnew_lines=Array.concat[before;[|prev_line^curr_line|];after]inletnew_row=t.cursor_row-1inletscroll_offset=ifnew_row<t.scroll_offsetthenmax0(t.scroll_offset-1)elset.scroll_offsetin{twithlines=new_lines;cursor_row=new_row;cursor_col=new_col;scroll_offset;}elset(** Delete character at cursor (delete key) *)letdeletet=letline=current_linetinift.cursor_col<String.lengthlinethenletleft=String.subline0t.cursor_colinletright=String.subline(t.cursor_col+1)(String.lengthline-t.cursor_col-1)inset_current_linet(left^right)elseift.cursor_row<Array.lengtht.lines-1then(* Join with next line *)letnext_line=t.lines.(t.cursor_row+1)inletbefore=Array.subt.lines0t.cursor_rowinletafter=Array.subt.lines(t.cursor_row+2)(Array.lengtht.lines-t.cursor_row-2)inletnew_lines=Array.concat[before;[|line^next_line|];after]in{twithlines=new_lines}elset(** Insert character at cursor *)letinsert_chartch=letline=current_linetinletleft=String.subline0t.cursor_colinletright=String.sublinet.cursor_col(String.lengthline-t.cursor_col)inset_current_line{twithcursor_col=t.cursor_col+1}(left^ch^right)(** Move cursor left *)letmove_leftt=ift.cursor_col>0then{twithcursor_col=t.cursor_col-1}elseift.cursor_row>0thenletnew_row=t.cursor_row-1inletscroll_offset=ifnew_row<t.scroll_offsetthenmax0(t.scroll_offset-1)elset.scroll_offsetin{twithcursor_row=new_row;cursor_col=String.lengtht.lines.(new_row);scroll_offset;}elset(** Move cursor right *)letmove_rightt=letline=current_linetinift.cursor_col<String.lengthlinethen{twithcursor_col=t.cursor_col+1}elseift.cursor_row<Array.lengtht.lines-1thenletnew_row=t.cursor_row+1inletscroll_offset=ifnew_row>=t.scroll_offset+t.heightthent.scroll_offset+1elset.scroll_offsetin{twithcursor_row=new_row;cursor_col=0;scroll_offset}elset(** Move cursor up *)letmove_upt=ift.cursor_row>0thenletnew_row=t.cursor_row-1inletnew_col=mint.cursor_col(String.lengtht.lines.(new_row))inletscroll_offset=ifnew_row<t.scroll_offsetthenmax0(t.scroll_offset-1)elset.scroll_offsetin{twithcursor_row=new_row;cursor_col=new_col;scroll_offset}else{twithcursor_col=0}(** Move cursor down *)letmove_downt=ift.cursor_row<Array.lengtht.lines-1thenletnew_row=t.cursor_row+1inletnew_col=mint.cursor_col(String.lengtht.lines.(new_row))inletscroll_offset=ifnew_row>=t.scroll_offset+t.heightthent.scroll_offset+1elset.scroll_offsetin{twithcursor_row=new_row;cursor_col=new_col;scroll_offset}else{twithcursor_col=String.length(current_linet)}(** Move to start of line *)letmove_homet={twithcursor_col=0}(** Move to end of line *)letmove_endt={twithcursor_col=String.length(current_linet)}(** Render the textarea *)letrendert~focus:(_:bool)=lettotal_lines=Array.lengtht.linesinletis_empty=total_lines=1&&String.lengtht.lines.(0)=0inletbuf=Buffer.create256in(* Title *)(matcht.titlewith|Sometitle->Buffer.add_stringbuf(titleizetitle);Buffer.add_charbuf'\n'|None->());(* Top border *)Buffer.add_stringbuf(themed_border("+"^String.make(t.width-2)'-'^"+"));Buffer.add_charbuf'\n';(* Content lines *)fori=0tot.height-1doletline_idx=t.scroll_offset+iinBuffer.add_stringbuf(themed_border"|");letcontent=ifis_empty&&i=0thenmatcht.placeholderwithSomep->themed_mutedp|None->""elseifline_idx<total_linesthenletline=t.lines.(line_idx)inifline_idx=t.cursor_rowthen(* Show cursor *)letleft=String.subline0(mint.cursor_col(String.lengthline))inletright=ift.cursor_col<String.lengthlinethenString.sublinet.cursor_col(String.lengthline-t.cursor_col)else""inleft^"_"^rightelselineelse""in(* Pad or truncate to width *)letvisible_len=visible_chars_countcontentinletinner_width=t.width-2inletpadded=ifvisible_len>=inner_widththenletbyte_idx=visible_byte_index_of_poscontent(inner_width-1)inString.subcontent0byte_idx^"…"elsecontent^String.make(inner_width-visible_len)' 'inBuffer.add_stringbufpadded;Buffer.add_stringbuf(themed_border"|");ifi<t.height-1thenBuffer.add_charbuf'\n'done;Buffer.add_charbuf'\n';(* Bottom border *)Buffer.add_stringbuf(themed_border("+"^String.make(t.width-2)'-'^"+"));(* Line indicator *)letindicator=Printf.sprintf" Line %d/%d"(t.cursor_row+1)total_linesinBuffer.add_stringbuf(themed_mutedindicator);Buffer.contentsbuf(** Handle key input *)leton_keyt~key=letopenMiaou_interfaces.Key_eventinletedit~opf=lett=push_undot~opinlett=ftin{twithlast_op=op}inmatchkeywith|"A-Enter"|"Alt-Enter"->(* Alt+Enter inserts newline *)(edit~op:Other_editinsert_newline,Handled)|"Backspace"->(edit~op:Other_editbackspace,Handled)|"Delete"->(edit~op:Other_editdelete,Handled)|"Left"->(move_leftt,Handled)|"Right"->(move_rightt,Handled)|"Up"->(move_upt,Handled)|"Down"->(move_downt,Handled)|"Home"->(move_homet,Handled)|"End"->(move_endt,Handled)|"Esc"|"Escape"->({twithcancelled=true},Handled)|"C-z"->(undot,Handled)|"C-y"|"C-Z"->(redot,Handled)|"WheelUp"->(* Scroll up by moving view, not cursor *)letnew_scroll=max0(t.scroll_offset-Miaou_helpers.Mouse.wheel_scroll_lines)in({twithscroll_offset=new_scroll},Handled)|"WheelDown"->letmax_scroll=max0(Array.lengtht.lines-t.height+2)inletnew_scroll=minmax_scroll(t.scroll_offset+Miaou_helpers.Mouse.wheel_scroll_lines)in({twithscroll_offset=new_scroll},Handled)|kwhenString.lengthk=1->(edit~op:Char_insert(funt->insert_chartk),Handled)|key->((* Check for mouse click to position cursor *)matchMiaou_helpers.Mouse.parse_clickkeywith|Some{row;col}->(* Account for box border (1 row for top border) *)lettext_row=row-1+t.scroll_offsetinlettext_col=col-1in(* 1 for left border *)letmax_row=Array.lengtht.lines-1inletnew_row=max0(minmax_rowtext_row)inletmax_col=String.lengtht.lines.(new_row)inletnew_col=max0(minmax_coltext_col)in({twithcursor_row=new_row;cursor_col=new_col},Handled)|None->(t,Bubble))lethandle_keyt~key=lett',_=on_keyt~keyint'(** Get all text as a single string *)letget_textt=String.concat"\n"(Array.to_listt.lines)letvaluet=get_textt(** Set text content *)letset_textts=letlines=ifString.lengths=0then[|""|]elseArray.of_list(String.split_on_char'\n's)inletcursor_row=mint.cursor_row(Array.lengthlines-1)inletcursor_col=mint.cursor_col(String.lengthlines.(cursor_row))in{twithlines;cursor_row;cursor_col}letis_cancelledt=t.cancelledletreset_cancelledt={twithcancelled=false}letcursor_positiont=(t.cursor_row,t.cursor_col)letline_countt=Array.lengtht.linesletwidtht=t.widthletheightt=t.heightletwith_dimensionst~width~height={twithwidth=max10width;height=max3height}let()=Miaou_registry.register~name:"textarea"~mli:[%blob"textarea_widget.mli"]()