nandi/frqpublic Fork
⑂ 9e3e031
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.

Download ZIP

Hang a face off a message where the terminal can draw one

A cell holds a character, so the terminal has read a message as a nick with
its words under it — the initial that stands in for a portrait in a window
was a letter printed twice, indenting every nick past its own text. Kitty's
graphics protocol is a picture over those cells, which jolt-tui already
places for an `:image`, and frq has the portrait on disk either way.

So the face comes back where the protocol is: `frq.tui` names the terminals
that answer it, the same way the library does, and a message hangs its
picture beside the whole of itself rather than off its heading. A portrait is
two rows and a heading is one, so on the heading its lower half would sit
beside a blank row; beside the message the words take that row, and the
column the face makes is the indent the rest of the message was holding
itself in by — so `indented` steps aside for it and nothing is spent.

The column keeps its width with no picture in it, for a fetch still in
flight and for the guests who have no profile at all: a backlog that steps
sideways as portraits land is worse than one with a gap down its left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T11:39:39-07:00 Browse files
9e3e031 parent: 7095bb6
modified src/frq/app.jolt +172 -119
@@ -141,6 +141,28 @@
141141 ;; text hangs off — the shape a terminal has read messages in for forty years.
142142 (defonce terminal? (atom false))
143143
144+;; And whether that terminal draws pictures over its cells — Kitty's graphics
145+;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
146+;; `frq.tui` works it out from the environment and sets this beside the flag
147+;; above; a terminal that has not got it is the one described there, a column
148+;; of names with the words hanging under them.
149+(defonce terminal-graphics? (atom false))
150+
151+(defn- terminal-face?
152+ "Whether a message carries a picture of its sender in a terminal.
153+
154+ Not the same question as `terminal?`: the face is drawn where the protocol
155+ for one is, and where it is not an `:image` is `[ picture ]` printed beside
156+ every nick — worse than the nothing that is there now."
157+ []
158+ (and @terminal? @terminal-graphics?))
159+
160+;; How big a face is on a message, in points — the size the window has always
161+;; drawn one at. A terminal's cell is eight points across and sixteen down, so
162+;; the same number is four columns by two rows there: a cached 128-pixel
163+;; portrait at a quarter size, and about the smallest a face is still a face at.
164+(def ^:private face-size 32)
165+
144166 ;; ---------------------------------------------------------------- chats
145167
146168 (defn- open-button [name]
@@ -226,9 +248,13 @@
226248 The spacer asks for its width and no height. A `:size` is a length down the
227249 page, and a spacer two cells tall beside an empty column — a message with no
228250 picture and no reactions — is two blank rows in the middle of the backlog,
229- which is a message and a half at this size."
251+ which is a message and a half at this size.
252+
253+ Nothing to do where a face is drawn: the column that holds the portrait is
254+ already to the left of the whole message, and a second indent inside it would
255+ hold the words in from a name that is itself held in."
230256 [k node]
231- (if @terminal?
257+ (if (and @terminal? (not (terminal-face?)))
232258 [:hbox {:key k :spacing 0}
233259 [:spacer {:key :indent :size 0 :width-request 16}]
234260 node]
@@ -676,6 +702,134 @@
676702 (let [w @s/window-width]
677703 (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
678704
705+(defn- message-body
706+ "A message without its face: the sender's line, the words, and what hangs
707+ under them.
708+
709+ Its own component because a terminal that draws pictures puts the face
710+ beside all of this rather than on the sender's line — the shape the window
711+ has always had, and the one that spends no row on the half of a portrait
712+ that is taller than a line of text."
713+ [m highlit?]
714+ ;; A key each: the surface a jump leaves behind is a different node from the
715+ ;; plain column, not the same node wearing another tag.
716+ [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
717+ :spacing 2 :margin 0}
718+ ;; Who, when, and what can be done about it, on one line above the
719+ ;; words: the name and the time say where the message came from, and the
720+ ;; chips at the far end are the two answers to it.
721+ [:vbox {:key :who}
722+ ;; An event has no sender to head it, but it still happened at a time,
723+ ;; and a column of joins and quits with no clock on it says nothing
724+ ;; about how long the room was quiet. The time alone is that heading.
725+ (when (and (:system? m) (:at m))
726+ [:dim-label {:label (clock/clock-time (:at m))}])
727+ (when-not (:system? m)
728+ ;; Everything that is about the person rather than the line: their
729+ ;; picture, their name, and when they started saying this.
730+ [:hbox {:spacing 6}
731+ ;; Always an avatar, picture or not: the initial stands in until the
732+ ;; fetch lands, and for the guests who have no profile at all, which
733+ ;; is what keeps the column of faces straight down the left.
734+ ;; Reading the tick subscribes this row to a fetch finishing.
735+ ;; The face is also the way to the person behind it: Vidya's plain
736+ ;; label does not answer the pointer, so the tap sleek puts on the
737+ ;; nick lives here, on the one thing in the row that does.
738+ ;; And, where there is a pointer to ask with, resting on the face
739+ ;; answers before the click does: the card under it is the profile
740+ ;; screen's first few lines, painted beside the pointer.
741+ ;; And no face on this line in a terminal: the initial that stands in
742+ ;; for a portrait in a window is a letter printed twice in a cell grid,
743+ ;; which only pushed every nick in past the words it heads. Where the
744+ ;; terminal can draw a picture there is a face after all, hung beside
745+ ;; the whole message rather than off its heading — `message-row` has it.
746+ (when-not @terminal?
747+ (let [_ @s/media-tick]
748+ [:avatar (cond-> {:label (:from m)
749+ :src (or (avatars/path-when-ready (:actor m)) "")
750+ :size face-size
751+ :on-click #(profile/open! (:from m) (:actor m))}
752+ (platform/desktop?)
753+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
754+ :on-unhover #(profile/unhover! (:from m))))
755+ ;; Only the hovered face carries one: a card is painted when its
756+ ;; node has children, and the pointer is on one face at a time.
757+ (when (and (platform/desktop?)
758+ (= (:from m) (:nick @profile/hovering)))
759+ [hover-card (:from m) (:actor m)])]))
760+ ;; The name carries the row, so it is set at body size in the plain
761+ ;; text colour: dimmed caption made the one thing you scan a column
762+ ;; for the faintest thing on it.
763+ [:label {:label (:from m)}]
764+ (when-let [at (:at m)]
765+ [:dim-label {:label (clock/clock-time at)}])
766+ ;; Beside the clock, because it is the same kind of fact about the
767+ ;; line: what is on screen is not what was first said.
768+ (when (:edited? m)
769+ [:dim-label {:label "(edited)"}])
770+ ;; And the two things you can do to the message, at the far end of
771+ ;; its heading: a nested row laid out from the right takes what is
772+ ;; left of the width and puts the chips against the edge of it, so
773+ ;; the words below keep the whole column.
774+ ;;
775+ ;; A key of its own for each branch, rather than one key whose node
776+ ;; changes tag under it: a line this client sent has no msgid until
777+ ;; the server echoes it back, and swapping a `:spacer` for a chip in
778+ ;; place is what left the row rebuilt in the wrong order.
779+ (if (:id m)
780+ [action-chips {:key :actions} @s/current m]
781+ [:spacer {:key :actions-gap :size 0}])])]
782+ ;; A little air under the sender's row: the name and face are a heading
783+ ;; for what follows, and a heading that touches its text is not one.
784+ ;; The text keeps a column of its own: a wrapping label directly in a
785+ ;; row wraps against the row.
786+ (indented
787+ :text
788+ [:vbox {:key :text :spacing 2 :margin-top 4}
789+ ;; What this answers, in the column the answer itself is in: the chip
790+ ;; is a quote of a line, so it wraps to the width a line has. Outside
791+ ;; this column it wrapped to the whole row instead — wider than any
792+ ;; message, and out to the window's edge on a narrow screen.
793+ [:vbox {:key :reply-chip}
794+ (when-let [reply-to (:reply-to m)]
795+ [reply-chip @s/current reply-to])]
796+ (map-indexed (fn [j run] (run-node j run (:system? m)))
797+ (text-runs (:text m)))])
798+ ;; And the picker, when this is the message it was opened on: under the
799+ ;; line it is about, where the reader is already looking.
800+ ;;
801+ ;; The id has to exist, not merely match: a line this client sent itself
802+ ;; has no msgid, and neither does a closed picker — so `nil = nil` was
803+ ;; every one of those messages opening a picker of its own at startup.
804+ [:vbox {:key :picker :margin-bottom 4}
805+ (when (and (:id m) (= (:id m) (:id @s/reacting)))
806+ [emoji-picker])]
807+ ;; Pictures under the line that linked them. The link stays: it is what a
808+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
809+ (indented
810+ :images
811+ [:vbox {:key :images :spacing 4}
812+ (when (seq (:images m))
813+ ;; Reading the tick is what subscribes this row to a fetch finishing.
814+ (let [_ @s/media-tick]
815+ (for [url (:images m)]
816+ (when-let [path (media/path-when-ready url)]
817+ [:image {:key url
818+ :src path
819+ :max-height (preview-height)
820+ :max-width (preview-width)
821+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
822+ ;; Reactions go last, under whatever the message turned out to be: a
823+ ;; line with a picture on it is the picture, and pills between the words
824+ ;; and the image they introduce read as reactions to the words alone.
825+ ;; Nothing moves for a message with no picture — the box above it is
826+ ;; empty, and the pills still sit under the last line of text.
827+ (indented
828+ :reactions-row
829+ [:vbox {:key :reactions-row :margin-top 6}
830+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
831+ [reaction-row @s/current m])])])
832+
679833 (defn message-row
680834 "One message: who said it, when, what you can do to it, and the words.
681835
@@ -710,123 +864,22 @@
710864 ;; without it every message in the backlog touches the one above it, and a
711865 ;; conversation reads as one paragraph with names scattered through it.
712866 (when @terminal? [:spacer {:key :gap :size 16}])
713- ;; Likewise a key each: the surface a jump leaves behind is a different
714- ;; node from the plain column, not the same node wearing another tag.
715- [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
716- :spacing 2 :margin 0}
717- ;; Who, when, and what can be done about it, on one line above the
718- ;; words: the name and the time say where the message came from, and the
719- ;; chips at the far end are the two answers to it.
720- [:vbox {:key :who}
721- ;; An event has no sender to head it, but it still happened at a time,
722- ;; and a column of joins and quits with no clock on it says nothing
723- ;; about how long the room was quiet. The time alone is that heading.
724- (when (and (:system? m) (:at m))
725- [:dim-label {:label (clock/clock-time (:at m))}])
726- (when-not (:system? m)
727- ;; Everything that is about the person rather than the line: their
728- ;; picture, their name, and when they started saying this.
729- [:hbox {:spacing 6}
730- ;; Always an avatar, picture or not: the initial stands in until the
731- ;; fetch lands, and for the guests who have no profile at all, which
732- ;; is what keeps the column of faces straight down the left.
733- ;; Reading the tick subscribes this row to a fetch finishing.
734- ;; The face is also the way to the person behind it: Vidya's plain
735- ;; label does not answer the pointer, so the tap sleek puts on the
736- ;; nick lives here, on the one thing in the row that does.
737- ;; And, where there is a pointer to ask with, resting on the face
738- ;; answers before the click does: the card under it is the profile
739- ;; screen's first few lines, painted beside the pointer.
740- ;; And in a terminal there is no face at all: a cell grid has no
741- ;; picture to put in one, and the initial standing in for it is the
742- ;; first letter of the name printed twice, indenting every nick past
743- ;; the words it heads.
744- (when-not @terminal?
745- (let [_ @s/media-tick]
746- [:avatar (cond-> {:label (:from m)
747- :src (or (avatars/path-when-ready (:actor m)) "")
748- :size 32
749- :on-click #(profile/open! (:from m) (:actor m))}
750- (platform/desktop?)
751- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
752- :on-unhover #(profile/unhover! (:from m))))
753- ;; Only the hovered face carries one: a card is painted when its
754- ;; node has children, and the pointer is on one face at a time.
755- (when (and (platform/desktop?)
756- (= (:from m) (:nick @profile/hovering)))
757- [hover-card (:from m) (:actor m)])]))
758- ;; The name carries the row, so it is set at body size in the plain
759- ;; text colour: dimmed caption made the one thing you scan a column
760- ;; for the faintest thing on it.
761- [:label {:label (:from m)}]
762- (when-let [at (:at m)]
763- [:dim-label {:label (clock/clock-time at)}])
764- ;; Beside the clock, because it is the same kind of fact about the
765- ;; line: what is on screen is not what was first said.
766- (when (:edited? m)
767- [:dim-label {:label "(edited)"}])
768- ;; And the two things you can do to the message, at the far end of
769- ;; its heading: a nested row laid out from the right takes what is
770- ;; left of the width and puts the chips against the edge of it, so
771- ;; the words below keep the whole column.
772- ;;
773- ;; A key of its own for each branch, rather than one key whose node
774- ;; changes tag under it: a line this client sent has no msgid until
775- ;; the server echoes it back, and swapping a `:spacer` for a chip in
776- ;; place is what left the row rebuilt in the wrong order.
777- (if (:id m)
778- [action-chips {:key :actions} @s/current m]
779- [:spacer {:key :actions-gap :size 0}])])]
780- ;; A little air under the sender's row: the name and face are a heading
781- ;; for what follows, and a heading that touches its text is not one.
782- ;; The text keeps a column of its own: a wrapping label directly in a
783- ;; row wraps against the row.
784- (indented
785- :text
786- [:vbox {:key :text :spacing 2 :margin-top 4}
787- ;; What this answers, in the column the answer itself is in: the chip
788- ;; is a quote of a line, so it wraps to the width a line has. Outside
789- ;; this column it wrapped to the whole row instead — wider than any
790- ;; message, and out to the window's edge on a narrow screen.
791- [:vbox {:key :reply-chip}
792- (when-let [reply-to (:reply-to m)]
793- [reply-chip @s/current reply-to])]
794- (map-indexed (fn [j run] (run-node j run (:system? m)))
795- (text-runs (:text m)))])
796- ;; And the picker, when this is the message it was opened on: under the
797- ;; line it is about, where the reader is already looking.
798- ;;
799- ;; The id has to exist, not merely match: a line this client sent itself
800- ;; has no msgid, and neither does a closed picker — so `nil = nil` was
801- ;; every one of those messages opening a picker of its own at startup.
802- [:vbox {:key :picker :margin-bottom 4}
803- (when (and (:id m) (= (:id m) (:id @s/reacting)))
804- [emoji-picker])]
805- ;; Pictures under the line that linked them. The link stays: it is what a
806- ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
807- (indented
808- :images
809- [:vbox {:key :images :spacing 4}
810- (when (seq (:images m))
811- ;; Reading the tick is what subscribes this row to a fetch finishing.
812- (let [_ @s/media-tick]
813- (for [url (:images m)]
814- (when-let [path (media/path-when-ready url)]
815- [:image {:key url
816- :src path
817- :max-height (preview-height)
818- :max-width (preview-width)
819- :on-click #(reset! s/lightbox {:path path :url url})}]))))])
820- ;; Reactions go last, under whatever the message turned out to be: a
821- ;; line with a picture on it is the picture, and pills between the words
822- ;; and the image they introduce read as reactions to the words alone.
823- ;; Nothing moves for a message with no picture — the box above it is
824- ;; empty, and the pills still sit under the last line of text.
825- (indented
826- :reactions-row
827- [:vbox {:key :reactions-row :margin-top 6}
828- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
829- [reaction-row @s/current m])])]]))
867+ ;; The face beside the message, where the terminal can draw one: a
868+ ;; portrait is two rows tall and a heading is one, so hanging it off the
869+ ;; sender's line would leave its lower half beside a blank row. Here the
870+ ;; words take that row, and the column the face makes is the indent every
871+ ;; other row of the message is held in by — `indented` steps aside for it.
872+ (if (terminal-face?)
873+ [:hbox {:key :faced :spacing 8}
874+ [:vbox {:key :face :width-request face-size}
875+ (let [_ @s/media-tick]
876+ (when-let [path (avatars/path-when-ready (:actor m))]
877+ [:image {:key :picture
878+ :src path
879+ :max-width face-size
880+ :max-height face-size}]))]
881+ [message-body m highlit?]]
882+ [message-body m highlit?])]))
830883
831884 (defn- day-separator [day-key label]
832885 [:vbox {:key day-key :spacing 4 :margin 0}
@@ -141,6 +141,28 @@
141 ;; text hangs off — the shape a terminal has read messages in for forty years.141 ;; text hangs off — the shape a terminal has read messages in for forty years.
142 (defonce terminal? (atom false))142 (defonce terminal? (atom false))
143 143
144+;; And whether that terminal draws pictures over its cells — Kitty's graphics
145+;; protocol, which kitty, Ghostty and WezTerm answer and an xterm does not.
146+;; `frq.tui` works it out from the environment and sets this beside the flag
147+;; above; a terminal that has not got it is the one described there, a column
148+;; of names with the words hanging under them.
149+(defonce terminal-graphics? (atom false))
150+
151+(defn- terminal-face?
152+ "Whether a message carries a picture of its sender in a terminal.
153+
154+ Not the same question as `terminal?`: the face is drawn where the protocol
155+ for one is, and where it is not an `:image` is `[ picture ]` printed beside
156+ every nick — worse than the nothing that is there now."
157+ []
158+ (and @terminal? @terminal-graphics?))
159+
160+;; How big a face is on a message, in points — the size the window has always
161+;; drawn one at. A terminal's cell is eight points across and sixteen down, so
162+;; the same number is four columns by two rows there: a cached 128-pixel
163+;; portrait at a quarter size, and about the smallest a face is still a face at.
164+(def ^:private face-size 32)
165+
144 ;; ---------------------------------------------------------------- chats166 ;; ---------------------------------------------------------------- chats
145 167
146 (defn- open-button [name]168 (defn- open-button [name]
@@ -226,9 +248,13 @@
226 The spacer asks for its width and no height. A `:size` is a length down the248 The spacer asks for its width and no height. A `:size` is a length down the
227 page, and a spacer two cells tall beside an empty column — a message with no249 page, and a spacer two cells tall beside an empty column — a message with no
228 picture and no reactions — is two blank rows in the middle of the backlog,250 picture and no reactions — is two blank rows in the middle of the backlog,
229- which is a message and a half at this size."251+ which is a message and a half at this size.
252+
253+ Nothing to do where a face is drawn: the column that holds the portrait is
254+ already to the left of the whole message, and a second indent inside it would
255+ hold the words in from a name that is itself held in."
230 [k node]256 [k node]
231- (if @terminal?257+ (if (and @terminal? (not (terminal-face?)))
232 [:hbox {:key k :spacing 0}258 [:hbox {:key k :spacing 0}
233 [:spacer {:key :indent :size 0 :width-request 16}]259 [:spacer {:key :indent :size 0 :width-request 16}]
234 node]260 node]
@@ -676,6 +702,134 @@
676 (let [w @s/window-width]702 (let [w @s/window-width]
677 (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))703 (if (pos? w) (min 900 (max 240 (long (* 0.95 w)))) 320)))
678 704
705+(defn- message-body
706+ "A message without its face: the sender's line, the words, and what hangs
707+ under them.
708+
709+ Its own component because a terminal that draws pictures puts the face
710+ beside all of this rather than on the sender's line — the shape the window
711+ has always had, and the one that spends no row on the half of a portrait
712+ that is taller than a line of text."
713+ [m highlit?]
714+ ;; A key each: the surface a jump leaves behind is a different node from the
715+ ;; plain column, not the same node wearing another tag.
716+ [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)
717+ :spacing 2 :margin 0}
718+ ;; Who, when, and what can be done about it, on one line above the
719+ ;; words: the name and the time say where the message came from, and the
720+ ;; chips at the far end are the two answers to it.
721+ [:vbox {:key :who}
722+ ;; An event has no sender to head it, but it still happened at a time,
723+ ;; and a column of joins and quits with no clock on it says nothing
724+ ;; about how long the room was quiet. The time alone is that heading.
725+ (when (and (:system? m) (:at m))
726+ [:dim-label {:label (clock/clock-time (:at m))}])
727+ (when-not (:system? m)
728+ ;; Everything that is about the person rather than the line: their
729+ ;; picture, their name, and when they started saying this.
730+ [:hbox {:spacing 6}
731+ ;; Always an avatar, picture or not: the initial stands in until the
732+ ;; fetch lands, and for the guests who have no profile at all, which
733+ ;; is what keeps the column of faces straight down the left.
734+ ;; Reading the tick subscribes this row to a fetch finishing.
735+ ;; The face is also the way to the person behind it: Vidya's plain
736+ ;; label does not answer the pointer, so the tap sleek puts on the
737+ ;; nick lives here, on the one thing in the row that does.
738+ ;; And, where there is a pointer to ask with, resting on the face
739+ ;; answers before the click does: the card under it is the profile
740+ ;; screen's first few lines, painted beside the pointer.
741+ ;; And no face on this line in a terminal: the initial that stands in
742+ ;; for a portrait in a window is a letter printed twice in a cell grid,
743+ ;; which only pushed every nick in past the words it heads. Where the
744+ ;; terminal can draw a picture there is a face after all, hung beside
745+ ;; the whole message rather than off its heading — `message-row` has it.
746+ (when-not @terminal?
747+ (let [_ @s/media-tick]
748+ [:avatar (cond-> {:label (:from m)
749+ :src (or (avatars/path-when-ready (:actor m)) "")
750+ :size face-size
751+ :on-click #(profile/open! (:from m) (:actor m))}
752+ (platform/desktop?)
753+ (assoc :on-hover #(profile/hover! (:from m) (:actor m))
754+ :on-unhover #(profile/unhover! (:from m))))
755+ ;; Only the hovered face carries one: a card is painted when its
756+ ;; node has children, and the pointer is on one face at a time.
757+ (when (and (platform/desktop?)
758+ (= (:from m) (:nick @profile/hovering)))
759+ [hover-card (:from m) (:actor m)])]))
760+ ;; The name carries the row, so it is set at body size in the plain
761+ ;; text colour: dimmed caption made the one thing you scan a column
762+ ;; for the faintest thing on it.
763+ [:label {:label (:from m)}]
764+ (when-let [at (:at m)]
765+ [:dim-label {:label (clock/clock-time at)}])
766+ ;; Beside the clock, because it is the same kind of fact about the
767+ ;; line: what is on screen is not what was first said.
768+ (when (:edited? m)
769+ [:dim-label {:label "(edited)"}])
770+ ;; And the two things you can do to the message, at the far end of
771+ ;; its heading: a nested row laid out from the right takes what is
772+ ;; left of the width and puts the chips against the edge of it, so
773+ ;; the words below keep the whole column.
774+ ;;
775+ ;; A key of its own for each branch, rather than one key whose node
776+ ;; changes tag under it: a line this client sent has no msgid until
777+ ;; the server echoes it back, and swapping a `:spacer` for a chip in
778+ ;; place is what left the row rebuilt in the wrong order.
779+ (if (:id m)
780+ [action-chips {:key :actions} @s/current m]
781+ [:spacer {:key :actions-gap :size 0}])])]
782+ ;; A little air under the sender's row: the name and face are a heading
783+ ;; for what follows, and a heading that touches its text is not one.
784+ ;; The text keeps a column of its own: a wrapping label directly in a
785+ ;; row wraps against the row.
786+ (indented
787+ :text
788+ [:vbox {:key :text :spacing 2 :margin-top 4}
789+ ;; What this answers, in the column the answer itself is in: the chip
790+ ;; is a quote of a line, so it wraps to the width a line has. Outside
791+ ;; this column it wrapped to the whole row instead — wider than any
792+ ;; message, and out to the window's edge on a narrow screen.
793+ [:vbox {:key :reply-chip}
794+ (when-let [reply-to (:reply-to m)]
795+ [reply-chip @s/current reply-to])]
796+ (map-indexed (fn [j run] (run-node j run (:system? m)))
797+ (text-runs (:text m)))])
798+ ;; And the picker, when this is the message it was opened on: under the
799+ ;; line it is about, where the reader is already looking.
800+ ;;
801+ ;; The id has to exist, not merely match: a line this client sent itself
802+ ;; has no msgid, and neither does a closed picker — so `nil = nil` was
803+ ;; every one of those messages opening a picker of its own at startup.
804+ [:vbox {:key :picker :margin-bottom 4}
805+ (when (and (:id m) (= (:id m) (:id @s/reacting)))
806+ [emoji-picker])]
807+ ;; Pictures under the line that linked them. The link stays: it is what a
808+ ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
809+ (indented
810+ :images
811+ [:vbox {:key :images :spacing 4}
812+ (when (seq (:images m))
813+ ;; Reading the tick is what subscribes this row to a fetch finishing.
814+ (let [_ @s/media-tick]
815+ (for [url (:images m)]
816+ (when-let [path (media/path-when-ready url)]
817+ [:image {:key url
818+ :src path
819+ :max-height (preview-height)
820+ :max-width (preview-width)
821+ :on-click #(reset! s/lightbox {:path path :url url})}]))))])
822+ ;; Reactions go last, under whatever the message turned out to be: a
823+ ;; line with a picture on it is the picture, and pills between the words
824+ ;; and the image they introduce read as reactions to the words alone.
825+ ;; Nothing moves for a message with no picture — the box above it is
826+ ;; empty, and the pills still sit under the last line of text.
827+ (indented
828+ :reactions-row
829+ [:vbox {:key :reactions-row :margin-top 6}
830+ (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
831+ [reaction-row @s/current m])])])
832+
679 (defn message-row833 (defn message-row
680 "One message: who said it, when, what you can do to it, and the words.834 "One message: who said it, when, what you can do to it, and the words.
681 835
@@ -710,123 +864,22 @@
710 ;; without it every message in the backlog touches the one above it, and a864 ;; without it every message in the backlog touches the one above it, and a
711 ;; conversation reads as one paragraph with names scattered through it.865 ;; conversation reads as one paragraph with names scattered through it.
712 (when @terminal? [:spacer {:key :gap :size 16}])866 (when @terminal? [:spacer {:key :gap :size 16}])
713- ;; Likewise a key each: the surface a jump leaves behind is a different867+ ;; The face beside the message, where the terminal can draw one: a
714- ;; node from the plain column, not the same node wearing another tag.868+ ;; portrait is two rows tall and a heading is one, so hanging it off the
715- [(if highlit? :card :vbox) {:key (if highlit? :body-card :body-plain)869+ ;; sender's line would leave its lower half beside a blank row. Here the
716- :spacing 2 :margin 0}870+ ;; words take that row, and the column the face makes is the indent every
717- ;; Who, when, and what can be done about it, on one line above the871+ ;; other row of the message is held in by — `indented` steps aside for it.
718- ;; words: the name and the time say where the message came from, and the872+ (if (terminal-face?)
719- ;; chips at the far end are the two answers to it.873+ [:hbox {:key :faced :spacing 8}
720- [:vbox {:key :who}874+ [:vbox {:key :face :width-request face-size}
721- ;; An event has no sender to head it, but it still happened at a time,875+ (let [_ @s/media-tick]
722- ;; and a column of joins and quits with no clock on it says nothing876+ (when-let [path (avatars/path-when-ready (:actor m))]
723- ;; about how long the room was quiet. The time alone is that heading.877+ [:image {:key :picture
724- (when (and (:system? m) (:at m))878+ :src path
725- [:dim-label {:label (clock/clock-time (:at m))}])879+ :max-width face-size
726- (when-not (:system? m)880+ :max-height face-size}]))]
727- ;; Everything that is about the person rather than the line: their881+ [message-body m highlit?]]
728- ;; picture, their name, and when they started saying this.882+ [message-body m highlit?])]))
729- [:hbox {:spacing 6}
730- ;; Always an avatar, picture or not: the initial stands in until the
731- ;; fetch lands, and for the guests who have no profile at all, which
732- ;; is what keeps the column of faces straight down the left.
733- ;; Reading the tick subscribes this row to a fetch finishing.
734- ;; The face is also the way to the person behind it: Vidya's plain
735- ;; label does not answer the pointer, so the tap sleek puts on the
736- ;; nick lives here, on the one thing in the row that does.
737- ;; And, where there is a pointer to ask with, resting on the face
738- ;; answers before the click does: the card under it is the profile
739- ;; screen's first few lines, painted beside the pointer.
740- ;; And in a terminal there is no face at all: a cell grid has no
741- ;; picture to put in one, and the initial standing in for it is the
742- ;; first letter of the name printed twice, indenting every nick past
743- ;; the words it heads.
744- (when-not @terminal?
745- (let [_ @s/media-tick]
746- [:avatar (cond-> {:label (:from m)
747- :src (or (avatars/path-when-ready (:actor m)) "")
748- :size 32
749- :on-click #(profile/open! (:from m) (:actor m))}
750- (platform/desktop?)
751- (assoc :on-hover #(profile/hover! (:from m) (:actor m))
752- :on-unhover #(profile/unhover! (:from m))))
753- ;; Only the hovered face carries one: a card is painted when its
754- ;; node has children, and the pointer is on one face at a time.
755- (when (and (platform/desktop?)
756- (= (:from m) (:nick @profile/hovering)))
757- [hover-card (:from m) (:actor m)])]))
758- ;; The name carries the row, so it is set at body size in the plain
759- ;; text colour: dimmed caption made the one thing you scan a column
760- ;; for the faintest thing on it.
761- [:label {:label (:from m)}]
762- (when-let [at (:at m)]
763- [:dim-label {:label (clock/clock-time at)}])
764- ;; Beside the clock, because it is the same kind of fact about the
765- ;; line: what is on screen is not what was first said.
766- (when (:edited? m)
767- [:dim-label {:label "(edited)"}])
768- ;; And the two things you can do to the message, at the far end of
769- ;; its heading: a nested row laid out from the right takes what is
770- ;; left of the width and puts the chips against the edge of it, so
771- ;; the words below keep the whole column.
772- ;;
773- ;; A key of its own for each branch, rather than one key whose node
774- ;; changes tag under it: a line this client sent has no msgid until
775- ;; the server echoes it back, and swapping a `:spacer` for a chip in
776- ;; place is what left the row rebuilt in the wrong order.
777- (if (:id m)
778- [action-chips {:key :actions} @s/current m]
779- [:spacer {:key :actions-gap :size 0}])])]
780- ;; A little air under the sender's row: the name and face are a heading
781- ;; for what follows, and a heading that touches its text is not one.
782- ;; The text keeps a column of its own: a wrapping label directly in a
783- ;; row wraps against the row.
784- (indented
785- :text
786- [:vbox {:key :text :spacing 2 :margin-top 4}
787- ;; What this answers, in the column the answer itself is in: the chip
788- ;; is a quote of a line, so it wraps to the width a line has. Outside
789- ;; this column it wrapped to the whole row instead — wider than any
790- ;; message, and out to the window's edge on a narrow screen.
791- [:vbox {:key :reply-chip}
792- (when-let [reply-to (:reply-to m)]
793- [reply-chip @s/current reply-to])]
794- (map-indexed (fn [j run] (run-node j run (:system? m)))
795- (text-runs (:text m)))])
796- ;; And the picker, when this is the message it was opened on: under the
797- ;; line it is about, where the reader is already looking.
798- ;;
799- ;; The id has to exist, not merely match: a line this client sent itself
800- ;; has no msgid, and neither does a closed picker — so `nil = nil` was
801- ;; every one of those messages opening a picker of its own at startup.
802- [:vbox {:key :picker :margin-bottom 4}
803- (when (and (:id m) (= (:id m) (:id @s/reacting)))
804- [emoji-picker])]
805- ;; Pictures under the line that linked them. The link stays: it is what a
806- ;; failed fetch, an unsupported format, or a phone with no TLS leaves you.
807- (indented
808- :images
809- [:vbox {:key :images :spacing 4}
810- (when (seq (:images m))
811- ;; Reading the tick is what subscribes this row to a fetch finishing.
812- (let [_ @s/media-tick]
813- (for [url (:images m)]
814- (when-let [path (media/path-when-ready url)]
815- [:image {:key url
816- :src path
817- :max-height (preview-height)
818- :max-width (preview-width)
819- :on-click #(reset! s/lightbox {:path path :url url})}]))))])
820- ;; Reactions go last, under whatever the message turned out to be: a
821- ;; line with a picture on it is the picture, and pills between the words
822- ;; and the image they introduce read as reactions to the words alone.
823- ;; Nothing moves for a message with no picture — the box above it is
824- ;; empty, and the pills still sit under the last line of text.
825- (indented
826- :reactions-row
827- [:vbox {:key :reactions-row :margin-top 6}
828- (when (and (:id m) (not (:system? m)) (seq (:reactions m)))
829- [reaction-row @s/current m])])]]))
830 883
831 (defn- day-separator [day-key label]884 (defn- day-separator [day-key label]
832 [:vbox {:key day-key :spacing 4 :margin 0}885 [:vbox {:key day-key :spacing 4 :margin 0}
modified src/frq/tui.jolt +35 -2
@@ -33,6 +33,7 @@
3333 (:require [clojure.string :as str]
3434 [frq.app :as app]
3535 [frq.state :as s]
36+ [jolt.host :as host]
3637 [glimmer.core :as ui]
3738 ;; last, so its install! is the one that stands
3839 [glimmer-tui.core :as tui]))
@@ -79,6 +80,33 @@
7980 (reset! s/screen :chat)
8081 nil)
8182
83+(defn- graphics?
84+ "Whether this terminal draws pictures over its cells.
85+
86+ The same question `jolt-tui` asks itself before it reserves cells for an
87+ `:image`, and asked the same way: the protocol's own query comes back as
88+ input, long after the layout has had to decide how many rows a picture takes,
89+ so a terminal is identified by name here as it is everywhere else — and
90+ `JOLT_TUI_GRAPHICS` is the way in for one neither of us has learned yet.
91+
92+ frq asks it too, rather than reading the answer back out of the library,
93+ because what hangs on it is a tree: a face on a message is an `:image` node
94+ or it is no node at all, and that is decided before the backend sees it."
95+ []
96+ (let [env (fn [k] (or (host/getenv k) ""))
97+ known? (fn [name]
98+ (let [name (str/lower-case name)]
99+ (boolean (some #(str/includes? name %)
100+ ["kitty" "ghostty" "wezterm"]))))]
101+ (case (str/lower-case (env "JOLT_TUI_GRAPHICS"))
102+ ("1" "true") true
103+ ("0" "false") false
104+ (or (seq (env "KITTY_WINDOW_ID"))
105+ (seq (env "GHOSTTY_RESOURCES_DIR"))
106+ (known? (env "TERM"))
107+ (known? (env "TERM_PROGRAM"))
108+ false))))
109+
82110 (defn- narrow!
83111 "Tell the layout how much room it has, in the units it expects.
84112
@@ -108,9 +136,14 @@
108136 ;; nine the window's count works out to.
109137 (reset! app/chrome-row points-per-row)
110138 ;; 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.
139+ ;; a face with them beside it: the initial that stands in for a portrait in a
140+ ;; window is a letter printed twice here, which only pushed every nick in
141+ ;; past its own text.
113142 (reset! app/terminal? true)
143+ ;; The face itself comes back where the terminal can draw one: this client
144+ ;; fetches the picture either way, and Kitty's protocol puts it over the
145+ ;; cells the layout reserved for it.
146+ (reset! app/terminal-graphics? (boolean (graphics?)))
114147 (app/start! {:after! tui/after!
115148 :every! tui/every!
116149 :title! nil
@@ -33,6 +33,7 @@
33 (:require [clojure.string :as str]33 (:require [clojure.string :as str]
34 [frq.app :as app]34 [frq.app :as app]
35 [frq.state :as s]35 [frq.state :as s]
36+ [jolt.host :as host]
36 [glimmer.core :as ui]37 [glimmer.core :as ui]
37 ;; last, so its install! is the one that stands38 ;; last, so its install! is the one that stands
38 [glimmer-tui.core :as tui]))39 [glimmer-tui.core :as tui]))
@@ -79,6 +80,33 @@
79 (reset! s/screen :chat)80 (reset! s/screen :chat)
80 nil)81 nil)
81 82
83+(defn- graphics?
84+ "Whether this terminal draws pictures over its cells.
85+
86+ The same question `jolt-tui` asks itself before it reserves cells for an
87+ `:image`, and asked the same way: the protocol's own query comes back as
88+ input, long after the layout has had to decide how many rows a picture takes,
89+ so a terminal is identified by name here as it is everywhere else — and
90+ `JOLT_TUI_GRAPHICS` is the way in for one neither of us has learned yet.
91+
92+ frq asks it too, rather than reading the answer back out of the library,
93+ because what hangs on it is a tree: a face on a message is an `:image` node
94+ or it is no node at all, and that is decided before the backend sees it."
95+ []
96+ (let [env (fn [k] (or (host/getenv k) ""))
97+ known? (fn [name]
98+ (let [name (str/lower-case name)]
99+ (boolean (some #(str/includes? name %)
100+ ["kitty" "ghostty" "wezterm"]))))]
101+ (case (str/lower-case (env "JOLT_TUI_GRAPHICS"))
102+ ("1" "true") true
103+ ("0" "false") false
104+ (or (seq (env "KITTY_WINDOW_ID"))
105+ (seq (env "GHOSTTY_RESOURCES_DIR"))
106+ (known? (env "TERM"))
107+ (known? (env "TERM_PROGRAM"))
108+ false))))
109+
82 (defn- narrow!110 (defn- narrow!
83 "Tell the layout how much room it has, in the units it expects.111 "Tell the layout how much room it has, in the units it expects.
84 112
@@ -108,9 +136,14 @@
108 ;; nine the window's count works out to.136 ;; nine the window's count works out to.
109 (reset! app/chrome-row points-per-row)137 (reset! app/chrome-row points-per-row)
110 ;; And a message here is a name with its words hanging under it, rather than138 ;; 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 initial139+ ;; a face with them beside it: the initial that stands in for a portrait in a
112- ;; that stood in for one only pushed every nick in past its own text.140+ ;; window is a letter printed twice here, which only pushed every nick in
141+ ;; past its own text.
113 (reset! app/terminal? true)142 (reset! app/terminal? true)
143+ ;; The face itself comes back where the terminal can draw one: this client
144+ ;; fetches the picture either way, and Kitty's protocol puts it over the
145+ ;; cells the layout reserved for it.
146+ (reset! app/terminal-graphics? (boolean (graphics?)))
114 (app/start! {:after! tui/after!147 (app/start! {:after! tui/after!
115 :every! tui/every!148 :every! tui/every!
116 :title! nil149 :title! nil