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_layout/file_browser_widget.ml.html

Source file file_browser_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
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
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
(*****************************************************************************)
(*                                                                           *)
(* SPDX-License-Identifier: MIT                                              *)
(* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com>                *)
(* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech>       *)
(*                                                                           *)
(*****************************************************************************)
[@@@warning "-32-34-37-69"]

type entry = {name : string; is_dir : bool}

(* Cache for directory listings and writable status to avoid repeated filesystem calls *)
type cache = {
  mutable cached_path : string;
  mutable cached_entries : entry list;
  mutable cached_writable : (string, bool) Hashtbl.t;
  mutable cached_show_hidden : bool;
}

let make_cache () =
  {
    cached_path = "";
    cached_entries = [];
    cached_writable = Hashtbl.create 32;
    cached_show_hidden = false;
  }

(* Global cache - invalidated when path changes *)
let cache = make_cache ()

let invalidate_cache () =
  cache.cached_path <- "" ;
  cache.cached_entries <- [] ;
  Hashtbl.clear cache.cached_writable

type t = {
  current_path : string;
  cursor : int;
  cancelled : bool;
  dirs_only : bool;
  require_writable : bool;
  select_dirs : bool;
  show_hidden : bool; (* whether to show hidden files/dirs starting with '.' *)
  (* New: direct path editing *)
  mode : mode;
  path_buffer : string;
      (* kept for compatibility; source of truth is textbox when present *)
  path_error : string option;
  pending_selection : string option;
  create_dir_on_enter : bool;
  textbox : Miaou_widgets_input.Textbox_widget.t option;
  history : string list; (* most-recent-first *)
  history_idx : int option; (* index into history while editing *)
}

and mode = Browsing | EditingPath

module Textbox = Miaou_widgets_input.Textbox_widget
module Helpers = Miaou_helpers.Helpers

let textbox_create ?(width = 60) ?(initial = "") () =
  Textbox.create ~width ~initial ()

let textbox_get_text = Textbox.get_text

let textbox_set_text tb text = Textbox.set_text tb text

let textbox_handle_key tb ~key = Textbox.handle_key tb ~key

let textbox_render tb = Textbox.render tb ~focus:true

let key_hints w =
  let hidden_hint = if w.show_hidden then "hide hidden" else "show hidden" in
  [
    ("↑/↓", "navigate");
    ("PgUp/PgDn", "page");
    ("Space", "select");
    ("Enter", "open");
    ("Backspace", "parent");
    ("Tab", "edit path");
    ("h", hidden_hint);
    ("n", "new directory");
    ("Esc", "cancel");
  ]

(* Global state to handle async updates from modal callbacks *)
let pending_path_update : string option ref = ref None

(* Function to apply any pending path updates *)
let apply_pending_updates browser =
  match !pending_path_update with
  | None -> browser
  | Some path ->
      pending_path_update := None ;
      {browser with current_path = path; cursor = 0}

(* Function to schedule a path update for later application *)
let schedule_path_update path = pending_path_update := Some path

let rec normalize_start p =
  let sys = Miaou_interfaces.System.require () in
  try
    if sys.file_exists p && sys.is_directory p then
      (* Prefer an absolute, canonical path when available so that navigating
         up keeps working (e.g. avoid getting stuck at "./"). *)
      try Unix.realpath p with _ -> p
    else
      let parent = Filename.dirname p in
      if parent = p then "/" else normalize_start parent
  with _ -> "/"

let open_centered ?(path = "/") ?(dirs_only = true) ?(require_writable = true)
    ?(select_dirs = true) ?(show_hidden = false) () =
  let start = normalize_start path in
  {
    current_path = start;
    cursor = 0;
    cancelled = false;
    dirs_only;
    require_writable;
    select_dirs;
    show_hidden;
    mode = Browsing;
    path_buffer = "";
    path_error = None;
    pending_selection = None;
    create_dir_on_enter = false;
    textbox = None;
    history = [];
    history_idx = None;
  }

let clamp = List_nav.clamp

let is_writable path =
  match Hashtbl.find_opt cache.cached_writable path with
  | Some v -> v
  | None ->
      let sys = Miaou_interfaces.System.require () in
      let result =
        match sys.probe_writable ~path with Ok b -> b | Error _ -> false
      in
      Hashtbl.add cache.cached_writable path result ;
      result

