package tiny_appkits

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

Source file Page.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
(* Claude Code
 *
 * Copyright (C) 2026 Yoann Padioleau
 *
 * This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Library General Public License
 * (LGPL) as published by the Free Software Foundation; either version
 * 2 of the License, or (at your option) any later version.
 *)

(* See Page.mli *)

(*****************************************************************************)
(* Types *)
(*****************************************************************************)

type metrics = Style.t -> string -> float
type align = Left | Center | Right | Justify

type glyph = {
  offset : int;
  text : string;
  style : Style.t;
  x : float;
  baseline : float;
  advance : float;
}

type line = {
  top : float;
  height : float;
  baseline : float;
  cells : glyph list;
  (* the offsets it covers: [first, stop) *)
  first : int;
  stop : int;
}

type t = { lines : line list; length : int }

(* a line is as tall as its tallest look, with some air, and its
 * baseline sits one em of that look below its top *)
let line_height size = size *. 1.4

(*****************************************************************************)
(* From characters to words to lines *)
(*****************************************************************************)

(* a character, where it is in the text, its look and its width *)
type cell = { at : int; ch : string; look : Style.t; w : float }

(* a word is its letters and the spaces after them; a newline ends
 * the word it follows, and the line with it *)
type word = { parts : cell list; ink : float; total : float; hard : bool }

let words_of cells =
  let finish acc cur hard =
    if cur = [] then acc
    else
      let parts = List.rev cur in
      let is_space c = c.ch = " " || c.ch = "\n" in
      let ink =
        (* the width up to the last letter: the spaces after it may
           hang past the edge of the line *)
        let rec go w last = function
          | [] -> last
          | c :: rest -> let w = w +. c.w in go w (if is_space c then last else w) rest
        in
        go 0. 0. parts
      in
      { parts; ink; total = List.fold_left (fun a c -> a +. c.w) 0. parts; hard } :: acc
  in
  let rec go acc cur seen_space = function
    | [] -> List.rev (finish acc cur false)
    | c :: rest when c.ch = "\n" -> go (finish acc (c :: cur) true) [] false rest
    | c :: rest when c.ch = " " -> go acc (c :: cur) true rest
    | c :: rest ->
        (* a letter after spaces starts the next word *)
        if seen_space then go (finish acc cur false) [ c ] false rest else go acc (c :: cur) false rest
  in
  go [] [] false cells

(* Greedy, word by word: what Bravo did and what Word does. A word
 * that does not fit goes to the next line; a word longer than the
 * whole line is broken where it reaches the edge. *)
let lines_of ~width words =
  let lines = ref [] and cur = ref [] and x = ref 0. and last_hard = ref false in
  let close () =
    lines := List.rev !cur :: !lines;
    cur := [];
    x := 0.
  in
  List.iter
    (fun wd ->
      if !cur <> [] && !x +. wd.ink > width then close ();
      if wd.ink > width then
        (* too long for any line: as much as fits, then the rest *)
        List.iter
          (fun c ->
            if !cur <> [] && !x +. c.w > width && c.ch <> " " then close ();
            cur := c :: !cur;
            x := !x +. c.w)
          wd.parts
      else begin
        cur := List.rev_append wd.parts !cur;
        x := !x +. wd.total
      end;
      if wd.hard then close ();
      last_hard := wd.hard)
    words;
  (* the last line -- even an empty one, after a final newline or in
     an empty text, since the caret has to have somewhere to be *)
  if !cur <> [] || !lines = [] || !last_hard then close ();
  List.rev !lines

(*****************************************************************************)
(* Layout *)
(*****************************************************************************)

(* A line's slack, given to where the alignment says. [last] is whether
 * it ends its paragraph (a newline, or the end of the text), which is
 * set Left even when justifying. *)
