Legend:
Page
Library
Module
Module type
Parameter
Class
Class type
Source
Page
Library
Module
Module type
Parameter
Class
Class type
Source
matrix_main_loop.ml1 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 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615(*****************************************************************************) (* *) (* SPDX-License-Identifier: MIT *) (* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com> *) (* *) (*****************************************************************************) [@@@warning "-32-34-37"] open Miaou_core module Narrow_modal = Miaou_core.Narrow_modal module Logger_capability = Miaou_interfaces.Logger_capability module Clock = Miaou_interfaces.Clock module Timer = Miaou_interfaces.Timer module Clipboard = Miaou_interfaces.Clipboard module Fibers = Miaou_helpers.Fiber_runtime module Widgets = Miaou_widgets_display.Widgets module Style_context = Miaou_style.Style_context module Theme_loader = Miaou_style.Theme_loader (* One-time narrow terminal warning flag *) let narrow_warned = ref false (* Debug overlay - shows FPS/TPS when MIAOU_OVERLAY is set *) let overlay_enabled = lazy (match Sys.getenv_opt "MIAOU_OVERLAY" with | Some ("1" | "true" | "TRUE" | "yes" | "YES") -> true | _ -> false) type tps_tracker = { mutable tick_count : int; mutable last_time : float; mutable current_tps : float; } let create_tps_tracker () = {tick_count = 0; last_time = Unix.gettimeofday (); current_tps = 0.0} let update_tps tracker = tracker.tick_count <- tracker.tick_count + 1 ; let now = Unix.gettimeofday () in let elapsed = now -. tracker.last_time in if elapsed >= 1.0 then begin tracker.current_tps <- float_of_int tracker.tick_count /. elapsed ; tracker.tick_count <- 0 ; tracker.last_time <- now end let render_overlay_text ~loop_fps ~render_fps ~tps ~cols (ops : Matrix_buffer.batch_ops) = let text = Printf.sprintf "matrix L:%.0f R:%.0f T:%.0f" loop_fps render_fps tps in let len = String.length text in let start_col = cols - len - 1 in if start_col > 0 then begin let style = {Matrix_cell.default_style with dim = true; fg = 245} in String.iteri (fun i c -> ops.set_char ~row:0 ~col:(start_col + i) ~char:(String.make 1 c) ~style) text end (* Pack page and state together to avoid GADT escaping issues *) type packed_state = | Packed : (module Tui_page.PAGE_SIG with type state = 's) * 's Navigation.t -> packed_state type context = { config : Matrix_config.t; buffer : Matrix_buffer.t; parser : Matrix_ansi_parser.t; render_loop : Matrix_render_loop.t; io : Matrix_io.t; } let eio_sleep env seconds = if seconds > 0.001 then Eio.Time.sleep env#clock seconds let run ctx ~(env : Eio_unix.Stdenv.base) (initial_page : (module Tui_page.PAGE_SIG)) : [`Quit | `Back | `SwitchTo of string] = let tick_time_s = ctx.config.tick_time_ms /. 1000.0 in (* TPS tracker for debug overlay *) let tps_tracker = create_tps_tracker () in (* Track modal state to trigger full redraw on modal open/close *) let last_modal_active = ref false in (* Frame counter for periodic partial refresh (doesn't reset like tick_count) *) let frame_counter = ref 0 in (* Track last size for narrow warning detection *) let last_size = let rows, cols = ctx.io.size () in ref {LTerm_geom.rows; cols} in (* Esc cooldown: after closing a modal with Esc, suppress further Esc keys for a short period to prevent key-repeat from reaching the underlying page (e.g. causing the app to quit when the user holds Esc to close a modal). *) let esc_cooldown_until = ref 0.0 in let esc_cooldown_s = 0.2 in (* Clock capability — provides dt/now/elapsed to pages and widgets *) let clock_state = Clock.create_state () in Clock.register clock_state ; (* Timer capability — page-scoped periodic/one-shot callbacks *) let timer_state = Timer.create_state () in Timer.register timer_state ; (* Clipboard capability — copy text via OSC 52 *) Clipboard.register ~write:ctx.io.write () ; (* Text selection state for mouse-based copy *) let selection = Matrix_selection.create () in (* Convert a packed state with pending navigation into the loop outcome. *) let packed = let (Packed ((module Page), ps)) = packed in ignore (Page.init, ps) ; match Navigation.pending ps with | Some Navigation.Quit -> Matrix_render_loop.shutdown ctx.render_loop ; `Quit | Some Navigation.Back -> Matrix_render_loop.shutdown ctx.render_loop ; `Back | Some (Navigation.Goto name) -> `SwitchTo name | None -> (* Should not happen — caller checks pending first *) `Quit in (* Main loop *) let rec loop packed = let tick_start = Unix.gettimeofday () in Clock.tick clock_state ; Timer.tick timer_state ; let (Packed ((module Page), ps)) = packed in (* Get current size *) let rows, cols = ctx.io.size () in let size = {LTerm_geom.rows; cols} in (* Check if we need to resize buffer *) let buf_rows, buf_cols = Matrix_buffer.size ctx.buffer in if rows <> buf_rows || cols <> buf_cols then begin Matrix_buffer.resize ctx.buffer ~rows ~cols ; Matrix_buffer.mark_all_dirty ctx.buffer end ; (* One-time narrow terminal warning (only once per session) *) let prev_cols = !last_size.LTerm_geom.cols in if (cols < 80 && not !narrow_warned) || (cols < 80 && prev_cols >= 80 && not !narrow_warned) then ( (match Logger_capability.get () with | Some logger -> logger.logf Warning (Printf.sprintf "WIDTH_CROSSING: prev=%d new=%d (showing narrow modal)" prev_cols cols) | None -> ()) ; narrow_warned := true ; Modal_manager.push (module Narrow_modal.Page) ~init:(Narrow_modal.Page.init ()) ~ui: { title = "Narrow terminal"; left = Some 2; max_width = None; dim_background = true; } ~commit_on:[] ~cancel_on:[] ~on_close:(fun (_ : Narrow_modal.Page.pstate) _ -> ()) ; Modal_manager.set_consume_next_key () ; let my_title = "Narrow terminal" in Fibers.spawn (fun env -> Eio.Time.sleep env#clock 5.0 ; match Modal_manager.top_title_opt () with | Some t when t = my_title -> Modal_manager.close_top `Cancel | _ -> ())) ; last_size := size ; (* Build header lines for narrow terminal warning banner *) let header_lines = if cols < 80 then [ Miaou_widgets_display.Widgets.warning_banner ~cols (Printf.sprintf "Narrow terminal: %d cols (< 80). Some UI may be truncated." cols); ] else [] in (* Build footer hints from page key_hints *) let = let hints = Page.key_hints ps in if hints <> [] then List.map (fun (h : Miaou_core.Tui_page.key_hint) -> (h.key, h.help)) hints else [] in let = if footer_pairs = [] then "" else Widgets.footer_hints_wrapped_capped ~cols ~max_lines:2 footer_pairs in let = if footer_str = "" then 0 else List.length (String.split_on_char '\n' footer_str) in (* Reduce available rows for the page view to leave room for footer *) let view_size = if footer_lines > 0 then {size with LTerm_geom.rows = max 1 (rows - footer_lines)} else size in (* Render page view to ANSI string *) let view_output = Page.view ps ~focus:true ~size:view_size in (* Append footer if present *) let view_output = if footer_str = "" then view_output else view_output ^ "\n" ^ footer_str in (* Prepend header lines if any *) let view_output = match header_lines with | [] -> view_output | lines -> String.concat "\n" lines ^ "\n" ^ view_output in (* Check for modal state change *) let modal_active = Modal_manager.has_active () in let modal_just_changed = modal_active <> !last_modal_active in if modal_just_changed then last_modal_active := modal_active ; (* Render modal overlay if active *) let view_output = if modal_active then match Miaou_internals.Modal_renderer.render_overlay ~cols:(Some cols) ~base:view_output ~rows () with | Some v -> v | None -> view_output else view_output in (* Update TPS tracker *) update_tps tps_tracker ; (* Apply themed foreground to any text without explicit foreground color. This ensures visibility in light themes where terminal default fg may be white. *) let view_output = Miaou_widgets_display.Widgets.apply_themed_foreground view_output in (* Apply themed background to fill the terminal with theme's background color *) let view_output = Miaou_widgets_display.Widgets.apply_themed_background ~rows ~cols view_output in (* Update back buffer with new view - thread-safe batch operation *) Matrix_buffer.with_back_buffer ctx.buffer (fun ops -> (* Clear back buffer *) ops.clear () ; (* Parse ANSI output into buffer using batch set_char *) Matrix_ansi_parser.reset ctx.parser ; let _ = Matrix_ansi_parser.parse_into_batch ctx.parser ops ~row:0 ~col:0 view_output in (* Apply selection highlight overlay *) if Matrix_selection.has_selection selection then begin let set_style ~row ~col ~reverse = if row >= 0 && row < ops.rows && col >= 0 && col < ops.cols then let cell = ops.get ~row ~col in ops.set_char ~row ~col ~char:cell.Matrix_cell.char ~style:{cell.style with reverse} in Matrix_selection.apply_highlight selection ~set_style ~rows:ops.rows ~cols:ops.cols end ; (* Render debug overlay if enabled *) if Lazy.force overlay_enabled then render_overlay_text ~loop_fps:(Matrix_render_loop.loop_fps ctx.render_loop) ~render_fps:(Matrix_render_loop.current_fps ctx.render_loop) ~tps:tps_tracker.current_tps ~cols ops) ; (* On modal state change (open OR close), mark all cells dirty so the render domain re-emits every cell on the next frame. No screen clear or synchronous render needed: the diff-based renderer handles transitions correctly, and the render domain is the sole terminal writer. *) if modal_just_changed then Matrix_buffer.mark_all_dirty ctx.buffer ; (* Periodic scrub: re-emit every cell to correct any accumulated drift (e.g. wide-char artefacts, external writes). We only mark dirty here — the render domain picks it up within one frame, keeping it the sole terminal writer and avoiding the concurrent-write race that caused visible flicker. *) incr frame_counter ; if ctx.config.scrub_interval_frames > 0 && !frame_counter mod ctx.config.scrub_interval_frames = 0 then Matrix_buffer.mark_all_dirty ctx.buffer ; (* Drain all pending input events from the queue and process them sequentially. When the queue is empty we still run one tick (service_cycle / refresh) and sleep for the remainder of the budget. *) let events = ctx.io.drain () in process_events (Packed ((module Page), ps)) events tick_start size and process_events packed events tick_start size = match events with | [] -> (* No events — run service_cycle and sleep for remainder of tick *) let (Packed ((module Page), ps)) = packed in let ps' = Page.service_cycle (Page.refresh ps) 0 in check_navigation (Packed ((module Page), ps')) tick_start | ev :: rest -> ( match handle_single_event packed ev size with | `Continue packed' -> process_events packed' rest tick_start size | `Exit result -> result) and handle_single_event packed event size = let (Packed ((module Page), ps)) = packed in let rows = size.LTerm_geom.rows in let cols = size.LTerm_geom.cols in match event with | Matrix_io.Quit -> Matrix_render_loop.shutdown ctx.render_loop ; `Exit `Quit | Matrix_io.Resize -> ctx.io.invalidate_size_cache () ; (* Clear display on resize to avoid artifacts from old layout *) ctx.io.write "\027[2J\027[H" ; Matrix_buffer.mark_all_dirty ctx.buffer ; `Continue packed | Matrix_io.Refresh -> let ps' = Page.service_cycle (Page.refresh ps) 0 in `Continue (Packed ((module Page), ps')) | Matrix_io.Idle -> `Continue packed | Matrix_io.Key key -> (* Debug: log received key if MIAOU_DEBUG is set *) if Sys.getenv_opt "MIAOU_DEBUG" = Some "1" then ( let oc = open_out_gen [Open_append; Open_creat] 0o644 "/tmp/miaou-keys.log" in Printf.fprintf oc "Key received: %S, modal_active=%b, has_modal=%b\n%!" key (Modal_manager.has_active ()) (Page.has_modal ps) ; close_out oc) ; (* Ctrl+C always quits, regardless of modal state *) if key = "C-c" then ( Matrix_render_loop.shutdown ctx.render_loop ; `Exit `Quit) else ( (* Set modal size before handling keys *) Modal_manager.set_current_size rows cols ; (* Helper: detect whether the transient narrow modal is currently active *) let is_narrow_modal_active () = match Modal_manager.top_title_opt () with | Some t when t = "Narrow terminal" -> true | _ -> false in let packed' = (* Check if modal is active - if so, send keys to modal instead of page *) if Modal_manager.has_active () then begin (* If the narrow modal is active, close it on any key *) if is_narrow_modal_active () then Modal_manager.close_top `Cancel else Modal_manager.handle_key key ; (* If modal was closed by Esc, activate cooldown *) if (key = "Esc" || key = "Escape") && not (Modal_manager.has_active ()) then esc_cooldown_until := Unix.gettimeofday () +. esc_cooldown_s ; (* After modal handles key, run service_cycle *) let ps' = Page.service_cycle (Page.refresh ps) 0 in Packed ((module Page), ps') end else if Page.has_modal ps then begin (* Page has its own modal - use page's modal key handler *) let ps' = match Keys.of_string key with | Some typed_key -> let ps', _result = Page.on_modal_key ps typed_key ~size in ps' | None -> (* Fallback to legacy handle_modal_key for unparseable keys *) Page.handle_modal_key ps key ~size in (* If modal was closed by Esc, activate cooldown *) if (key = "Esc" || key = "Escape") && not (Page.has_modal ps') then esc_cooldown_until := Unix.gettimeofday () +. esc_cooldown_s ; Packed ((module Page), ps') end else if (* Suppress Esc keys during cooldown period after modal close *) (key = "Esc" || key = "Escape") && Unix.gettimeofday () < !esc_cooldown_until then packed else (* All keys go through on_key *) let ps' = match Keys.of_string key with | Some typed_key -> let ps', _result = Page.on_key ps typed_key ~size in ps' | None -> (* Fallback to legacy handle_key for unparseable keys *) Page.handle_key ps key ~size in Packed ((module Page), ps') in (* Check navigation after each key — if a key triggers navigation, stop processing remaining events in this tick *) let (Packed ((module Page2), ps2)) = packed' in let ps2 = match Modal_manager.take_pending_navigation () with | Some (Navigation.Goto page) -> Navigation.goto page ps2 | Some Navigation.Back -> Navigation.back ps2 | Some Navigation.Quit -> Navigation.quit ps2 | None -> ps2 in let next = Navigation.pending ps2 in match next with | Some _ -> (* Navigation requested — stop processing further events *) `Exit (nav_outcome (Packed ((module Page2), ps2))) | None -> `Continue (Packed ((module Page2), ps2))) | Matrix_io.MousePress (row, col, ) -> (* Mouse press starts a new selection (with double/triple click detection). No mark_all_dirty needed - the selection highlight will be rendered naturally on the next tick via apply_highlight in the back buffer. *) let get_char ~row ~col = let cell = Matrix_buffer.get_front ctx.buffer ~row ~col in cell.Matrix_cell.char in Matrix_selection.start_selection selection ~row ~col ~get_char ~cols ; `Continue packed | Matrix_io.Mouse (row, col, ) -> (* Mouse release: check if this is a click or a text selection. For text selection to be valid, user must have dragged (anchor != current). Single clicks and multi-clicks (double/triple) without drag are passed to widgets. *) Matrix_selection.update_selection selection ~row ~col ; let is_text_selection = Matrix_selection.is_active selection && (not (Matrix_selection.is_single_point selection)) && not (Matrix_selection.is_multi_click selection) in if is_text_selection then begin (* This was a drag (text selection) - finish and copy. No mark_all_dirty needed - clearing the selection means apply_highlight won't add reverse style on next tick, and the diff will naturally update those cells. *) let get_char ~row ~col = let cell = Matrix_buffer.get_front ctx.buffer ~row ~col in cell.Matrix_cell.char in (match Matrix_selection.finish_selection selection ~get_char ~cols with | Some text when String.length text > 0 -> Matrix_selection.copy_to_clipboard text | _ -> ()) ; Matrix_selection.clear selection ; `Continue packed end else begin (* Click (single, double, or triple) - dispatch to page. No mark_all_dirty needed - selection was single-point or multi-click, any minimal highlight will be cleared naturally on next tick. *) let click_count = Matrix_selection.click_count selection in Matrix_selection.clear selection ; (* Use DoubleClick/TripleClick prefix for multi-clicks *) let mouse_key = match click_count with | 2 -> Printf.sprintf "DoubleClick:%d:%d" row col | 3 -> Printf.sprintf "TripleClick:%d:%d" row col | _ -> Printf.sprintf "Mouse:%d:%d" row col in Modal_manager.set_current_size rows cols ; let packed' = if Modal_manager.has_active () then begin Modal_manager.handle_key mouse_key ; let ps' = Page.service_cycle (Page.refresh ps) 0 in Packed ((module Page), ps') end else if Page.has_modal ps then let ps' = Page.handle_modal_key ps mouse_key ~size in Packed ((module Page), ps') else let ps' = Page.handle_key ps mouse_key ~size in Packed ((module Page), ps') in `Continue packed' end | Matrix_io.MouseDrag (row, col) -> (* Mouse drag updates the selection if active. No mark_all_dirty needed - selection bounds update is picked up naturally by apply_highlight on next tick. The diff will detect cells that gained/lost reverse style and update them. *) if Matrix_selection.is_active selection then begin Matrix_selection.update_selection selection ~row ~col ; `Continue packed end else begin (* No active selection - send drag key to page *) let drag_key = Printf.sprintf "MouseDrag:%d:%d" row col in Modal_manager.set_current_size rows cols ; let packed' = if Modal_manager.has_active () then begin Modal_manager.handle_key drag_key ; let ps' = Page.service_cycle (Page.refresh ps) 0 in Packed ((module Page), ps') end else if Page.has_modal ps then let ps' = Page.handle_modal_key ps drag_key ~size in Packed ((module Page), ps') else let ps' = Page.handle_key ps drag_key ~size in Packed ((module Page), ps') in `Continue packed' end and packed tick_start = let (Packed ((module Page), ps)) = packed in (* Check for pending navigation from modal callbacks *) let ps = match Modal_manager.take_pending_navigation () with | Some (Navigation.Goto page) -> Navigation.goto page ps | Some Navigation.Back -> Navigation.back ps | Some Navigation.Quit -> Navigation.quit ps | None -> ps in let next = Navigation.pending ps in (* Debug: log navigation if MIAOU_DEBUG is set *) (if Sys.getenv_opt "MIAOU_DEBUG" = Some "1" then match next with | Some -> let oc = open_out_gen [Open_append; Open_creat] 0o644 "/tmp/miaou-keys.log" in let = match nav with | Navigation.Goto name -> Printf.sprintf "Goto %S" name | Navigation.Back -> "Back" | Navigation.Quit -> "Quit" in Printf.fprintf oc "Navigation requested: %s\n%!" nav_str ; close_out oc | None -> ()) ; match next with | Some Navigation.Quit -> Matrix_render_loop.shutdown ctx.render_loop ; `Quit | Some Navigation.Back -> Matrix_render_loop.shutdown ctx.render_loop ; `Back | Some (Navigation.Goto name) -> `SwitchTo name | None -> (* Maintain TPS by sleeping if we have time left *) let elapsed = Unix.gettimeofday () -. tick_start in let sleep_time = tick_time_s -. elapsed in eio_sleep env sleep_time ; loop (Packed ((module Page), ps)) in (* Start with initial page *) let (module P) = initial_page in (* Load theme and wrap entire loop in mutable theme context. This ensures both page views AND modal rendering inherit the theme, and allows runtime theme updates via Style_context.set_theme. *) let theme = Theme_loader.load () in Style_context.with_mutable_theme theme (fun () -> loop (Packed ((module P), P.init ())))