package miaou-core

  1. Overview
  2. Docs
Legend:
Page
Library
Module
Module type
Parameter
Class
Class type
Source

Source file table_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
(*****************************************************************************)
(*                                                                           *)
(* SPDX-License-Identifier: MIT                                              *)
(* Copyright (c) 2025 Nomadic Labs <contact@nomadic-labs.com>                *)
(* Copyright (c) 2026 Mathias Bourgoin <mathias.bourgoin@atacama.tech>       *)
(*                                                                           *)
(*****************************************************************************)
module Helpers = Miaou_helpers.Helpers
module W = Widgets
module Palette = Palette

type glyphs = {
  vline : string;
  hline : string;
  corner_tl : string;
  corner_tr : string;
  corner_bl : string;
  corner_br : string;
  top_sep : string;
  mid_left : string;
  mid_sep : string;
  mid_right : string;
  bottom_sep : string;
}

let glyphs_for_backend ?backend () =
  let use_ascii = W.prefer_ascii ?backend () in
  let glyph_corner_tl = if use_ascii then "+" else "┌" in
  let glyph_corner_tr = if use_ascii then "+" else "┐" in
  let glyph_corner_bl = if use_ascii then "+" else "└" in
  let glyph_corner_br = if use_ascii then "+" else "┘" in
  let glyph_hline = if use_ascii then "-" else "─" in
  let glyph_vline = if use_ascii then "|" else "│" in
  let glyph_top_sep = if use_ascii then "+" else "┬" in
  let glyph_mid_left = if use_ascii then "+" else "├" in
  let glyph_mid_sep = if use_ascii then "+" else "┼" in
  let glyph_mid_right = if use_ascii then "+" else "┤" in
  let glyph_bottom_sep = if use_ascii then "+" else "┴" in
  {
    vline = glyph_vline;
    hline = glyph_hline;
    corner_tl = glyph_corner_tl;
    corner_tr = glyph_corner_tr;
    corner_bl = glyph_corner_bl;
    corner_br = glyph_corner_br;
    top_sep = glyph_top_sep;
    mid_left = glyph_mid_left;
    mid_sep = glyph_mid_sep;
    mid_right = glyph_mid_right;
    bottom_sep = glyph_bottom_sep;
  }

let visible_chars_count = W.visible_chars_count

let visible_byte_index_of_pos = W.visible_byte_index_of_pos

let add_repeat buf s n =
  for _ = 1 to n do
    Buffer.add_string buf s
  done

let pad s w =
  let vis = visible_chars_count s in
  if vis <= w then s ^ String.make (w - vis) ' '
  else
    let byte_idx = visible_byte_index_of_pos s (max 0 (w - 1)) in
    String.sub s 0 byte_idx ^ "…"

let col_widths_total = 80

type selection_mode = Row | Col | Cell | None_mode

type column_opts = {max_width : int option; pad_left : int; pad_right : int}

type render_opts = {
  selection_mode : selection_mode;
  highlight_header : bool;
  sort : (int * bool) option;
}

let default_opts = {selection_mode = Row; highlight_header = false; sort = None}

