Measure a gap down the page in rows, and let a thin one be nothing
A cell is about eight points across and sixteen down, and one divisor for both spends twice as much of the screen on vertical air as the design asked for. Vertical air is the whole budget: a conversation is measured in how many messages fit, and frq's fit two. So there are two scales now, and which one a prop takes is the axis it measures. `:spacing` and `:gap` ask the box: the distance between the children of a column is rows, the same number between the children of a row is columns, and nothing in the name says which. And a half rounds down. That is the case that decides how a screen reads, because it is nearly every case: frq spaces its columns 8 points apart against a 16-point row, so every gap in the tree is exactly one half. Rounded up, the chat screen spent nine rows on the gaps between its ten children — and most of those children are empty wrappers holding a place for something not on screen, each costing a blank line it was never meant to have. Rounded down, a gap that thin is what it looks like at this size: nothing. Thirteen messages now, where there were two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c4f56b0 parent: 68910bd modified
jolt/glimmer-tui/src/glimmer_tui/core.jolt +75 -31 | @@ -48,28 +48,46 @@ | ||
| 48 | 48 | ;; twelve blank rows and a column wider than the screen. The reconciler is not |
| 49 | 49 | ;; the place to fix that and neither is the app: the numbers are right, and it |
| 50 | 50 | ;; is the unit under them that changed. So the backend divides on the way |
| 51 | -;; across, and a tree written for cells leaves the scale at 1. | |
| 52 | -(defonce ^:private scale (atom 1)) | |
| 53 | - | |
| 54 | -;; The props that are a distance rather than a count, a flag or a name. A key | |
| 55 | -;; this list does not know crosses unscaled, which is the right way round: a | |
| 56 | -;; number that turns out to be a length paints a little large, where a scaled | |
| 57 | -;; `:value` or `:selected` would be silently wrong. | |
| 58 | -(def ^:private spatial-props | |
| 59 | - #{:margin :margin-top :margin-bottom :margin-left :margin-right | |
| 60 | - :padding :padding-top :padding-bottom :padding-left :padding-right | |
| 61 | - :spacing :gap :size :reserve | |
| 62 | - :width-request :height-request :max-width :min-width :max-height}) | |
| 51 | +;; across, and a tree written for cells leaves both scales at 1. | |
| 52 | +;; | |
| 53 | +;; Two scales, because a cell is not square. It is about eight points across | |
| 54 | +;; and sixteen down, so one divisor for both spends twice as much of the screen | |
| 55 | +;; on vertical air as the design asked for — and vertical air is the whole | |
| 56 | +;; budget: a chat backlog is measured in how many messages fit. A `:spacing 8` | |
| 57 | +;; between every pair of rows is half a row, which is to say none; the same 8 | |
| 58 | +;; between two buttons is a column, which is the space that keeps them apart. | |
| 59 | +(defonce ^:private col-scale (atom 1)) | |
| 60 | +(defonce ^:private row-scale (atom 1)) | |
| 61 | + | |
| 62 | +;; The props that are a distance rather than a count, a flag or a name, split | |
| 63 | +;; by the axis each one measures. A key none of these knows crosses unscaled, | |
| 64 | +;; which is the right way round: a number that turns out to be a length paints | |
| 65 | +;; a little large, where a scaled `:value` or `:selected` would be silently | |
| 66 | +;; wrong. | |
| 67 | +(def ^:private col-props | |
| 68 | + #{:width-request :max-width :min-width :margin-left :margin-right | |
| 69 | + :padding-left :padding-right}) | |
| 70 | + | |
| 71 | +;; `:margin` and `:padding` are one number for both axes and there is one | |
| 72 | +;; inset under them, so they are counted as rows: the tighter of the two | |
| 73 | +;; readings, and the axis where being loose costs a message. | |
| 74 | +(def ^:private row-props | |
| 75 | + #{:height-request :max-height :reserve :size | |
| 76 | + :margin :margin-top :margin-bottom | |
| 77 | + :padding :padding-top :padding-bottom}) | |
| 63 | 78 | |
| 64 | 79 | (defn- scaled |
| 65 | - "`v` in cells, rounded away from zero so a margin that was asked for is at | |
| 66 | - least one cell of one." | |
| 67 | - [v] | |
| 68 | - (let [n (/ (double v) @scale)] | |
| 69 | - (cond | |
| 70 | - (zero? n) 0 | |
| 71 | - (< (Math/abs n) 1.0) (if (pos? n) 1 -1) | |
| 72 | - :else (Math/round n)))) | |
| 80 | + "`v` divided by `scale`, to the nearest cell, with a half going down. | |
| 81 | + | |
| 82 | + Half a cell is the case that decides how a screen reads, and it decides it | |
| 83 | + many times: frq's columns are spaced 8 points apart and a row is 16, so every | |
| 84 | + gap in the tree is exactly one half. Rounded up, the chat screen spends nine | |
| 85 | + rows on the nine gaps between its ten children — and most of those children | |
| 86 | + are empty wrappers, there to hold a place for something that is not on | |
| 87 | + screen, each now costing a blank line it was never meant to have. Rounded | |
| 88 | + down, a gap that thin is what it looks like at this size: nothing." | |
| 89 | + [v scale] | |
| 90 | + (long (Math/ceil (- (/ (double v) scale) 0.5)))) | |
| 73 | 91 | |
| 74 | 92 | ;; --- props ------------------------------------------------------------------- |
| 75 | 93 | ;; :hbox and :vbox are one node in the library; the tag only implies an |
| @@ -82,20 +100,34 @@ | ||
| 82 | 100 | (let [s (name k)] |
| 83 | 101 | (and (> (count s) 3) (= "on-" (subs s 0 3))))) |
| 84 | 102 | |
| 103 | +(defn- axis-scale | |
| 104 | + "Which divisor `k` is measured in, on a node laid out `vertical?`. | |
| 105 | + | |
| 106 | + `:spacing` and `:gap` are the ones that need asking: they are the distance | |
| 107 | + between a box's children, so which axis they run along is the box's own | |
| 108 | + orientation and not something the name says. nil for a prop that is not a | |
| 109 | + distance at all." | |
| 110 | + [k vertical?] | |
| 111 | + (cond | |
| 112 | + (contains? col-props k) @col-scale | |
| 113 | + (contains? row-props k) @row-scale | |
| 114 | + (contains? #{:spacing :gap} k) (if vertical? @row-scale @col-scale) | |
| 115 | + :else nil)) | |
| 116 | + | |
| 85 | 117 | (defn- set-prop! |
| 86 | 118 | "Write one prop to a node, in the ABI type that fits its value. nil clears |
| 87 | 119 | nothing — the prop was already dropped by the clear that precedes a write — |
| 88 | 120 | and an unrecognized value is stringified rather than refused, so a prop this |
| 89 | 121 | backend has not learned yet still reaches the library." |
| 90 | - [node k v] | |
| 122 | + [node k v vertical?] | |
| 91 | 123 | (let [key (name k)] |
| 92 | 124 | (cond |
| 93 | 125 | (nil? v) nil |
| 94 | 126 | (true? v) (ffi/node-set-bool! node key true) |
| 95 | 127 | (false? v) (ffi/node-set-bool! node key false) |
| 96 | 128 | (number? v) (ffi/node-set-num! node key |
| 97 | - (double (if (contains? spatial-props k) | |
| 98 | - (scaled v) | |
| 129 | + (double (if-let [scale (axis-scale k vertical?)] | |
| 130 | + (scaled v scale) | |
| 99 | 131 | v))) |
| 100 | 132 | (string? v) (ffi/node-set-str! node key v) |
| 101 | 133 | (keyword? v) (ffi/node-set-str! node key (name v)) |
| @@ -114,9 +146,16 @@ | ||
| 114 | 146 | (when-let [orientation (tag-orientation tag)] |
| 115 | 147 | (when-not (contains? props :orientation) |
| 116 | 148 | (ffi/node-set-str! node "orientation" orientation))) |
| 117 | - (doseq [[k v] props] | |
| 118 | - (when-not (handler-key? k) | |
| 119 | - (set-prop! node k v))) | |
| 149 | + ;; Which way this node lays its children out, for the props whose axis is the | |
| 150 | + ;; box's rather than their own. Everything that is not explicitly a row is a | |
| 151 | + ;; column, which is what the library assumes of a container it does not know. | |
| 152 | + (let [vertical? (not= "horizontal" | |
| 153 | + (or (some-> (:orientation props) name) | |
| 154 | + (tag-orientation tag) | |
| 155 | + "vertical"))] | |
| 156 | + (doseq [[k v] props] | |
| 157 | + (when-not (handler-key? k) | |
| 158 | + (set-prop! node k v vertical?)))) | |
| 120 | 159 | (swap! handlers assoc node |
| 121 | 160 | (reduce (fn [acc [k v]] |
| 122 | 161 | (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) |
| @@ -365,11 +404,15 @@ | ||
| 365 | 404 | |
| 366 | 405 | Options (on top of glimmer's own): |
| 367 | 406 | :mouse report clicks and the wheel (default true) |
| 368 | - :points-per-cell how many of the tree's own units go into one cell | |
| 369 | - (default 1). 8 is about right for a tree written against | |
| 370 | - a window: it is the width of a character in the size a | |
| 407 | + :points-per-cell how many of the tree's own units go across one cell | |
| 408 | + (default 1). 8 is about right for a tree written against a | |
| 409 | + window: it is the width of a character in the size a | |
| 371 | 410 | desktop UI uses, which is what those numbers were laid out |
| 372 | 411 | in. |
| 412 | + :points-per-row the same down the page (default: twice | |
| 413 | + `:points-per-cell`, because a cell is about twice as tall | |
| 414 | + as it is wide). This is the one that decides how much of a | |
| 415 | + conversation fits on a screen. | |
| 373 | 416 | :fps how often the loop wakes when no input arrives (default 60) |
| 374 | 417 | :headless [columns rows] — a session with no terminal at all, for a |
| 375 | 418 | test or a screenshot; input is fed by hand |
| @@ -380,9 +423,10 @@ | ||
| 380 | 423 | terminal in raw mode on the alternate screen — which is the one failure here |
| 381 | 424 | a reader cannot recover from without `reset`." |
| 382 | 425 | [opts mount-root!] |
| 383 | - (let [{:keys [mouse fps headless auto-quit-ms points-per-cell] | |
| 426 | + (let [{:keys [mouse fps headless auto-quit-ms points-per-cell points-per-row] | |
| 384 | 427 | :or {mouse true fps 60 points-per-cell 1}} opts |
| 385 | - _ (reset! scale (max 1 points-per-cell)) | |
| 428 | + _ (reset! col-scale (max 1 points-per-cell)) | |
| 429 | + _ (reset! row-scale (max 1 (or points-per-row (* 2 points-per-cell)))) | |
| 386 | 430 | opened? (if headless |
| 387 | 431 | (ffi/headless! (first headless) (second headless)) |
| 388 | 432 | (ffi/open! mouse))] |
| @@ -48,28 +48,46 @@ | |||
| 48 | ;; twelve blank rows and a column wider than the screen. The reconciler is not | 48 | ;; twelve blank rows and a column wider than the screen. The reconciler is not |
| 49 | ;; the place to fix that and neither is the app: the numbers are right, and it | 49 | ;; the place to fix that and neither is the app: the numbers are right, and it |
| 50 | ;; is the unit under them that changed. So the backend divides on the way | 50 | ;; is the unit under them that changed. So the backend divides on the way |
| 51 | -;; across, and a tree written for cells leaves the scale at 1. | 51 | +;; across, and a tree written for cells leaves both scales at 1. |
| 52 | -(defonce ^:private scale (atom 1)) | 52 | +;; |
| 53 | - | 53 | +;; Two scales, because a cell is not square. It is about eight points across |
| 54 | -;; The props that are a distance rather than a count, a flag or a name. A key | 54 | +;; and sixteen down, so one divisor for both spends twice as much of the screen |
| 55 | -;; this list does not know crosses unscaled, which is the right way round: a | 55 | +;; on vertical air as the design asked for — and vertical air is the whole |
| 56 | -;; number that turns out to be a length paints a little large, where a scaled | 56 | +;; budget: a chat backlog is measured in how many messages fit. A `:spacing 8` |
| 57 | -;; `:value` or `:selected` would be silently wrong. | 57 | +;; between every pair of rows is half a row, which is to say none; the same 8 |
| 58 | -(def ^:private spatial-props | 58 | +;; between two buttons is a column, which is the space that keeps them apart. |
| 59 | - #{:margin :margin-top :margin-bottom :margin-left :margin-right | 59 | +(defonce ^:private col-scale (atom 1)) |
| 60 | - :padding :padding-top :padding-bottom :padding-left :padding-right | 60 | +(defonce ^:private row-scale (atom 1)) |
| 61 | - :spacing :gap :size :reserve | 61 | + |
| 62 | - :width-request :height-request :max-width :min-width :max-height}) | 62 | +;; The props that are a distance rather than a count, a flag or a name, split |
| 63 | +;; by the axis each one measures. A key none of these knows crosses unscaled, | ||
| 64 | +;; which is the right way round: a number that turns out to be a length paints | ||
| 65 | +;; a little large, where a scaled `:value` or `:selected` would be silently | ||
| 66 | +;; wrong. | ||
| 67 | +(def ^:private col-props | ||
| 68 | + #{:width-request :max-width :min-width :margin-left :margin-right | ||
| 69 | + :padding-left :padding-right}) | ||
| 70 | + | ||
| 71 | +;; `:margin` and `:padding` are one number for both axes and there is one | ||
| 72 | +;; inset under them, so they are counted as rows: the tighter of the two | ||
| 73 | +;; readings, and the axis where being loose costs a message. | ||
| 74 | +(def ^:private row-props | ||
| 75 | + #{:height-request :max-height :reserve :size | ||
| 76 | + :margin :margin-top :margin-bottom | ||
| 77 | + :padding :padding-top :padding-bottom}) | ||
| 63 | 78 | ||
| 64 | (defn- scaled | 79 | (defn- scaled |
| 65 | - "`v` in cells, rounded away from zero so a margin that was asked for is at | 80 | + "`v` divided by `scale`, to the nearest cell, with a half going down. |
| 66 | - least one cell of one." | 81 | + |
| 67 | - [v] | 82 | + Half a cell is the case that decides how a screen reads, and it decides it |
| 68 | - (let [n (/ (double v) @scale)] | 83 | + many times: frq's columns are spaced 8 points apart and a row is 16, so every |
| 69 | - (cond | 84 | + gap in the tree is exactly one half. Rounded up, the chat screen spends nine |
| 70 | - (zero? n) 0 | 85 | + rows on the nine gaps between its ten children — and most of those children |
| 71 | - (< (Math/abs n) 1.0) (if (pos? n) 1 -1) | 86 | + are empty wrappers, there to hold a place for something that is not on |
| 72 | - :else (Math/round n)))) | 87 | + screen, each now costing a blank line it was never meant to have. Rounded |
| 88 | + down, a gap that thin is what it looks like at this size: nothing." | ||
| 89 | + [v scale] | ||
| 90 | + (long (Math/ceil (- (/ (double v) scale) 0.5)))) | ||
| 73 | 91 | ||
| 74 | ;; --- props ------------------------------------------------------------------- | 92 | ;; --- props ------------------------------------------------------------------- |
| 75 | ;; :hbox and :vbox are one node in the library; the tag only implies an | 93 | ;; :hbox and :vbox are one node in the library; the tag only implies an |
| @@ -82,20 +100,34 @@ | |||
| 82 | (let [s (name k)] | 100 | (let [s (name k)] |
| 83 | (and (> (count s) 3) (= "on-" (subs s 0 3))))) | 101 | (and (> (count s) 3) (= "on-" (subs s 0 3))))) |
| 84 | 102 | ||
| 103 | +(defn- axis-scale | ||
| 104 | + "Which divisor `k` is measured in, on a node laid out `vertical?`. | ||
| 105 | + | ||
| 106 | + `:spacing` and `:gap` are the ones that need asking: they are the distance | ||
| 107 | + between a box's children, so which axis they run along is the box's own | ||
| 108 | + orientation and not something the name says. nil for a prop that is not a | ||
| 109 | + distance at all." | ||
| 110 | + [k vertical?] | ||
| 111 | + (cond | ||
| 112 | + (contains? col-props k) @col-scale | ||
| 113 | + (contains? row-props k) @row-scale | ||
| 114 | + (contains? #{:spacing :gap} k) (if vertical? @row-scale @col-scale) | ||
| 115 | + :else nil)) | ||
| 116 | + | ||
| 85 | (defn- set-prop! | 117 | (defn- set-prop! |
| 86 | "Write one prop to a node, in the ABI type that fits its value. nil clears | 118 | "Write one prop to a node, in the ABI type that fits its value. nil clears |
| 87 | nothing — the prop was already dropped by the clear that precedes a write — | 119 | nothing — the prop was already dropped by the clear that precedes a write — |
| 88 | and an unrecognized value is stringified rather than refused, so a prop this | 120 | and an unrecognized value is stringified rather than refused, so a prop this |
| 89 | backend has not learned yet still reaches the library." | 121 | backend has not learned yet still reaches the library." |
| 90 | - [node k v] | 122 | + [node k v vertical?] |
| 91 | (let [key (name k)] | 123 | (let [key (name k)] |
| 92 | (cond | 124 | (cond |
| 93 | (nil? v) nil | 125 | (nil? v) nil |
| 94 | (true? v) (ffi/node-set-bool! node key true) | 126 | (true? v) (ffi/node-set-bool! node key true) |
| 95 | (false? v) (ffi/node-set-bool! node key false) | 127 | (false? v) (ffi/node-set-bool! node key false) |
| 96 | (number? v) (ffi/node-set-num! node key | 128 | (number? v) (ffi/node-set-num! node key |
| 97 | - (double (if (contains? spatial-props k) | 129 | + (double (if-let [scale (axis-scale k vertical?)] |
| 98 | - (scaled v) | 130 | + (scaled v scale) |
| 99 | v))) | 131 | v))) |
| 100 | (string? v) (ffi/node-set-str! node key v) | 132 | (string? v) (ffi/node-set-str! node key v) |
| 101 | (keyword? v) (ffi/node-set-str! node key (name v)) | 133 | (keyword? v) (ffi/node-set-str! node key (name v)) |
| @@ -114,9 +146,16 @@ | |||
| 114 | (when-let [orientation (tag-orientation tag)] | 146 | (when-let [orientation (tag-orientation tag)] |
| 115 | (when-not (contains? props :orientation) | 147 | (when-not (contains? props :orientation) |
| 116 | (ffi/node-set-str! node "orientation" orientation))) | 148 | (ffi/node-set-str! node "orientation" orientation))) |
| 117 | - (doseq [[k v] props] | 149 | + ;; Which way this node lays its children out, for the props whose axis is the |
| 118 | - (when-not (handler-key? k) | 150 | + ;; box's rather than their own. Everything that is not explicitly a row is a |
| 119 | - (set-prop! node k v))) | 151 | + ;; column, which is what the library assumes of a container it does not know. |
| 152 | + (let [vertical? (not= "horizontal" | ||
| 153 | + (or (some-> (:orientation props) name) | ||
| 154 | + (tag-orientation tag) | ||
| 155 | + "vertical"))] | ||
| 156 | + (doseq [[k v] props] | ||
| 157 | + (when-not (handler-key? k) | ||
| 158 | + (set-prop! node k v vertical?)))) | ||
| 120 | (swap! handlers assoc node | 159 | (swap! handlers assoc node |
| 121 | (reduce (fn [acc [k v]] | 160 | (reduce (fn [acc [k v]] |
| 122 | (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) | 161 | (if (and (handler-key? k) (fn? v)) (assoc acc k v) acc)) |
| @@ -365,11 +404,15 @@ | |||
| 365 | 404 | ||
| 366 | Options (on top of glimmer's own): | 405 | Options (on top of glimmer's own): |
| 367 | :mouse report clicks and the wheel (default true) | 406 | :mouse report clicks and the wheel (default true) |
| 368 | - :points-per-cell how many of the tree's own units go into one cell | 407 | + :points-per-cell how many of the tree's own units go across one cell |
| 369 | - (default 1). 8 is about right for a tree written against | 408 | + (default 1). 8 is about right for a tree written against a |
| 370 | - a window: it is the width of a character in the size a | 409 | + window: it is the width of a character in the size a |
| 371 | desktop UI uses, which is what those numbers were laid out | 410 | desktop UI uses, which is what those numbers were laid out |
| 372 | in. | 411 | in. |
| 412 | + :points-per-row the same down the page (default: twice | ||
| 413 | + `:points-per-cell`, because a cell is about twice as tall | ||
| 414 | + as it is wide). This is the one that decides how much of a | ||
| 415 | + conversation fits on a screen. | ||
| 373 | :fps how often the loop wakes when no input arrives (default 60) | 416 | :fps how often the loop wakes when no input arrives (default 60) |
| 374 | :headless [columns rows] — a session with no terminal at all, for a | 417 | :headless [columns rows] — a session with no terminal at all, for a |
| 375 | test or a screenshot; input is fed by hand | 418 | test or a screenshot; input is fed by hand |
| @@ -380,9 +423,10 @@ | |||
| 380 | terminal in raw mode on the alternate screen — which is the one failure here | 423 | terminal in raw mode on the alternate screen — which is the one failure here |
| 381 | a reader cannot recover from without `reset`." | 424 | a reader cannot recover from without `reset`." |
| 382 | [opts mount-root!] | 425 | [opts mount-root!] |
| 383 | - (let [{:keys [mouse fps headless auto-quit-ms points-per-cell] | 426 | + (let [{:keys [mouse fps headless auto-quit-ms points-per-cell points-per-row] |
| 384 | :or {mouse true fps 60 points-per-cell 1}} opts | 427 | :or {mouse true fps 60 points-per-cell 1}} opts |
| 385 | - _ (reset! scale (max 1 points-per-cell)) | 428 | + _ (reset! col-scale (max 1 points-per-cell)) |
| 429 | + _ (reset! row-scale (max 1 (or points-per-row (* 2 points-per-cell)))) | ||
| 386 | opened? (if headless | 430 | opened? (if headless |
| 387 | (ffi/headless! (first headless) (second headless)) | 431 | (ffi/headless! (first headless) (second headless)) |
| 388 | (ffi/open! mouse))] | 432 | (ffi/open! mouse))] |