let rec next_available_name ~existing ~prefix idx =
  let candidate =
    if idx = 0 then prefix else Printf.sprintf "%s_%d" prefix idx
  in
  if List.exists (fun (e : entry) -> e.name = candidate) existing then
    next_available_name ~existing ~prefix (idx + 1)
  else candidate

let is_hidden name =
  String.length name > 0 && name.[0] = '.' && name <> ".." && name <> "."

let list_entries path ~dirs_only ~show_hidden =
  let sys = Miaou_interfaces.System.require () in
  match sys.list_dir path with
  | Error e -> Error e
  | Ok items ->
      let items = List.filter (fun n -> n <> ".") items in
      let items = List.sort String.compare items in
      let mapped =
        List.fold_right
          (fun n acc ->
            (* Filter hidden files unless show_hidden is true *)
            if (not show_hidden) && is_hidden n then acc
            else
              let full = Filename.concat path n in
              let is_dir = try sys.is_directory full with _ -> false in
              if dirs_only && not is_dir then acc else {name = n; is_dir} :: acc)
          items
          []
      in
      let dirs, files = List.partition (fun e -> e.is_dir) mapped in
      Ok (dirs @ files)

(* Internal helper that returns empty list on error for backward compatibility *)
let list_entries_safe path ~dirs_only ~show_hidden =
  match list_entries path ~dirs_only ~show_hidden with
  | Ok entries -> entries
  | Error _ -> []

let list_entries_with_parent path ~dirs_only ~show_hidden =
  (* Use cached entries if path and show_hidden match *)
  if
    cache.cached_path = path
    && cache.cached_show_hidden = show_hidden
    && cache.cached_entries <> []
  then cache.cached_entries
  else begin
    (* Path or show_hidden changed - clear writable cache for fresh checks *)
    if cache.cached_path <> path then Hashtbl.clear cache.cached_writable ;
    let entries = list_entries_safe path ~dirs_only ~show_hidden in
    let parent = Filename.dirname path in
    let result =
      let with_parent =
        if parent = path then entries
        else {name = ".."; is_dir = true} :: entries
      in
      (* Always include the current directory entry first so Enter selects it by default. *)
      {name = "."; is_dir = true} :: with_parent
    in
    (* Update cache *)
    cache.cached_path <- path ;
    cache.cached_entries <- result ;
    cache.cached_show_hidden <- show_hidden ;
    result
  end

let is_cancelled w = w.cancelled

let reset_cancelled w = {w with cancelled = false}

let get_current_path w = w.current_path

let get_pending_selection w = w.pending_selection

let get_selected_entry w =
  let entries =
    list_entries_with_parent
      w.current_path
      ~dirs_only:w.dirs_only
      ~show_hidden:w.show_hidden
  in
  if entries = [] then None
  else
    let idx = clamp 0 (max 0 (List.length entries - 1)) w.cursor in
    List.nth_opt entries idx

let get_selection w =
  match w.pending_selection with
  | Some p -> Some p
  | None -> (
      match w.mode with
      | EditingPath -> (
          (* In edit mode, return the textbox content as the selection *)
          match w.textbox with
          | Some tb ->
              let path = textbox_get_text tb in
              if path = "" then Some w.current_path else Some path
          | None -> Some w.current_path)
      | Browsing -> (
          match get_selected_entry w with
          | None -> Some w.current_path
          | Some e ->
              let target =
                if e.name = ".." then Filename.dirname w.current_path
                else if e.name = "." then w.current_path
                else Filename.concat w.current_path e.name
              in
              if e.is_dir && not w.select_dirs then None else Some target))

let is_editing w = match w.mode with EditingPath -> true | Browsing -> false

let can_commit w =
  match w.mode with
  | EditingPath -> (
      (* Allow commit only when we have a validated pending file selection *)
      match w.pending_selection with
      | Some _ -> true
      | None -> false)
  | Browsing -> true

let current_input w =
  match w.textbox with
  | Some tb -> textbox_get_text tb
  | None -> if w.path_buffer = "" then w.current_path else w.path_buffer

