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

Lay a message out as one paragraph, so a link stops taking a line

The runs a message is split into — text, link, text, link — were stacked in
a column, one run per row, which gave every URL a line of its own and left
the words either side of it stranded on lines of their own too. A message
with two links in it came out as four.

The column was protecting something real: a Wrap hands each child unbounded
width and then places the finished box, so a long URL in a wrapping row is
an atom that either fits or starts a new line, and a plain Row is worse
still — a label there measures against the row rather than the column, which
drags the URL off the left edge.

So the runs go into a row marked `:inline`, and the Flutter renderer lays
such a row out as a single Text.rich: one paragraph, whose line breaker sees
the whole sentence and may break inside a run as well as between two. A
link's tap becomes a TapGestureRecognizer rather than an InkWell, because an
InkWell is a widget and a widget is the box that broke the line in the first
place. The prop is only a hint — a row holding anything with a shape of its
own still gets the Wrap it got before.

One consequence: runs now keep the spaces they were typed with. Trimming
each one cost nothing while every run was a line, and costs the space before
a link now that they share one. `text-runs` trims the message's two ends
instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-12T10:14:49-07:00 Browse files
54b668a parent: 5b62aea
modified common/frq/screens/chat.cljc +45 -11
@@ -623,11 +623,17 @@
623623 are stacked in: a row is what puts an emoji *in* a sentence instead of
624624 breaking the sentence around it, and `:hbox` wraps its children by default,
625625 so a long line still folds at the column's edge. A run with no emoji in it
626- is still one plain label — the row is only paid for where it is needed."
626+ is still one plain label — the row is only paid for where it is needed.
627+
628+ A run is drawn with the spaces it was typed with. Each used to be trimmed,
629+ which cost nothing while every run was a line of its own and costs the space
630+ before a link now that they share one: `i should write a` and the URL after
631+ it ran together into one word. `text-runs` trims the message's own two ends
632+ instead."
627633 [j [kind value] system?]
628634 (if (= :link kind)
629635 [:link {:key j :label value :on-click #(actions/open-url! value)}]
630- (let [pieces (glyphs/runs (str/trim value))]
636+ (let [pieces (glyphs/runs value)]
631637 (if (glyphs/emoji? pieces)
632638 ;; Runs alternate text and picture, so this gap only ever falls either
633639 ;; side of an emoji — never between two words, whose spacing is the
@@ -640,7 +646,7 @@
640646 [:emoji {:key k :emoji pvalue :size text-emoji-size}]
641647 (word-node k pvalue system?)))
642648 pieces)]
643- (word-node j (str/trim value) system?)))))
649+ (word-node j value system?)))))
644650
645651 (defn- trim-trailing-punctuation
646652 "A URL at the end of a sentence would otherwise keep the sentence's
@@ -657,13 +663,32 @@
657663
658664 (def ^:private url-pattern #"https?://[^\s<>\"]+")
659665
666+(defn- trim-ends
667+ "The message's leading and trailing whitespace, off the runs that carry it.
668+
669+ Only the two ends: every space between the runs is a space somebody typed
670+ between two words, and the paragraph they now share is where it shows."
671+ [runs]
672+ (let [edge (fn [rs i f]
673+ (let [[kind value] (nth rs i nil)]
674+ (if (= :text kind)
675+ (let [v (f value)]
676+ (if (seq v)
677+ (assoc (vec rs) i [:text v])
678+ (vec (concat (take i rs) (drop (inc i) rs)))))
679+ (vec rs))))
680+ runs (vec runs)
681+ runs (if (seq runs) (edge runs 0 #(str/triml %)) runs)]
682+ (if (seq runs) (edge runs (dec (count runs)) #(str/trimr %)) runs)))
683+
660684 (defn text-runs
661685 "Message text as alternating [:text s] and [:link url] runs.
662686
663- Runs because a link has to be its own widget to be clickable, and stacked
664- rather than laid out in a row because a wrapping label inside a horizontal
665- row lays out against the row's width, not the column's — which is what drags
666- long URLs off the left edge."
687+ Runs because a link has to be styled and clickable on its own. They are laid
688+ out by `message-body` as one `:inline` row — a paragraph — rather than
689+ stacked: stacking gave every link a line of its own, and a plain wrapping row
690+ measures each label against the row's width rather than the column's, which
691+ is what drags long URLs off the left edge."
667692 [text]
668693 (let [text (or text "")]
669694 (loop [pos 0 acc []]
@@ -673,8 +698,9 @@
673698 before (subs text pos at)
674699 acc (cond-> acc (seq before) (conj [:text before]))]
675700 (recur (+ at (count url)) (conj acc [:link url])))
676- (let [tail (subs text pos)]
677- (cond-> acc (seq tail) (conj [:text tail])))))))
701+ (let [tail (subs text pos)
702+ acc (cond-> acc (seq tail) (conj [:text tail]))]
703+ (trim-ends acc))))))
678704
679705 (defn- message-body
680706 "A message without its face: the sender's line, the words, and what hangs
@@ -766,8 +792,16 @@
766792 [:vbox {:key :reply-chip}
767793 (when-let [reply-to (:reply-to m)]
768794 [reply-chip @cells/current reply-to])]
769- (map-indexed (fn [j run] (run-node j run (:system? m)))
770- (text-runs (:text m)))])
795+ ;; The words themselves, as one sentence rather than a stack of runs.
796+ ;; `:inline` asks the renderer to lay the runs out in a single paragraph,
797+ ;; so the text either side of a link stays on the link's line and the
798+ ;; break falls where the width runs out instead of at every URL. The
799+ ;; column here is still what the paragraph wraps against, which is the
800+ ;; thing the stack was protecting: a row measured against a row is what
801+ ;; drags a long URL off the left edge.
802+ [:hbox {:key :runs :wrap true :inline true}
803+ (map-indexed (fn [j run] (run-node j run (:system? m)))
804+ (text-runs (:text m)))]])
771805 ;; And the picker, when this is the message it was opened on: under the
772806 ;; line it is about, where the reader is already looking.
773807 ;;
@@ -623,11 +623,17 @@
623 are stacked in: a row is what puts an emoji *in* a sentence instead of623 are stacked in: a row is what puts an emoji *in* a sentence instead of
624 breaking the sentence around it, and `:hbox` wraps its children by default,624 breaking the sentence around it, and `:hbox` wraps its children by default,
625 so a long line still folds at the column's edge. A run with no emoji in it625 so a long line still folds at the column's edge. A run with no emoji in it
626- is still one plain label — the row is only paid for where it is needed."626+ is still one plain label — the row is only paid for where it is needed.
627+
628+ A run is drawn with the spaces it was typed with. Each used to be trimmed,
629+ which cost nothing while every run was a line of its own and costs the space
630+ before a link now that they share one: `i should write a` and the URL after
631+ it ran together into one word. `text-runs` trims the message's own two ends
632+ instead."
627 [j [kind value] system?]633 [j [kind value] system?]
628 (if (= :link kind)634 (if (= :link kind)
629 [:link {:key j :label value :on-click #(actions/open-url! value)}]635 [:link {:key j :label value :on-click #(actions/open-url! value)}]
630- (let [pieces (glyphs/runs (str/trim value))]636+ (let [pieces (glyphs/runs value)]
631 (if (glyphs/emoji? pieces)637 (if (glyphs/emoji? pieces)
632 ;; Runs alternate text and picture, so this gap only ever falls either638 ;; Runs alternate text and picture, so this gap only ever falls either
633 ;; side of an emoji — never between two words, whose spacing is the639 ;; side of an emoji — never between two words, whose spacing is the
@@ -640,7 +646,7 @@
640 [:emoji {:key k :emoji pvalue :size text-emoji-size}]646 [:emoji {:key k :emoji pvalue :size text-emoji-size}]
641 (word-node k pvalue system?)))647 (word-node k pvalue system?)))
642 pieces)]648 pieces)]
643- (word-node j (str/trim value) system?)))))649+ (word-node j value system?)))))
644 650
645 (defn- trim-trailing-punctuation651 (defn- trim-trailing-punctuation
646 "A URL at the end of a sentence would otherwise keep the sentence's652 "A URL at the end of a sentence would otherwise keep the sentence's
@@ -657,13 +663,32 @@
657 663
658 (def ^:private url-pattern #"https?://[^\s<>\"]+")664 (def ^:private url-pattern #"https?://[^\s<>\"]+")
659 665
666+(defn- trim-ends
667+ "The message's leading and trailing whitespace, off the runs that carry it.
668+
669+ Only the two ends: every space between the runs is a space somebody typed
670+ between two words, and the paragraph they now share is where it shows."
671+ [runs]
672+ (let [edge (fn [rs i f]
673+ (let [[kind value] (nth rs i nil)]
674+ (if (= :text kind)
675+ (let [v (f value)]
676+ (if (seq v)
677+ (assoc (vec rs) i [:text v])
678+ (vec (concat (take i rs) (drop (inc i) rs)))))
679+ (vec rs))))
680+ runs (vec runs)
681+ runs (if (seq runs) (edge runs 0 #(str/triml %)) runs)]
682+ (if (seq runs) (edge runs (dec (count runs)) #(str/trimr %)) runs)))
683+
660 (defn text-runs684 (defn text-runs
661 "Message text as alternating [:text s] and [:link url] runs.685 "Message text as alternating [:text s] and [:link url] runs.
662 686
663- Runs because a link has to be its own widget to be clickable, and stacked687+ Runs because a link has to be styled and clickable on its own. They are laid
664- rather than laid out in a row because a wrapping label inside a horizontal688+ out by `message-body` as one `:inline` row — a paragraph — rather than
665- row lays out against the row's width, not the column's — which is what drags689+ stacked: stacking gave every link a line of its own, and a plain wrapping row
666- long URLs off the left edge."690+ measures each label against the row's width rather than the column's, which
691+ is what drags long URLs off the left edge."
667 [text]692 [text]
668 (let [text (or text "")]693 (let [text (or text "")]
669 (loop [pos 0 acc []]694 (loop [pos 0 acc []]
@@ -673,8 +698,9 @@
673 before (subs text pos at)698 before (subs text pos at)
674 acc (cond-> acc (seq before) (conj [:text before]))]699 acc (cond-> acc (seq before) (conj [:text before]))]
675 (recur (+ at (count url)) (conj acc [:link url])))700 (recur (+ at (count url)) (conj acc [:link url])))
676- (let [tail (subs text pos)]701+ (let [tail (subs text pos)
677- (cond-> acc (seq tail) (conj [:text tail])))))))702+ acc (cond-> acc (seq tail) (conj [:text tail]))]
703+ (trim-ends acc))))))
678 704
679 (defn- message-body705 (defn- message-body
680 "A message without its face: the sender's line, the words, and what hangs706 "A message without its face: the sender's line, the words, and what hangs
@@ -766,8 +792,16 @@
766 [:vbox {:key :reply-chip}792 [:vbox {:key :reply-chip}
767 (when-let [reply-to (:reply-to m)]793 (when-let [reply-to (:reply-to m)]
768 [reply-chip @cells/current reply-to])]794 [reply-chip @cells/current reply-to])]
769- (map-indexed (fn [j run] (run-node j run (:system? m)))795+ ;; The words themselves, as one sentence rather than a stack of runs.
770- (text-runs (:text m)))])796+ ;; `:inline` asks the renderer to lay the runs out in a single paragraph,
797+ ;; so the text either side of a link stays on the link's line and the
798+ ;; break falls where the width runs out instead of at every URL. The
799+ ;; column here is still what the paragraph wraps against, which is the
800+ ;; thing the stack was protecting: a row measured against a row is what
801+ ;; drags a long URL off the left edge.
802+ [:hbox {:key :runs :wrap true :inline true}
803+ (map-indexed (fn [j run] (run-node j run (:system? m)))
804+ (text-runs (:text m)))]])
771 ;; And the picker, when this is the message it was opened on: under the805 ;; And the picker, when this is the message it was opened on: under the
772 ;; line it is about, where the reader is already looking.806 ;; line it is about, where the reader is already looking.
773 ;;807 ;;
modified flutter/src/frq/hiccup.cljd +96 -1
@@ -26,6 +26,7 @@
2626 read it — fine at this size, and the thing to revisit if a message list ever
2727 feels it."
2828 (:require ["dart:io" :as io]
29+ ["package:flutter/gestures.dart" :as g]
2930 ["package:flutter/material.dart" :as m]
3031 [cljd.flutter :as f]
3132 [frq.theme :as t]))
@@ -517,6 +518,81 @@
517518 .height 1.35
518519 .fontWeight (or weight m/FontWeight.w400))))
519520
521+;; ------------------------------------------------------------ inline text
522+
523+(defn- inline-style [size color]
524+ (m/TextStyle .fontSize size .color color .height 1.35))
525+
526+(defn- inline?
527+ "Whether a node can be a run *inside* a paragraph rather than a box beside
528+ one.
529+
530+ Only the tags a sentence is made of: words, a link, an emoji, and a row
531+ holding nothing but those. Anything with a shape of its own — a button, a
532+ card, a picture — is a box, and a box in a Wrap is what a Wrap is for."
533+ [node]
534+ (let [n (expand node)]
535+ (cond
536+ (nil? n) true
537+ (seq? n) (every? inline? n)
538+ (not (vector? n)) false
539+ :else (let [tag (first n)]
540+ (cond
541+ (contains? #{:label :dim-label :link :emoji} tag) true
542+ (= :hbox tag) (every? inline? (body n))
543+ :else false)))))
544+
545+(defn- inline-spans
546+ "The runs of a paragraph as Flutter InlineSpans.
547+
548+ Nested rows are flattened rather than nested, and their `:spacing` goes with
549+ them: spacing is a gap between boxes, and there are no boxes left here. What
550+ an emoji needs instead is the air `run-node` already gives it, which is the
551+ WidgetSpan's own — a picture set in a line of text sits on the baseline like
552+ a letter does.
553+
554+ A link's tap is a TapGestureRecognizer rather than an InkWell, because an
555+ InkWell is a widget and a widget is a box: it is precisely the box that
556+ stops the line breaker seeing the URL as part of the sentence. The
557+ recognizers are never disposed — Flutter asks that they be, and the honest
558+ note is that these live as long as the message list does and a conversation
559+ is at most a screen of them."
560+ [ctx nodes]
561+ (persistent!
562+ (reduce
563+ (fn [acc n0]
564+ (let [n (expand n0)]
565+ (cond
566+ (nil? n) acc
567+ (seq? n) (reduce conj! acc (inline-spans ctx n))
568+ (not (vector? n)) acc
569+ :else
570+ (let [p (props n)]
571+ (case (first n)
572+ :label
573+ (conj! acc (m/TextSpan .text (str (:label p ""))
574+ .style (inline-style t/text-body t/on-bg)))
575+ :dim-label
576+ (conj! acc (m/TextSpan .text (str (:label p ""))
577+ .style (inline-style t/text-caption t/dim)))
578+ :link
579+ (conj! acc (m/TextSpan
580+ .text (str (:label p ""))
581+ .style (inline-style t/text-body t/accent)
582+ .recognizer (when-let [on (:on-click p)]
583+ (let [r (g/TapGestureRecognizer)]
584+ (set! (.-onTap r) #(on))
585+ r))))
586+ :emoji
587+ (conj! acc (m/WidgetSpan
588+ .alignment m/PlaceholderAlignment.middle
589+ .child (m/Text (str (:emoji p ""))
590+ .style (t/emoji-style (dbl (:size p) 16.0)))))
591+ :hbox
592+ (reduce conj! acc (inline-spans ctx (body n)))
593+ acc)))))
594+ (transient []) nodes)))
595+
520596 ;; ---------------------------------------------------------------- buttons
521597
522598 (defn- cosmic-button
@@ -657,12 +733,30 @@
657733 ;; Wrap. Without it the longest message decided the width of the
658734 ;; conversation and the rest overflowed off the right.
659735 :hbox
660- (let [row (if (true? (:wrap p))
736+ (let [row (cond
737+ ;; `:inline true` is a row that is a *sentence*: its runs
738+ ;; go into one paragraph, so Flutter's line breaker sees
739+ ;; the whole line at once and may break inside a run as
740+ ;; well as between two. A Wrap cannot — it hands each child
741+ ;; unbounded width and then places the finished box, so a
742+ ;; URL is an atom that either fits on the line or starts a
743+ ;; new one, and a message with two links in it came out as
744+ ;; five lines with a link alone on two of them.
745+ ;;
746+ ;; The check is not just the prop: a sentence with a button
747+ ;; in it is not a sentence, and a caller that asks for one
748+ ;; anyway gets the Wrap it would have got before.
749+ (and (true? (:inline p)) (inline? (body node)))
750+ (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node)))
751+ .softWrap true)
752+
753+ (true? (:wrap p))
661754 (m/Wrap
662755 .spacing (dbl (:spacing p) 0.0)
663756 .runSpacing (dbl (:spacing p) 0.0)
664757 .crossAxisAlignment m/WrapCrossAlignment.center
665758 .children kids)
759+
666760 ;; `:align` is where in the row its children sit, and it is
667761 ;; glimmer's meaning that the shared screens are written
668762 ;; against: `:end` lays them out *from* the right, so the
@@ -672,6 +766,7 @@
672766 ;; first so it would land beside the words — hanging off the
673767 ;; end of a phone's width, which is why a line you wrote had
674768 ;; no way to rewrite it.
769+ :else
675770 (let [end? (= :end (:align p))
676771 kids (flexed fills-row? (body node))]
677772 (m/Row
@@ -26,6 +26,7 @@
26 read it — fine at this size, and the thing to revisit if a message list ever26 read it — fine at this size, and the thing to revisit if a message list ever
27 feels it."27 feels it."
28 (:require ["dart:io" :as io]28 (:require ["dart:io" :as io]
29+ ["package:flutter/gestures.dart" :as g]
29 ["package:flutter/material.dart" :as m]30 ["package:flutter/material.dart" :as m]
30 [cljd.flutter :as f]31 [cljd.flutter :as f]
31 [frq.theme :as t]))32 [frq.theme :as t]))
@@ -517,6 +518,81 @@
517 .height 1.35518 .height 1.35
518 .fontWeight (or weight m/FontWeight.w400))))519 .fontWeight (or weight m/FontWeight.w400))))
519 520
521+;; ------------------------------------------------------------ inline text
522+
523+(defn- inline-style [size color]
524+ (m/TextStyle .fontSize size .color color .height 1.35))
525+
526+(defn- inline?
527+ "Whether a node can be a run *inside* a paragraph rather than a box beside
528+ one.
529+
530+ Only the tags a sentence is made of: words, a link, an emoji, and a row
531+ holding nothing but those. Anything with a shape of its own — a button, a
532+ card, a picture — is a box, and a box in a Wrap is what a Wrap is for."
533+ [node]
534+ (let [n (expand node)]
535+ (cond
536+ (nil? n) true
537+ (seq? n) (every? inline? n)
538+ (not (vector? n)) false
539+ :else (let [tag (first n)]
540+ (cond
541+ (contains? #{:label :dim-label :link :emoji} tag) true
542+ (= :hbox tag) (every? inline? (body n))
543+ :else false)))))
544+
545+(defn- inline-spans
546+ "The runs of a paragraph as Flutter InlineSpans.
547+
548+ Nested rows are flattened rather than nested, and their `:spacing` goes with
549+ them: spacing is a gap between boxes, and there are no boxes left here. What
550+ an emoji needs instead is the air `run-node` already gives it, which is the
551+ WidgetSpan's own — a picture set in a line of text sits on the baseline like
552+ a letter does.
553+
554+ A link's tap is a TapGestureRecognizer rather than an InkWell, because an
555+ InkWell is a widget and a widget is a box: it is precisely the box that
556+ stops the line breaker seeing the URL as part of the sentence. The
557+ recognizers are never disposed — Flutter asks that they be, and the honest
558+ note is that these live as long as the message list does and a conversation
559+ is at most a screen of them."
560+ [ctx nodes]
561+ (persistent!
562+ (reduce
563+ (fn [acc n0]
564+ (let [n (expand n0)]
565+ (cond
566+ (nil? n) acc
567+ (seq? n) (reduce conj! acc (inline-spans ctx n))
568+ (not (vector? n)) acc
569+ :else
570+ (let [p (props n)]
571+ (case (first n)
572+ :label
573+ (conj! acc (m/TextSpan .text (str (:label p ""))
574+ .style (inline-style t/text-body t/on-bg)))
575+ :dim-label
576+ (conj! acc (m/TextSpan .text (str (:label p ""))
577+ .style (inline-style t/text-caption t/dim)))
578+ :link
579+ (conj! acc (m/TextSpan
580+ .text (str (:label p ""))
581+ .style (inline-style t/text-body t/accent)
582+ .recognizer (when-let [on (:on-click p)]
583+ (let [r (g/TapGestureRecognizer)]
584+ (set! (.-onTap r) #(on))
585+ r))))
586+ :emoji
587+ (conj! acc (m/WidgetSpan
588+ .alignment m/PlaceholderAlignment.middle
589+ .child (m/Text (str (:emoji p ""))
590+ .style (t/emoji-style (dbl (:size p) 16.0)))))
591+ :hbox
592+ (reduce conj! acc (inline-spans ctx (body n)))
593+ acc)))))
594+ (transient []) nodes)))
595+
520 ;; ---------------------------------------------------------------- buttons596 ;; ---------------------------------------------------------------- buttons
521 597
522 (defn- cosmic-button598 (defn- cosmic-button
@@ -657,12 +733,30 @@
657 ;; Wrap. Without it the longest message decided the width of the733 ;; Wrap. Without it the longest message decided the width of the
658 ;; conversation and the rest overflowed off the right.734 ;; conversation and the rest overflowed off the right.
659 :hbox735 :hbox
660- (let [row (if (true? (:wrap p))736+ (let [row (cond
737+ ;; `:inline true` is a row that is a *sentence*: its runs
738+ ;; go into one paragraph, so Flutter's line breaker sees
739+ ;; the whole line at once and may break inside a run as
740+ ;; well as between two. A Wrap cannot — it hands each child
741+ ;; unbounded width and then places the finished box, so a
742+ ;; URL is an atom that either fits on the line or starts a
743+ ;; new one, and a message with two links in it came out as
744+ ;; five lines with a link alone on two of them.
745+ ;;
746+ ;; The check is not just the prop: a sentence with a button
747+ ;; in it is not a sentence, and a caller that asks for one
748+ ;; anyway gets the Wrap it would have got before.
749+ (and (true? (:inline p)) (inline? (body node)))
750+ (m/Text.rich (m/TextSpan .children (inline-spans ctx (body node)))
751+ .softWrap true)
752+
753+ (true? (:wrap p))
661 (m/Wrap754 (m/Wrap
662 .spacing (dbl (:spacing p) 0.0)755 .spacing (dbl (:spacing p) 0.0)
663 .runSpacing (dbl (:spacing p) 0.0)756 .runSpacing (dbl (:spacing p) 0.0)
664 .crossAxisAlignment m/WrapCrossAlignment.center757 .crossAxisAlignment m/WrapCrossAlignment.center
665 .children kids)758 .children kids)
759+
666 ;; `:align` is where in the row its children sit, and it is760 ;; `:align` is where in the row its children sit, and it is
667 ;; glimmer's meaning that the shared screens are written761 ;; glimmer's meaning that the shared screens are written
668 ;; against: `:end` lays them out *from* the right, so the762 ;; against: `:end` lays them out *from* the right, so the
@@ -672,6 +766,7 @@
672 ;; first so it would land beside the words — hanging off the766 ;; first so it would land beside the words — hanging off the
673 ;; end of a phone's width, which is why a line you wrote had767 ;; end of a phone's width, which is why a line you wrote had
674 ;; no way to rewrite it.768 ;; no way to rewrite it.
769+ :else
675 (let [end? (= :end (:align p))770 (let [end? (= :end (:align p))
676 kids (flexed fills-row? (body node))]771 kids (flexed fills-row? (body node))]
677 (m/Row772 (m/Row