let aligned align ~width ~last (glyphs : glyph list) =
  let ink =
    (* up to the end of the last glyph that is not a space: the spaces a
       line was broken after hang past the edge, and are not counted *)
    List.fold_left
      (fun acc (g : glyph) -> if g.text = " " || g.text = "\n" then acc else g.x +. g.advance)
      0. glyphs
  in
  let slack = max 0. (width -. ink) in
  let shift dx = List.map (fun (g : glyph) -> { g with x = g.x +. dx }) glyphs in
  match align with
  | Left -> glyphs
  | Center -> shift (slack /. 2.)
  | Right -> shift slack
  | Justify when last -> glyphs
  | Justify ->
      (* the spaces between words -- not the ones hanging at the end --
         each get an equal share *)
      let inner =
        List.filter (fun (g : glyph) -> g.text = " " && g.x +. g.advance <= ink) glyphs
      in
      let n = List.length inner in
      if n = 0 then glyphs
      else
        let extra = slack /. float_of_int n in
        let _, out =
          List.fold_left
            (fun (added, acc) (g : glyph) ->
              let g' = { g with x = g.x +. added } in
              let is_inner = g.text = " " && g.x +. g.advance <= ink in
              if is_inner then (added +. extra, { g' with advance = g.advance +. extra } :: acc)
              else (added, g' :: acc))
            (0., []) glyphs
        in
        List.rev out

(* The lines laid round boxes, one at a time (see the .mli): each as
 * its segments -- (cells, where the stretch starts, how wide it is) --
 * and its top. A line fills the widest stretch the boxes leave it, or,
 * [both], every stretch wide enough, left to right: a text on both
 * sides of a box. A line's height is guessed from its first letter's
 * look before it is filled, which is the look that decides it in all
 * but mixed lines. *)
let lines_around ~both ~width ~around ~size_of words =
  let min_w = 40. in
  (* the stretches of [0, width] the boxes reaching [y, y+h) leave, left
     to right, and those boxes *)
  let free y h =
    let blocking = List.filter (fun (_, y0, _, y1) -> y0 < y +. h && y1 > y) around in
    let cuts = List.sort compare (List.map (fun (x0, _, x1, _) -> (Float.max 0. x0, Float.min width x1)) blocking) in
    let stretches, last =
      List.fold_left (fun (acc, x) (a, b) -> ((if a > x then (x, a) :: acc else acc), Float.max x b)) ([], 0.) cuts
    in
    let stretches = List.rev (if width > last then (last, width) :: stretches else stretches) in
    let wide = List.filter (fun (a, b) -> b -. a >= min_w) stretches in
    let chosen =
      if both then wide
      else
        match
          List.fold_left (fun best (a, b) -> match best with Some (a', b') when b' -. a' >= b -. a -> best | _ -> Some (a, b)) None wide
        with
        | Some s -> [ s ]
        | None -> []
    in
    (chosen, blocking)
  in
  (* words into a stretch w wide, as many as fit -- and whether a
     newline ended the line *)
  let fill w words =
    let rec go x cur = function
      | wd :: rest when cur = [] || x +. wd.ink <= w ->
          let cur = List.rev_append wd.parts cur in
          if wd.hard then (List.rev cur, rest, true) else go (x +. wd.total) cur rest
      | rest -> (List.rev cur, rest, false)
    in
    go 0. [] words
  in
  let rec go y words acc last_hard =
    match words with
    | [] ->
        (* the caret's last line, after a final newline or in an empty
           text *)
        List.rev (if acc = [] || last_hard then ([ ([], 0., width) ], y) :: acc else acc)
    | first :: _ -> (
        let h = line_height (size_of (match first.parts with c :: _ -> [ c ] | [] -> [])) in
        match free y h with
        | [], blocking ->
            (* no room at this height: on below the nearest bottom of the
               boxes in the way *)
            let next = List.fold_left (fun m (_, _, _, y1) -> Float.min m y1) infinity blocking in
            go (if next = infinity || next <= y then y +. 1. else next) words acc last_hard
        | stretches, _ ->
            (* each stretch in turn, until the words or the line end *)
            let rec segments words hard acc = function
              | [] -> (List.rev acc, words, hard)
              | _ when words = [] || hard -> (List.rev acc, words, hard)
              | (a, b) :: more ->
                  let cells, rest, hard = fill (b -. a) words in
                  segments rest hard ((cells, a, b -. a) :: acc) more
            in
            let segs, rest, hard = segments words false [] stretches in
            let cells = List.concat_map (fun (c, _, _) -> c) segs in
            go (y +. line_height (size_of cells)) rest ((segs, y) :: acc) hard)
  in
  go 0. words [] false

let layout ?(align = Left) ?(around = []) ?(both = false) ~metrics ~width r =
  let s = Rich.to_string r in
  let rec cells_from i acc =
    if i >= String.length s then List.rev acc
    else
      let j = Text.next_char s i in
      let ch = String.sub s i (j - i) in
      let look = Rich.style_at r i in
      cells_from j ({ at = i; ch; look; w = (if ch = "\n" then 0. else metrics look ch) } :: acc)
  in
  let words = words_of (cells_from 0 []) in
  let length = String.length s in
  let base = Rich.typing_style (Rich.at length r) in
  let size_of cells =
    let size = List.fold_left (fun m c -> max m c.look.Style.size) 0. cells in
    if size = 0. then base.Style.size else size
  in
  (* each line: its segments -- cells, where the stretch starts, its
     width -- and its top *)
  let placed =
    if around = [] then
      List.rev
        (snd
           (List.fold_left
              (fun (top, acc) cells -> (top +. line_height (size_of cells), ([ (cells, 0., width) ], top) :: acc))
              (0., []) (lines_of ~width words)))
    else lines_around ~both ~width ~around ~size_of words
  in
  let lines =
    List.map
      (fun (segs, top) ->
        let cells = List.concat_map (fun (c, _, _) -> c) segs in
        let size = size_of cells in
        let baseline = top +. size in
        let first = match cells with c :: _ -> c.at | [] -> length in
        let stop = List.fold_left (fun _ c -> c.at + String.length c.ch) first cells in
        (* the last line of a paragraph: it ends with a newline, or the
           text ends with it *)
        let last = stop >= length || (match List.rev cells with c :: _ -> c.ch = "\n" | [] -> true) in
        let n = List.length segs in
        let glyphs =
          List.concat
            (List.mapi
               (fun i (cells, x0, w) ->
                 let _, glyphs =
                   List.fold_left
                     (fun (x, gs) c -> (x +. c.w, { offset = c.at; text = c.ch; style = c.look; x; baseline; advance = c.w } :: gs))
                     (0., []) cells
                 in
                 (* only the line's last segment can be a paragraph's last *)
                 let glyphs = aligned align ~width:w ~last:(last && i = n - 1) (List.rev glyphs) in
                 if x0 = 0. then glyphs else List.map (fun g -> { g with x = g.x +. x0 }) glyphs)
               segs)
        in
        { top; height = line_height size; baseline; cells = glyphs; first; stop })
      placed
  in
  { lines; length }

let glyphs t = List.concat_map (fun l -> l.cells) t.lines
let lines t = t.lines

let height t = List.fold_left (fun _ l -> l.top +. l.height) 0. t.lines

(*****************************************************************************)
(* Both ways between the text and the page *)
(*****************************************************************************)

let caret_at t offset =
  let offset = max 0 (min t.length offset) in
  match List.find_opt (fun l -> l.first <= offset && offset < l.stop) t.lines with
  | Some l ->
      let g = List.find (fun g -> g.offset = offset) l.cells in
      (g.x, l.baseline, l.height)
  | None -> (
      (* the very end of the text: after the last glyph of the last line *)
      match List.rev t.lines with
      | l :: _ -> (
          match List.rev l.cells with
          | g :: _ when g.text <> "\n" -> (g.x +. g.advance, l.baseline, l.height)
          | _ -> (0., l.baseline, l.height))
      | [] -> (0., 0., 0.))

let offset_at t (x, y) =
  let lines = t.lines in
  let line =
    match List.find_opt (fun l -> y < l.top +. l.height) lines with
    | Some l -> Some l
    | None -> ( match List.rev lines with l :: _ -> Some l | [] -> None)
  in
  match line with
  | None -> 0
  | Some l -> (
      let last_line = (match List.rev lines with z :: _ -> z == l | [] -> true) in
      match List.find_opt (fun g -> g.text <> "\n" && x < g.x +. (g.advance /. 2.)) l.cells with
      | Some g -> g.offset
      | None -> (
          (* past the end of the line: before its newline if it has one,
             at the end of the text on the last line, and otherwise
             before the space it was broken after -- so that the caret
             stays on the line that was clicked *)
          match List.rev l.cells with
          | g :: _ when g.text = "\n" -> g.offset
          | _ when last_line -> t.length
          | g :: _ -> g.offset
          | [] -> l.first))