let render_table_sdl ~cols ~header:(h1, h2, h3) ~rows ~cursor ~sel_col:_ ~opts =
  (* More UI-like SDL style: solid rows and badges. Kept as a separate entrypoint
     from the terminal renderer to avoid imposing styling on all tables. *)
  let open Widgets in
  let total_w =
    match cols with Some c -> max 30 (min c 140) | None -> col_widths_total
  in
  let inner_w = max 10 (total_w - 4) in
  let pad_cell s w =
    let vis = visible_chars_count s in
    if vis <= w then s ^ String.make (max 0 (w - vis)) ' '
    else
      let idx = visible_byte_index_of_pos s (max 0 (w - 1)) in
      String.sub s 0 idx ^ "…"
  in
  let header_list = [h1; h2; h3] in
  let rows_list = List.map (fun (a, b, c) -> [a; b; c]) rows in
  let col_count = List.length header_list in
  let content_widths =
    let all_rows = header_list :: rows_list in
    List.init col_count (fun col ->
        List.fold_left
          (fun acc row ->
            let cell = List.nth_opt row col |> Option.value ~default:"" in
            max acc (visible_chars_count cell))
          0
          all_rows)
  in
  let base_widths =
    List.map (fun w -> min w (inner_w / max 1 col_count)) content_widths
  in
  let total_base = List.fold_left ( + ) 0 base_widths in
  let extra = max 0 (inner_w - total_base) in
  let col_widths =
    List.mapi
      (fun _ w ->
        let add = if col_count = 0 then 0 else extra / col_count in
        w + add + 2)
      base_widths
  in
  let badge style txt = style (bold ("● " ^ txt)) in
  let style_status s =
    match String.lowercase_ascii (String.trim s) with
    | "ok" | "ready" -> badge W.themed_success s
    | "warn" | "warning" -> badge W.themed_warning s
    | "error" | "fail" -> badge W.themed_error s
    | _ -> badge W.themed_accent s
  in
  let header_line =
    let cells =
      List.mapi
        (fun i h ->
          let w = List.nth col_widths i in
          let base = pad_cell (String.uppercase_ascii h) (w - 2) in
          let padded = " " ^ base ^ " " in
          W.themed_emphasis padded)
        header_list
    in
    let line = "  " ^ String.concat (W.themed_border "│") cells in
    line
  in
  let line_for_row idx row =
    let row_bg =
      match opts.selection_mode with
      | Row when idx = cursor -> W.themed_selection
      | _ -> W.themed_background_alt
    in
    let cells =
      List.mapi
        (fun i cell ->
          let w = List.nth col_widths i in
          let cell =
            if i = 2 then style_status cell else pad_cell cell (w - 2)
          in
          let padded = " " ^ pad_cell cell (w - 2) ^ " " in
          padded)
        row
    in
    let pointer = if idx = cursor then W.themed_accent ">" else " " in
    let bar = String.concat (W.themed_border "│") cells in
    let with_pointer = pointer ^ " " ^ bar in
    if idx = cursor then row_bg with_pointer else with_pointer
  in
  let body = List.mapi (fun i row -> line_for_row i row) rows_list in
  let header_sep =
    W.themed_border (String.make (max 0 (String.length header_line)) '-')
  in
  String.concat "\n" (header_line :: header_sep :: body)

(* Generic table renderer that handles column width calculation, padding,
   borders, and selection highlighting. *)
