nandi/frqpublic Fork 0
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.

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