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_main_loop.ml.html

Source file matrix_main_loop.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
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 nav_outcome 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 footer_pairs =
      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 footer_str =
      if footer_pairs = [] then ""
      else Widgets.footer_hints_wrapped_capped ~cols ~max_lines:2 footer_pairs
    in
    let footer_lines =
      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, _button) ->
        (* 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, _button) ->
        (* 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 check_navigation 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 nav ->
           let oc =
             open_out_gen [Open_append; Open_creat] 0o644 "/tmp/miaou-keys.log"
           in
           let nav_str =
             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 ())))