let rec handle_key w ~key =
  (* Apply any pending path updates first *)
  let w = apply_pending_updates w in
  let selection_for_entry w e =
    let target =
      if e.name = ".." then Filename.dirname w.current_path
      else if e.name = "." then w.current_path
      else Filename.concat w.current_path e.name
    in
    if e.is_dir && not w.select_dirs then None else Some target
  in
  let entries =
    list_entries_with_parent
      w.current_path
      ~dirs_only:w.dirs_only
      ~show_hidden:w.show_hidden
  in
  let total = List.length entries in
  match w.mode with
  | Browsing -> (
      match key with
      | "h" ->
          (* Toggle hidden files *)
          invalidate_cache () ;
          {w with show_hidden = not w.show_hidden; cursor = 0}
      | "Up" ->
          {
            w with
            cursor = List_nav.move_cursor ~total ~cursor:w.cursor ~delta:(-1);
          }
      | "Down" ->
          {
            w with
            cursor = List_nav.move_cursor ~total ~cursor:w.cursor ~delta:1;
            pending_selection = None;
          }
      | "PageUp" ->
          {
            w with
            cursor =
              List_nav.page_move ~total ~cursor:w.cursor ~page_size:8 ~dir:`Up;
            pending_selection = None;
          }
      | "PageDown" ->
          {
            w with
            cursor =
              List_nav.page_move ~total ~cursor:w.cursor ~page_size:8 ~dir:`Down;
            pending_selection = None;
          }
      | " " ->
          (* Explicitly select current entry without navigating. *)
          let pending =
            match List.nth_opt entries w.cursor with
            | None -> None
            | Some e -> selection_for_entry w e
          in
          {w with pending_selection = pending}
      | "n" ->
          if not (is_writable w.current_path) then
            {w with path_error = Some "Not writable"}
          else
            let entries =
              list_entries_safe
                w.current_path
                ~dirs_only:false
                ~show_hidden:true
            in
            let suggested =
              next_available_name ~existing:entries ~prefix:"new_directory" 0
            in
            let tb = textbox_create ~initial:suggested () in
            {
              w with
              mode = EditingPath;
              textbox = Some tb;
              create_dir_on_enter = true;
              path_error = None;
              pending_selection = None;
              history_idx = None;
            }
      | "Shift-Space" ->
          {
            w with
            cursor =
              List_nav.page_move ~total ~cursor:w.cursor ~page_size:8 ~dir:`Up;
          }
      | "Esc" -> {w with cancelled = true}
      | "Enter" -> (
          (* Navigate into subdirectories, select current dir or files *)
          match List.nth_opt entries w.cursor with
          | Some entry when entry.name = ".." ->
              let parent = Filename.dirname w.current_path in
              {
                w with
                current_path = parent;
                cursor = 0;
                pending_selection = None;
              }
          | Some entry when entry.name = "." ->
              (* Select the current directory *)
              {w with pending_selection = selection_for_entry w entry}
          | Some entry ->
              let target = Filename.concat w.current_path entry.name in
              let sys = Miaou_interfaces.System.require () in
              let is_dir =
                entry.is_dir || try sys.is_directory target with _ -> false
              in
              if is_dir then
                (* Navigate into subdirectory *)
                let new_path = normalize_start target in
                {
                  w with
                  current_path = new_path;
                  cursor = 0;
                  path_error = None;
                  pending_selection = None;
                }
              else
                (* Fallback: if is_directory failed, try to list it. 
                   If we can list it, it's browseable, so treat as directory. *)
                let works_as_dir =
                  match
                    list_entries
                      target
                      ~dirs_only:false
                      ~show_hidden:w.show_hidden
                  with
                  | Ok _ -> true
                  | Error _ -> false
                in
                if works_as_dir then
                  {
                    w with
                    current_path = target;
                    (* Skip normalize_start as it might fail is_directory check *)
                    cursor = 0;
                    path_error = None;
                    pending_selection = None;
                  }
                else
                  (* Select the file *)
                  {w with pending_selection = selection_for_entry w entry}
          | _ -> w)
      | "Left" | "Backspace" ->
          let parent = Filename.dirname w.current_path in
          {w with current_path = parent; cursor = 0}
      | "Tab" | "C-l" ->
          (* Enter editing with textbox prefilled from current_path. *)
          let tb = textbox_create ~initial:w.current_path ~width:60 () in
          {
            w with
            mode = EditingPath;
            textbox = Some tb;
            path_error = None;
            pending_selection = None;
            history_idx = None;
          }
      | k when String.length k = 1 && (k.[0] = '/' || k.[0] = '~') ->
          let tb = textbox_create ~initial:(w.current_path ^ k) ~width:60 () in
          {
            w with
            mode = EditingPath;
            textbox = Some tb;
            path_error = None;
            pending_selection = None;
            history_idx = None;
          }
      | "WheelUp" ->
          {
            w with
            cursor =
              List_nav.move_cursor
                ~total
                ~cursor:w.cursor
                ~delta:(-Miaou_helpers.Mouse.wheel_scroll_lines);
          }
      | "WheelDown" ->
          {
            w with
            cursor =
              List_nav.move_cursor
                ~total
                ~cursor:w.cursor
                ~delta:Miaou_helpers.Mouse.wheel_scroll_lines;
          }
      | key -> (
          (* Check for mouse click to select entry *)
          match Miaou_helpers.Mouse.parse_click key with
          | Some {row; col = _} ->
              (* Header is 1 line (path bar), so body starts at row 2.
                 row is 1-indexed, so item at row 2 is index 0. *)
              let header_lines = 1 in
              let clicked_idx = row - header_lines - 1 in
              if clicked_idx >= 0 && clicked_idx < total then
                let w = {w with cursor = clicked_idx} in
                (* Double-click activates entry (same as Enter) *)
                if Miaou_helpers.Mouse.is_double_click key then
                  handle_key w ~key:"Enter"
                else w
              else w
          | None -> w))
  | EditingPath -> (
      match (key, w.textbox) with
      | "Esc", _ ->
          {
            w with
            mode = Browsing;
            path_error = None;
            pending_selection = None;
            create_dir_on_enter = false;
            history_idx = None;
          }
      | " ", Some tb ->
          (* Space in edit mode: insert space, never set pending_selection *)
          {w with textbox = Some (textbox_handle_key tb ~key:" ")}
      | ("Up" | "Down"), Some tb ->
          let len = List.length w.history in
          if len = 0 then w
          else
            let idx =
              match (key, w.history_idx) with
              | "Up", None -> 0
              | "Down", None -> 0
              | "Up", Some i -> min (len - 1) (i + 1)
              | "Down", Some i -> max 0 (i - 1)
              | _ -> 0
            in
            let text = List.nth w.history idx in
            {
              w with
              history_idx = Some idx;
              textbox = Some (textbox_set_text tb text);
            }
      | "Tab", Some tb -> (
          (* Completion forward - always include hidden files for tab completion *)
          let buf = textbox_get_text tb in
          let dir = Filename.dirname buf in
          let base = Filename.basename buf in
          let candidates =
            list_entries_safe dir ~dirs_only:false ~show_hidden:true
          in
          let names = List.map (fun e -> e.name) candidates in
          let matches =
            List.filter (fun n -> String.starts_with ~prefix:base n) names
          in
          let choose name =
            let newp = Filename.concat dir name in
            let is_dir =
              List.exists (fun e -> e.name = name && e.is_dir) candidates
            in
            let newp = if is_dir then newp ^ "/" else newp in
            {
              w with
              textbox = Some (textbox_set_text tb newp);
              path_error = None;
            }
          in
          match matches with
          | [] -> w
          | [one] -> choose one
          | many -> choose (List.hd many))
      | "Shift-Tab", Some tb -> (
          (* Completion backward - always include hidden files for tab completion *)
          let buf = textbox_get_text tb in
          let dir = Filename.dirname buf in
          let base = Filename.basename buf in
          let candidates =
            list_entries_safe dir ~dirs_only:false ~show_hidden:true
          in
          let names = List.map (fun e -> e.name) candidates in
          let matches =
            List.filter (fun n -> String.starts_with ~prefix:base n) names
          in
          let choose name =
            let newp = Filename.concat dir name in
            let is_dir =
              List.exists (fun e -> e.name = name && e.is_dir) candidates
            in
            let newp = if is_dir then newp ^ "/" else newp in
            {
              w with
              textbox = Some (textbox_set_text tb newp);
              path_error = None;
            }
          in
          match List.rev matches with [] -> w | one :: _ -> choose one)
      | "Enter", Some tb ->
          let sys = Miaou_interfaces.System.require () in
          let p =
            let s = textbox_get_text tb in
            if s = "" then w.current_path else s
          in
          let p =
            if Filename.is_relative p then Filename.concat w.current_path p
            else p
          in
          if w.create_dir_on_enter then
            if sys.file_exists p then
              {w with path_error = Some "Already exists"}
            else
              match sys.mkdir p with
              | Ok () ->
                  (* Invalidate cache after creating directory *)
                  invalidate_cache () ;
                  {
                    w with
                    current_path = p;
                    cursor = 0;
                    mode = Browsing;
                    path_error = None;
                    pending_selection = None;
                    history =
                      (let h = List.filter (fun x -> x <> p) w.history in
                       p :: h);
                    history_idx = None;
                    create_dir_on_enter = false;
                  }
              | Error e -> {w with path_error = Some e}
          else
            let exists = sys.file_exists p in
            if not exists then {w with path_error = Some "Path not found"}
            else
              let writable_ok = (not w.require_writable) || is_writable p in
              if sys.is_directory p then
                if writable_ok then
                  {
                    w with
                    current_path = p;
                    cursor = 0;
                    mode = Browsing;
                    path_error = None;
                    pending_selection = None;
                    history =
                      (if p = "" then w.history
                       else
                         let h = List.filter (fun x -> x <> p) w.history in
                         p :: h);
                    history_idx = None;
                  }
                else {w with path_error = Some "Not writable"}
              else if writable_ok then
                {
                  w with
                  pending_selection = Some p;
                  path_error = None;
                  history =
                    (let h = List.filter (fun x -> x <> p) w.history in
                     p :: h);
                  history_idx = None;
                }
              else {w with path_error = Some "Not writable"}
      | _, Some tb -> {w with textbox = Some (textbox_handle_key tb ~key)}
      | _, None -> w)

let render_with_size w ~focus:_ ~(size : LTerm_geom.size) =
  let w = apply_pending_updates w in
  let module W = Miaou_widgets_display.Widgets in
  let human_bytes (n : int64) =
    let open Int64 in
    let f x = Int64.to_float x in
    let units = [|"B"; "KB"; "MB"; "GB"; "TB"|] in
    let rec loop v u =
      if v < 1024L || u = Array.length units - 1 then
        Printf.sprintf "%.1f%s" (f v) units.(u)
      else loop (div v 1024L) (u + 1)
    in
    loop n 0
  in
  let entries =
    list_entries_with_parent
      w.current_path
      ~dirs_only:w.dirs_only
      ~show_hidden:w.show_hidden
  in
  let total = List.length entries in
  let cursor = clamp 0 (if total = 0 then 0 else total - 1) w.cursor in
  let w = {w with cursor} in
  let rows_total = size.LTerm_geom.rows in
  (* Width sizing: compute maximum content width and helpers. *)
  let max_width = max 10 (size.LTerm_geom.cols - 2) in
  let truncate s =
    let len = W.visible_chars_count s in
    if len <= max_width then s
    else if max_width <= 1 then String.make max_width '.'
    else
      let cut = max 0 (max_width - 1) in
      String.sub s 0 cut ^ "."
  in
  let pad_to_width s =
    let v = W.visible_chars_count s in
    if v >= max_width then s else s ^ String.make (max_width - v) ' '
  in
  let shorten_path_to p max_len =
    if max_len <= 5 then
      if String.length p <= max_len then p
      else String.sub p 0 (max 0 (max_len - 1)) ^ "…"
    else if String.length p <= max_len then p
    else
      let keep = (max_len - 1) / 2 in
      let first = String.sub p 0 keep in
      let last = String.sub p (String.length p - keep) keep in
      first ^ "…" ^ last
  in
  (* Path editor bar at the top *)
  let path_bar =
    match w.mode with
    | Browsing ->
        let prefix = "Path: [" in
        let suffix = "]" in
        let room =
          max 0 (max_width - (String.length prefix + String.length suffix))
        in
        let label =
          prefix ^ shorten_path_to w.current_path room ^ suffix
          |> truncate |> pad_to_width
        in
        [label]
    | EditingPath ->
        let tb =
          match w.textbox with
          | Some t -> t
          | None -> textbox_create ~initial:w.current_path ()
        in
        let prefix = "Path: " in
        let rendered = textbox_render tb in
        let line = prefix ^ rendered |> truncate |> pad_to_width in
        let line =
          match w.path_error with
          | None -> W.themed_selection line
          | Some msg -> W.themed_error (line ^ "  " ^ msg)
        in
        [line]
  in
  let hidden_hint = if w.show_hidden then "hide hidden" else "show hidden" in
  let header = path_bar in
  (* Vertical sizing: render exactly [rows_total] lines so parent frames don't
     crop in a way that desynchronizes cursor and viewport. *)
  let footer_pairs =
    [
      ("↑/↓", "navigate");
      ("PgUp/PgDn", "page");
      ("Space", "select");
      ("Enter", "open");
      ("Esc", "cancel");
      ("Backspace", "parent");
      ("Tab", "edit path");
      ("h", hidden_hint);
      ("n", "new directory");
    ]
  in
  let footer_controls =
    W.footer_hints_wrapped_capped ~cols:size.cols ~max_lines:2 footer_pairs
  in
  let footer_hint_lines = String.split_on_char '\n' footer_controls in
  let header_lines = List.length header in
  let footer_lines =
    2 + List.length footer_hint_lines
    (* blank + status + footer hint lines *)
  in
  let body_capacity = max 0 (rows_total - header_lines - footer_lines) in
  let max_shown = min total body_capacity in
  let start =
    if total <= max_shown || max_shown <= 0 then 0
    else
      let max_start = max 0 (total - max_shown) in
      let desired = w.cursor - (max_shown - 1) in
      max 0 (min desired max_start)
  in
  let slice =
    entries
    |> List.mapi (fun i v -> (i, v))
    |> List.filter (fun (i, _) -> i >= start && i < start + max_shown)
  in
  let body =
    let sys = Miaou_interfaces.System.require () in
    List.map
      (fun (i, e) ->
        let full =
          if e.name = ".." then Filename.dirname w.current_path
          else Filename.concat w.current_path e.name
        in
        let size_suffix =
          if e.name = ".." then ""
          else if e.is_dir then "/"
          else
            match sys.get_disk_usage ~path:full with
            | Ok bytes -> "  (" ^ human_bytes bytes ^ ")"
            | Error _ -> ""
        in
        let name_for_display =
          match e.name with "." -> w.current_path | _ -> e.name
        in
        let plain =
          if e.is_dir then name_for_display ^ "/"
          else name_for_display ^ size_suffix
        in
        let clipped = plain |> truncate in
        (* Apply icon prefix and per-extension foreground colour. The
           selection highlight applied below still wins for the cursor row. *)
        let colored =
          File_icons.decorate ~name:e.name ~is_dir:e.is_dir clipped
        in
        let label =
          if w.mode = EditingPath then W.themed_muted colored else colored
        in
        (* Only show selection highlight in Browsing mode *)
        if w.mode = Browsing && i = w.cursor then W.themed_selection label
        else label)
      slice
  in
  let body =
    if List.length body >= body_capacity then body
    else body @ List.init (body_capacity - List.length body) (fun _ -> "")
  in
  (* Show selectable status near the bottom; footer shows action keys like Enter and n. *)
  let selectable =
    match List.nth_opt entries w.cursor with
    | None -> is_writable w.current_path
    | Some e ->
        let full = Filename.concat w.current_path e.name in
        is_writable full
  in
  let status =
    let base =
      if selectable then W.themed_success "Selectable"
      else W.themed_warning "Not writable"
    in
    (* If current item is a file, append size hint to status for quick preview *)
    let s =
      match List.nth_opt entries w.cursor with
      | Some e when not e.is_dir -> (
          let full = Filename.concat w.current_path e.name in
          match
            (Miaou_interfaces.System.require ()).get_disk_usage ~path:full
          with
          | Ok bytes -> base ^ W.themed_muted (" • size " ^ human_bytes bytes)
          | Error _ -> base)
      | _ -> base
    in
    s |> truncate |> pad_to_width
  in
  let padded_footer_hints =
    footer_hint_lines
    |> List.map (fun l -> W.themed_muted (truncate l |> pad_to_width))
  in
  let sections = header @ body @ ("" :: status :: padded_footer_hints) in
  Helpers.concat_lines sections

let render w ~focus =
  let default_size : LTerm_geom.size = {rows = 24; cols = 80} in
  render_with_size w ~focus ~size:default_size

let mkdir_and_cd browser dirname =
  let sys = Miaou_interfaces.System.require () in
  let new_path = Filename.concat browser.current_path dirname in
  match sys.mkdir new_path with
  | Error e -> Error e
  | Ok () -> (
      (* Invalidate cache after creating directory *)
      invalidate_cache () ;
      (* Verify we can list the new directory *)
      match
        list_entries
          new_path
          ~dirs_only:browser.dirs_only
          ~show_hidden:browser.show_hidden
      with
      | Error e -> Error e
      | Ok _entries ->
          (* Schedule the path update instead of returning it directly *)
          schedule_path_update new_path ;
          (* Return the original browser state - the update will be applied later *)
          Ok (browser, true))

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