let render_table_generic_with_opts ?backend ?(wrap = false) ~cols ~header_list
    ~rows_list ~cursor ~sel_col:_ ~opts ?(col_opts = []) () =
  let glyphs = glyphs_for_backend ?backend () in
  let total_w =
    match cols with Some c -> max 20 (min c 240) | None -> col_widths_total
  in
  let inner_w = max 0 (total_w - 4) in
  let col_count = List.length header_list in
  let default_col_opt : column_opts =
    {max_width = None; pad_left = 1; pad_right = 1}
  in
  let col_opts =
    if List.length col_opts = col_count then col_opts
    else List.init col_count (fun _ -> default_col_opt)
  in
  let content_widths =
    let all_rows = header_list :: rows_list in
    List.init col_count (fun col ->
        List.fold_left
          (fun acc row ->
            let cell = List.nth_opt row col |> Option.value ~default:"" in
            max acc (visible_chars_count cell))
          0
          all_rows)
  in
  let col_widths =
    List.mapi
      (fun i w ->
        let copts = List.nth col_opts i in
        let padded = w + copts.pad_left + copts.pad_right in
        match copts.max_width with Some mw -> min padded mw | None -> padded)
      content_widths
  in
  let total_cols_w = List.fold_left ( + ) 0 col_widths in
  let _sep_w = col_count + 1 in
  let extra_space = max 0 (inner_w - total_cols_w) in
  let col_widths =
    if extra_space > 0 then
      let per_col = extra_space / col_count in
      List.map (fun w -> w + per_col) col_widths
    else col_widths
  in
  let rows = rows_list in
  let rows_sorted =
    match opts.sort with
    | None -> rows
    | Some (col, asc) ->
        let key_of row = List.nth_opt row col |> Option.value ~default:"" in
        let cmp a b = String.compare (key_of a) (key_of b) in
        let lst = List.mapi (fun i r -> (i, r)) rows in
        let lst_sorted =
          List.sort (fun (_, a) (_, b) -> if asc then cmp a b else -cmp a b) lst
        in
        List.map snd lst_sorted
  in
  let headers_padded =
    List.mapi
      (fun i h ->
        let w = List.nth col_widths i in
        let copts = List.nth col_opts i in
        let base = pad h (w - copts.pad_left - copts.pad_right) in
        String.make copts.pad_left ' ' ^ base ^ String.make copts.pad_right ' ')
      header_list
  in
  let header_line =
    let vline = W.themed_border glyphs.vline in
    let line =
      vline
      ^ Helpers.concat_with_sep
          vline
          (headers_padded |> List.map W.themed_emphasis)
      ^ vline
    in
    if opts.highlight_header then Palette.purple_gradient_line Right line
    else line
  in
  let build_border left sep right =
    let buf = Buffer.create total_w in
    Buffer.add_string buf left ;
    List.iteri
      (fun i w ->
        add_repeat buf glyphs.hline w ;
        Buffer.add_string buf (if i = col_count - 1 then right else sep))
      col_widths ;
    Buffer.contents buf
  in
  let top_border =
    build_border glyphs.corner_tl glyphs.top_sep glyphs.corner_tr
    |> W.themed_border
  in
  let mid_border =
    build_border glyphs.mid_left glyphs.mid_sep glyphs.mid_right
    |> W.themed_border
  in
  let bottom_border =
    build_border glyphs.corner_bl glyphs.bottom_sep glyphs.corner_br
    |> W.themed_border
  in
  let blank_for_col =
    List.mapi (fun idx w -> (idx, String.make w ' ')) col_widths
  in
  let assemble_columns ~is_selected cols =
    let buf = Buffer.create inner_w in
    (* When row is selected, don't style vlines - let them inherit selection bg *)
    let vline =
      if is_selected then glyphs.vline else W.themed_border glyphs.vline
    in
    Buffer.add_string buf vline ;
    List.iteri
      (fun idx col ->
        if idx > 0 then Buffer.add_string buf vline ;
        Buffer.add_string buf col)
      cols ;
    Buffer.add_string buf vline ;
    Buffer.contents buf
  in
  let row_to_lines i cols_cells =
    if not wrap then
      let cells =
        List.mapi
          (fun col_idx cell ->
            let w = List.nth col_widths col_idx in
            let copts = List.nth col_opts col_idx in
            let base = pad cell (w - copts.pad_left - copts.pad_right) in
            String.make copts.pad_left ' '
            ^ base
            ^ String.make copts.pad_right ' ')
          cols_cells
      in
      let is_selected =
        match opts.selection_mode with
        | Row when i = cursor -> true
        | _ -> false
      in
      let line_core = assemble_columns ~is_selected cells in
      let line =
        match opts.selection_mode with
        | Row when i = cursor -> W.themed_selection line_core
        | _ -> line_core
      in
      [line ^ "\027[0m"]
    else
      let cell_lines =
        List.mapi
          (fun col_idx cell ->
            let w = List.nth col_widths col_idx in
            let copts = List.nth col_opts col_idx in
            let inner = max 0 (w - copts.pad_left - copts.pad_right) in
            Widgets.wrap_text ~width:inner cell
            |> List.map (fun l ->
                let base = Widgets.pad_visible l inner in
                String.make copts.pad_left ' '
                ^ base
                ^ String.make copts.pad_right ' '))
          cols_cells
      in
      let height =
        List.fold_left (fun acc ls -> max acc (List.length ls)) 1 cell_lines
      in
      let padded_lines =
        List.mapi
          (fun col_idx lines ->
            let fallback =
              match List.assoc_opt col_idx blank_for_col with
              | Some s -> s
              | None -> String.make (List.nth col_widths col_idx) ' '
            in
            let rec fill lst =
              if List.length lst >= height then lst else fill (lst @ [fallback])
            in
            fill lines)
          cell_lines
      in
      let assemble_line idx =
        let cols_for_idx =
          List.mapi
            (fun col_idx lines ->
              match List.nth_opt lines idx with
              | Some l -> l
              | None -> (
                  match List.assoc_opt col_idx blank_for_col with
                  | Some b -> b
                  | None -> ""))
            padded_lines
        in
        let is_selected =
          match opts.selection_mode with
          | Row when i = cursor -> true
          | _ -> false
        in
        let line_core = assemble_columns ~is_selected cols_for_idx in
        let line =
          match opts.selection_mode with
          | Row when i = cursor -> W.themed_selection line_core
          | _ -> line_core
        in
        line ^ "\027[0m"
      in
      List.init height assemble_line
  in
  let buf = Buffer.create (total_w * (List.length rows_sorted + 4)) in
  let add_line line =
    Buffer.add_string buf line ;
    Buffer.add_char buf '\n'
  in
  add_line top_border ;
  add_line header_line ;
  add_line mid_border ;
  List.iter
    (fun lines -> List.iter add_line lines)
    (List.mapi row_to_lines rows_sorted) ;
  Buffer.add_string buf bottom_border ;
  Buffer.contents buf

let render_table_80_with_opts ?backend ?wrap ~cols ~header:(h1, h2, h3) ~rows
    ~cursor ~sel_col ~opts () =
  let header_list = [h1; h2; h3] in
  let rows_list = List.map (fun (a, b, c) -> [a; b; c]) rows in
  render_table_generic_with_opts
    ?backend
    ?wrap
    ~cols
    ~header_list
    ~rows_list
    ~cursor
    ~sel_col
    ~opts
    ()

let render_table_80 ~cols ~header ~rows ~cursor ~sel_col =
  let a, b, c = header in
  let header_list = [a; b; c] in
  let rows_list = List.map (fun (x, y, z) -> [x; y; z]) rows in
  match W.get_backend () with
  | `Sdl ->
      render_table_sdl
        ~cols
        ~header:(a, b, c)
        ~rows
        ~cursor
        ~sel_col
        ~opts:default_opts
  | `Terminal ->
      render_table_generic_with_opts
        ~backend:(W.get_backend ())
        ~cols
        ~header_list
        ~rows_list
        ~cursor
        ~sel_col
        ~opts:default_opts
        ()

module Table = struct
  type 'a column = {header : string; to_string : 'a -> string}

  type column_layout = {
    min_width : int option;
    max_width : int option;
    weight : int option;
    pad_left : int option;
    pad_right : int option;
  }

  type 'a t = {
    cols : int option;
    columns : 'a column list;
    opts : render_opts;
    rows : 'a list;
    cursor : int;
    layout : column_layout list option;
  }

  let create ?cols ?(opts = default_opts) ?layout ~columns ~rows () =
    let layout = layout in
    {cols; columns; opts; rows; cursor = 0; layout}

  let set_rows t rows =
    let cursor =
      if t.cursor >= List.length rows then max 0 (List.length rows - 1)
      else t.cursor
    in
    {t with rows; cursor}

  let move_cursor t delta =
    let len = List.length t.rows in
    if len = 0 then {t with cursor = 0}
    else
      let raw = t.cursor + delta in
      let raw = if raw < 0 then 0 else if raw >= len then len - 1 else raw in
      {t with cursor = raw}

  let cursor t = t.cursor

  let rows t = List.length t.rows

  let set_layout t layout = {t with layout = Some layout}

  let render t =
    let headers = List.map (fun c -> c.header) t.columns in
    let rendered_rows =
      let safe_to_string f x = try f x with _ -> "<err>" in
      List.map
        (fun a ->
          List.map (fun col -> safe_to_string col.to_string a) t.columns)
        t.rows
    in
    let col_opts =
      match t.layout with
      | None -> []
      | Some specs ->
          let count = List.length t.columns in
          let specs =
            if List.length specs = count then specs
            else
              List.init count (fun _ ->
                  {
                    min_width = None;
                    max_width = None;
                    weight = None;
                    pad_left = None;
                    pad_right = None;
                  })
          in
          let content_widths =
            let all_rows = headers :: rendered_rows in
            List.init count (fun col ->
                List.fold_left
                  (fun acc row ->
                    let cell =
                      List.nth_opt row col |> Option.value ~default:""
                    in
                    max acc (visible_chars_count cell))
                  0
                  all_rows)
          in
          let base_widths =
            List.mapi
              (fun i w ->
                let spec = List.nth specs i in
                match spec.min_width with None -> w | Some mw -> max mw w)
              content_widths
          in
          let total_w =
            match t.cols with
            | Some c -> max 20 (min c 240)
            | None -> col_widths_total
          in
          let inner_w = max 0 (total_w - (count + 1)) in
          let paddings =
            List.map
              (fun spec ->
                let pl = Option.value ~default:1 spec.pad_left in
                let pr = Option.value ~default:1 spec.pad_right in
                pl + pr)
              specs
          in
          let occupied =
            List.fold_left2
              (fun acc w pad -> acc + w + pad)
              0
              base_widths
              paddings
          in
          let remaining = max 0 (inner_w - occupied) in
          let weights =
            let raw =
              List.map (fun spec -> Option.value ~default:1 spec.weight) specs
            in
            let sum = List.fold_left ( + ) 0 raw in
            if sum = 0 then List.init count (fun _ -> 1) else raw
          in
          let distributed =
            if remaining = 0 then List.init count (fun _ -> 0)
            else
              let sumw = List.fold_left ( + ) 0 weights in
              let acc = ref 0 in
              List.mapi
                (fun i w ->
                  if i = count - 1 then remaining - !acc
                  else
                    let add = remaining * w / sumw in
                    acc := !acc + add ;
                    add)
                weights
          in
          List.mapi
            (fun i spec ->
              let pl = Option.value ~default:1 spec.pad_left in
              let pr = Option.value ~default:1 spec.pad_right in
              let w = List.nth base_widths i + List.nth distributed i in
              let w =
                match spec.max_width with Some mw -> min mw w | None -> w
              in
              {max_width = Some w; pad_left = pl; pad_right = pr})
            specs
    in
    render_table_generic_with_opts
      ~cols:t.cols
      ~header_list:headers
      ~rows_list:rendered_rows
      ~cursor:t.cursor
      ~sel_col:0
      ~opts:t.opts
      ~col_opts
      ()

  let get_selected t =
    if t.cursor < 0 || t.cursor >= List.length t.rows then None
    else Some (List.nth t.rows t.cursor)
end

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