nandi/frqpublic Fork 0
f978612
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Hang a message under its sender's name, in a terminal

A face is a picture, and a cell grid has none: the avatar in a message
came out as the sender's initial, one column wide, which indented every
nick past the text it heads. The name is the heading here, so the face
goes and the words hang under it — the shape a terminal has read a
conversation in for forty years.

The words are held in by a spacer rather than a margin. The backend
insets a node by `:margin` on all four sides or not at all: there is no
left margin in a cell grid, and a uniform one would spend a blank row
above every message to buy two columns beside it.

And two of frq's gaps are written as lengths too small to survive the
trip. `:margin-top 10` between messages is ten points against a row of
sixteen, which rounds to nothing — so every message touched the one above
it and the backlog read as one paragraph with names scattered through it.
`:spacing 4` between two chips is half a column, which rounds the same
way and put 🙂 and ↩️ hard against each other, one wide glyph as far as a
reader is concerned. The terminal buys both outright: a row between
messages, a column between chips. The window keeps its own numbers, which
were never wrong there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T01:12:27-07:00 Browse files
f978612 parent: 57c6de2
modified src/frq/app.jolt +97 -39
@@ -180,6 +180,46 @@
180180
181181 (defn- chrome-scale [] (/ (double @chrome-row) window-row))
182182
183+;; Whether the backend under this tree is a terminal, set by `frq.tui` before
184+;; the first paint and never again. Two things in a message hang on it, and
185+;; both are about a cell grid rather than a canvas: there is no picture to
186+;; draw a face with, and a name with nothing to its left is the heading the
187+;; text hangs off — the shape a terminal has read messages in for forty years.
188+(defonce terminal? (atom false))
189+
190+(defn- chip-gap
191+ "The air between two chips on a row.
192+
193+ Four points is half a column, which rounds to none: in a window that is the
194+ gap a pair of lozenges want, and in a terminal it puts two emoji hard against
195+ each other and they read as one wide glyph. A column, where a column is the
196+ smallest thing there is."
197+ []
198+ (if @terminal? 8 4))
199+
200+(defn- indented
201+ "One of a message's columns, held in from the name above it — in a terminal.
202+
203+ The nick is the heading and the words hang under it, which is the shape a
204+ terminal has read a conversation in for forty years; in a window the column
205+ of faces already marks that edge, so there is nothing to hold in.
206+
207+ A cell of its own rather than a margin, because the backend insets a node by
208+ `:margin` on all four sides or not at all: there is no left margin in a cell
209+ grid, and a uniform one would spend a blank row above every message to buy
210+ two columns beside it.
211+
212+ The spacer asks for its width and no height. A `:size` is a length down the
213+ page, and a spacer two cells tall beside an empty column — a message with no
214+ picture and no reactions — is two blank rows in the middle of the backlog,
215+ which is a message and a half at this size."
216+ [k node]
217+ (if @terminal?
218+ [:hbox {:key k :spacing 0}
219+ [:spacer {:key :indent :size 0 :width-request 16}]
220+ node]
221+ node))
222+
183223 (defn- below-list []
184224 ;; What the strip under the list needs, counted: the gap after the list, the
185225 ;; separator, the gap under it, the tab bar's row of buttons and the margin
@@ -408,7 +448,7 @@
408448 row at a remove from the pair it belongs with. It keys itself, so the line
409449 that has no pencil is a row of two chips and not a row with a hole in it."
410450 [channel m]
411- (into [:hbox {:key :actions :align :end :spacing 4}
451+ (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
412452 [:reaction {:key :react
413453 :emoji "🙂"
414454 :size pill-size
@@ -450,7 +490,7 @@
450490 Twemoji pack, in colour, where a label gets whatever the text font has."
451491 [channel m]
452492 (let [reactions (:reactions m)]
453- [:hbox {:key :pills :spacing 4}
493+ [:hbox {:key :pills :spacing (chip-gap)}
454494 (for [emoji (sort (keys reactions))]
455495 [:reaction (cond-> {:key emoji
456496 :emoji emoji
@@ -634,6 +674,13 @@
634674 :margin-top 10
635675 ;; The jump target is what a "go to message" click scrolls to.
636676 :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))}
677+ ;; The gap above, where the margin cannot be one. `:margin-top 10` is
678+ ;; most of a row in a window and nothing at all in a terminal — ten points
679+ ;; against a row of sixteen, rounded down, because a gap that thin is what
680+ ;; it looks like at this size. So the terminal buys the row outright:
681+ ;; without it every message in the backlog touches the one above it, and a
682+ ;; conversation reads as one paragraph with names scattered through it.
683+ (when @terminal? [:spacer {:key :gap :size 16}])
637684 ;; Likewise a key each: the surface a jump leaves behind is a different
638685 ;; node from the plain column, not the same node wearing another tag.
639686 [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
@@ -661,19 +708,24 @@
661708 ;; And, where there is a pointer to ask with, resting on the face
662709 ;; answers before the click does: the card under it is the profile
663710 ;; screen's first few lines, painted beside the pointer.
664- (let [_ @s/media-tick]
665- [:avatar (cond-> {:label (:from m)
666- :src (or (avatars/path-when-ready (:actor m)) "")
667- :size 32
668- :on-click #(profile/open! (:from m) (:actor m))}
669- (platform/desktop?)
670- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
671- :on-unhover #(profile/unhover! (:from m))))
672- ;; Only the hovered face carries one: a card is painted when its
673- ;; node has children, and the pointer is on one face at a time.
674- (when (and (platform/desktop?)
675- (= (:from m) (:nick @profile/hovering)))
676- [hover-card (:from m) (:actor m)])])
711+ ;; And in a terminal there is no face at all: a cell grid has no
712+ ;; picture to put in one, and the initial standing in for it is the
713+ ;; first letter of the name printed twice, indenting every nick past
714+ ;; the words it heads.
715+ (when-not @terminal?
716+ (let [_ @s/media-tick]
717+ [:avatar (cond-> {:label (:from m)
718+ :src (or (avatars/path-when-ready (:actor m)) "")
719+ :size 32
720+ :on-click #(profile/open! (:from m) (:actor m))}
721+ (platform/desktop?)
722+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
723+ :on-unhover #(profile/unhover! (:from m))))
724+ ;; Only the hovered face carries one: a card is painted when its
725+ ;; node has children, and the pointer is on one face at a time.
726+ (when (and (platform/desktop?)
727+ (= (:from m) (:nick @profile/hovering)))
728+ [hover-card (:from m) (:actor m)])]))
677729 ;; The name carries the row, so it is set at body size in the plain
678730 ;; text colour: dimmed caption made the one thing you scan a column
679731 ;; for the faintest thing on it.
@@ -700,16 +752,18 @@
700752 ;; for what follows, and a heading that touches its text is not one.
701753 ;; The text keeps a column of its own: a wrapping label directly in a
702754 ;; row wraps against the row.
703- [:vbox {:key :text :spacing 2 :margin-top 4}
704- ;; What this answers, in the column the answer itself is in: the chip
705- ;; is a quote of a line, so it wraps to the width a line has. Outside
706- ;; this column it wrapped to the whole row instead — wider than any
707- ;; message, and out to the window's edge on a narrow screen.
708- [:vbox {:key :reply-chip}
709- (when-let [reply-to (:reply-to m)]
710- [reply-chip @s/current reply-to])]
711- (map-indexed (fn [j run] (run-node j run (:system? m)))
712- (text-runs (:text m)))]
755+ (indented
756+ :text
757+ [:vbox {:key :text :spacing 2 :margin-top 4}
758+ ;; What this answers, in the column the answer itself is in: the chip
759+ ;; is a quote of a line, so it wraps to the width a line has. Outside
760+ ;; this column it wrapped to the whole row instead — wider than any
761+ ;; message, and out to the window's edge on a narrow screen.
762+ [:vbox {:key :reply-chip}
763+ (when-let [reply-to (:reply-to m)]
764+ [reply-chip @s/current reply-to])]
765+ (map-indexed (fn [j run] (run-node j run (:system? m)))
766+ (text-runs (:text m)))])
713767 ;; And the picker, when this is the message it was opened on: under the
714768 ;; line it is about, where the reader is already looking.
715769 ;;
@@ -721,25 +775,29 @@
721775 [emoji-picker])]
722776 ;; Pictures under the line that linked them. The link stays: it is what a
723777 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
724- [:vbox {:key :images :spacing 4}
725- (when (seq (:images m))
726- ;; Reading the tick is what subscribes this row to a fetch finishing.
727- (let [_ @s/media-tick]
728- (for [url (:images m)]
729- (when-let [path (media/path-when-ready url)]
730- [:image {:key url
731- :src path
732- :max-height (preview-height)
733- :max-width (preview-width)
734- :on-click #(reset! s/lightbox {:path path :url url})}]))))]
778+ (indented
779+ :images
780+ [:vbox {:key :images :spacing 4}
781+ (when (seq (:images m))
782+ ;; Reading the tick is what subscribes this row to a fetch finishing.
783+ (let [_ @s/media-tick]
784+ (for [url (:images m)]
785+ (when-let [path (media/path-when-ready url)]
786+ [:image {:key url
787+ :src path
788+ :max-height (preview-height)
789+ :max-width (preview-width)
790+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
735791 ;; Reactions go last, under whatever the message turned out to be: a
736792 ;; line with a picture on it is the picture, and pills between the words
737793 ;; and the image they introduce read as reactions to the words alone.
738794 ;; Nothing moves for a message with no picture — the box above it is
739795 ;; empty, and the pills still sit under the last line of text.
740- [:vbox {:key :reactions-row :margin-top 6}
741- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
742- [reaction-row @s/current m])]]]))
796+ (indented
797+ :reactions-row
798+ [:vbox {:key :reactions-row :margin-top 6}
799+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
800+ [reaction-row @s/current m])])]]))
743801
744802 (defn- day-separator [day-key label]
745803 [:vbox {:key day-key :spacing 4 :margin 0}
@@ -180,6 +180,46 @@
180 180
181 (defn- chrome-scale [] (/ (double @chrome-row) window-row))181 (defn- chrome-scale [] (/ (double @chrome-row) window-row))
182 182
183+;; Whether the backend under this tree is a terminal, set by `frq.tui` before
184+;; the first paint and never again. Two things in a message hang on it, and
185+;; both are about a cell grid rather than a canvas: there is no picture to
186+;; draw a face with, and a name with nothing to its left is the heading the
187+;; text hangs off — the shape a terminal has read messages in for forty years.
188+(defonce terminal? (atom false))
189+
190+(defn- chip-gap
191+ "The air between two chips on a row.
192+
193+ Four points is half a column, which rounds to none: in a window that is the
194+ gap a pair of lozenges want, and in a terminal it puts two emoji hard against
195+ each other and they read as one wide glyph. A column, where a column is the
196+ smallest thing there is."
197+ []
198+ (if @terminal? 8 4))
199+
200+(defn- indented
201+ "One of a message's columns, held in from the name above it — in a terminal.
202+
203+ The nick is the heading and the words hang under it, which is the shape a
204+ terminal has read a conversation in for forty years; in a window the column
205+ of faces already marks that edge, so there is nothing to hold in.
206+
207+ A cell of its own rather than a margin, because the backend insets a node by
208+ `:margin` on all four sides or not at all: there is no left margin in a cell
209+ grid, and a uniform one would spend a blank row above every message to buy
210+ two columns beside it.
211+
212+ The spacer asks for its width and no height. A `:size` is a length down the
213+ page, and a spacer two cells tall beside an empty column — a message with no
214+ picture and no reactions — is two blank rows in the middle of the backlog,
215+ which is a message and a half at this size."
216+ [k node]
217+ (if @terminal?
218+ [:hbox {:key k :spacing 0}
219+ [:spacer {:key :indent :size 0 :width-request 16}]
220+ node]
221+ node))
222+
183 (defn- below-list []223 (defn- below-list []
184 ;; What the strip under the list needs, counted: the gap after the list, the224 ;; What the strip under the list needs, counted: the gap after the list, the
185 ;; separator, the gap under it, the tab bar's row of buttons and the margin225 ;; separator, the gap under it, the tab bar's row of buttons and the margin
@@ -408,7 +448,7 @@
408 row at a remove from the pair it belongs with. It keys itself, so the line448 row at a remove from the pair it belongs with. It keys itself, so the line
409 that has no pencil is a row of two chips and not a row with a hole in it."449 that has no pencil is a row of two chips and not a row with a hole in it."
410 [channel m]450 [channel m]
411- (into [:hbox {:key :actions :align :end :spacing 4}451+ (into [:hbox {:key :actions :align :end :spacing (chip-gap)}
412 [:reaction {:key :react452 [:reaction {:key :react
413 :emoji "🙂"453 :emoji "🙂"
414 :size pill-size454 :size pill-size
@@ -450,7 +490,7 @@
450 Twemoji pack, in colour, where a label gets whatever the text font has."490 Twemoji pack, in colour, where a label gets whatever the text font has."
451 [channel m]491 [channel m]
452 (let [reactions (:reactions m)]492 (let [reactions (:reactions m)]
453- [:hbox {:key :pills :spacing 4}493+ [:hbox {:key :pills :spacing (chip-gap)}
454 (for [emoji (sort (keys reactions))]494 (for [emoji (sort (keys reactions))]
455 [:reaction (cond-> {:key emoji495 [:reaction (cond-> {:key emoji
456 :emoji emoji496 :emoji emoji
@@ -634,6 +674,13 @@
634 :margin-top 10674 :margin-top 10
635 ;; The jump target is what a "go to message" click scrolls to.675 ;; The jump target is what a "go to message" click scrolls to.
636 :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))}676 :scroll-here (boolean (and (:id m) (= (:id m) @s/jump-to)))}
677+ ;; The gap above, where the margin cannot be one. `:margin-top 10` is
678+ ;; most of a row in a window and nothing at all in a terminal — ten points
679+ ;; against a row of sixteen, rounded down, because a gap that thin is what
680+ ;; it looks like at this size. So the terminal buys the row outright:
681+ ;; without it every message in the backlog touches the one above it, and a
682+ ;; conversation reads as one paragraph with names scattered through it.
683+ (when @terminal? [:spacer {:key :gap :size 16}])
637 ;; Likewise a key each: the surface a jump leaves behind is a different684 ;; Likewise a key each: the surface a jump leaves behind is a different
638 ;; node from the plain column, not the same node wearing another tag.685 ;; node from the plain column, not the same node wearing another tag.
639 [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)686 [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
@@ -661,19 +708,24 @@
661 ;; And, where there is a pointer to ask with, resting on the face708 ;; And, where there is a pointer to ask with, resting on the face
662 ;; answers before the click does: the card under it is the profile709 ;; answers before the click does: the card under it is the profile
663 ;; screen's first few lines, painted beside the pointer.710 ;; screen's first few lines, painted beside the pointer.
664- (let [_ @s/media-tick]711+ ;; And in a terminal there is no face at all: a cell grid has no
665- [:avatar (cond-> {:label (:from m)712+ ;; picture to put in one, and the initial standing in for it is the
666- :src (or (avatars/path-when-ready (:actor m)) "")713+ ;; first letter of the name printed twice, indenting every nick past
667- :size 32714+ ;; the words it heads.
668- :on-click #(profile/open! (:from m) (:actor m))}715+ (when-not @terminal?
669- (platform/desktop?)716+ (let [_ @s/media-tick]
670- (assoc :on-hover #(profile/hover! (:from m) (:actor m))717+ [:avatar (cond-> {:label (:from m)
671- :on-unhover #(profile/unhover! (:from m))))718+ :src (or (avatars/path-when-ready (:actor m)) "")
672- ;; Only the hovered face carries one: a card is painted when its719+ :size 32
673- ;; node has children, and the pointer is on one face at a time.720+ :on-click #(profile/open! (:from m) (:actor m))}
674- (when (and (platform/desktop?)721+ (platform/desktop?)
675- (= (:from m) (:nick @profile/hovering)))722+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
676- [hover-card (:from m) (:actor m)])])723+ :on-unhover #(profile/unhover! (:from m))))
724+ ;; Only the hovered face carries one: a card is painted when its
725+ ;; node has children, and the pointer is on one face at a time.
726+ (when (and (platform/desktop?)
727+ (= (:from m) (:nick @profile/hovering)))
728+ [hover-card (:from m) (:actor m)])]))
677 ;; The name carries the row, so it is set at body size in the plain729 ;; The name carries the row, so it is set at body size in the plain
678 ;; text colour: dimmed caption made the one thing you scan a column730 ;; text colour: dimmed caption made the one thing you scan a column
679 ;; for the faintest thing on it.731 ;; for the faintest thing on it.
@@ -700,16 +752,18 @@
700 ;; for what follows, and a heading that touches its text is not one.752 ;; for what follows, and a heading that touches its text is not one.
701 ;; The text keeps a column of its own: a wrapping label directly in a753 ;; The text keeps a column of its own: a wrapping label directly in a
702 ;; row wraps against the row.754 ;; row wraps against the row.
703- [:vbox {:key :text :spacing 2 :margin-top 4}755+ (indented
704- ;; What this answers, in the column the answer itself is in: the chip756+ :text
705- ;; is a quote of a line, so it wraps to the width a line has. Outside757+ [:vbox {:key :text :spacing 2 :margin-top 4}
706- ;; this column it wrapped to the whole row instead — wider than any758+ ;; What this answers, in the column the answer itself is in: the chip
707- ;; message, and out to the window's edge on a narrow screen.759+ ;; is a quote of a line, so it wraps to the width a line has. Outside
708- [:vbox {:key :reply-chip}760+ ;; this column it wrapped to the whole row instead — wider than any
709- (when-let [reply-to (:reply-to m)]761+ ;; message, and out to the window's edge on a narrow screen.
710- [reply-chip @s/current reply-to])]762+ [:vbox {:key :reply-chip}
711- (map-indexed (fn [j run] (run-node j run (:system? m)))763+ (when-let [reply-to (:reply-to m)]
712- (text-runs (:text m)))]764+ [reply-chip @s/current reply-to])]
765+ (map-indexed (fn [j run] (run-node j run (:system? m)))
766+ (text-runs (:text m)))])
713 ;; And the picker, when this is the message it was opened on: under the767 ;; And the picker, when this is the message it was opened on: under the
714 ;; line it is about, where the reader is already looking.768 ;; line it is about, where the reader is already looking.
715 ;;769 ;;
@@ -721,25 +775,29 @@
721 [emoji-picker])]775 [emoji-picker])]
722 ;; Pictures under the line that linked them. The link stays: it is what a776 ;; Pictures under the line that linked them. The link stays: it is what a
723 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.777 ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
724- [:vbox {:key :images :spacing 4}778+ (indented
725- (when (seq (:images m))779+ :images
726- ;; Reading the tick is what subscribes this row to a fetch finishing.780+ [:vbox {:key :images :spacing 4}
727- (let [_ @s/media-tick]781+ (when (seq (:images m))
728- (for [url (:images m)]782+ ;; Reading the tick is what subscribes this row to a fetch finishing.
729- (when-let [path (media/path-when-ready url)]783+ (let [_ @s/media-tick]
730- [:image {:key url784+ (for [url (:images m)]
731- :src path785+ (when-let [path (media/path-when-ready url)]
732- :max-height (preview-height)786+ [:image {:key url
733- :max-width (preview-width)787+ :src path
734- :on-click #(reset! s/lightbox {:path path :url url})}]))))]788+ :max-height (preview-height)
789+ :max-width (preview-width)
790+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
735 ;; Reactions go last, under whatever the message turned out to be: a791 ;; Reactions go last, under whatever the message turned out to be: a
736 ;; line with a picture on it is the picture, and pills between the words792 ;; line with a picture on it is the picture, and pills between the words
737 ;; and the image they introduce read as reactions to the words alone.793 ;; and the image they introduce read as reactions to the words alone.
738 ;; Nothing moves for a message with no picture — the box above it is794 ;; Nothing moves for a message with no picture — the box above it is
739 ;; empty, and the pills still sit under the last line of text.795 ;; empty, and the pills still sit under the last line of text.
740- [:vbox {:key :reactions-row :margin-top 6}796+ (indented
741- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))797+ :reactions-row
742- [reaction-row @s/current m])]]]))798+ [:vbox {:key :reactions-row :margin-top 6}
799+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
800+ [reaction-row @s/current m])])]]))
743 801
744 (defn- day-separator [day-key label]802 (defn- day-separator [day-key label]
745 [:vbox {:key day-key :spacing 4 :margin 0}803 [:vbox {:key day-key :spacing 4 :margin 0}
modified src/frq/tui.jolt +4 -0
@@ -107,6 +107,10 @@
107107 ;; under a scrolling list reserves the three rows it needs rather than the
108108 ;; nine the window's count works out to.
109109 (reset! app/chrome-row points-per-row)
110+ ;; And a message here is a name with its words hanging under it, rather than
111+ ;; a face with them beside it: there is no picture in a cell, and the initial
112+ ;; that stood in for one only pushed every nick in past its own text.
113+ (reset! app/terminal? true)
110114 (app/start! {:after! tui/after!
111115 :every! tui/every!
112116 :title! nil
@@ -107,6 +107,10 @@
107 ;; under a scrolling list reserves the three rows it needs rather than the107 ;; under a scrolling list reserves the three rows it needs rather than the
108 ;; nine the window's count works out to.108 ;; nine the window's count works out to.
109 (reset! app/chrome-row points-per-row)109 (reset! app/chrome-row points-per-row)
110+ ;; And a message here is a name with its words hanging under it, rather than
111+ ;; a face with them beside it: there is no picture in a cell, and the initial
112+ ;; that stood in for one only pushed every nick in past its own text.
113+ (reset! app/terminal? true)
110 (app/start! {:after! tui/after!114 (app/start! {:after! tui/after!
111 :every! tui/every!115 :every! tui/every!
112 :title! nil116 :title! nil