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>
54b668a parent: 5b62aea modified
common/frq/screens/chat.cljc +45 -11 | @@ -623,11 +623,17 @@ | ||
| 623 | 623 | are stacked in: a row is what puts an emoji *in* a sentence instead of |
| 624 | 624 | breaking the sentence around it, and `:hbox` wraps its children by default, |
| 625 | 625 | 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 | 633 | [j [kind value] system?] |
| 628 | 634 | (if (= :link kind) |
| 629 | 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 | 637 | (if (glyphs/emoji? pieces) |
| 632 | 638 | ;; Runs alternate text and picture, so this gap only ever falls either |
| 633 | 639 | ;; side of an emoji — never between two words, whose spacing is the |
| @@ -640,7 +646,7 @@ | ||
| 640 | 646 | [:emoji {:key k :emoji pvalue :size text-emoji-size}] |
| 641 | 647 | (word-node k pvalue system?))) |
| 642 | 648 | pieces)] |
| 643 | - (word-node j (str/trim value) system?))))) | |
| 649 | + (word-node j value system?))))) | |
| 644 | 650 | |
| 645 | 651 | (defn- trim-trailing-punctuation |
| 646 | 652 | "A URL at the end of a sentence would otherwise keep the sentence's |
| @@ -657,13 +663,32 @@ | ||
| 657 | 663 | |
| 658 | 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 | 684 | (defn text-runs |
| 661 | 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 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." | |
| 667 | 692 | [text] |
| 668 | 693 | (let [text (or text "")] |
| 669 | 694 | (loop [pos 0 acc []] |
| @@ -673,8 +698,9 @@ | ||
| 673 | 698 | before (subs text pos at) |
| 674 | 699 | acc (cond-> acc (seq before) (conj [:text before]))] |
| 675 | 700 | (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)))))) | |
| 678 | 704 | |
| 679 | 705 | (defn- message-body |
| 680 | 706 | "A message without its face: the sender's line, the words, and what hangs |
| @@ -766,8 +792,16 @@ | ||
| 766 | 792 | [:vbox {:key :reply-chip} |
| 767 | 793 | (when-let [reply-to (:reply-to m)] |
| 768 | 794 | [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)))]]) | |
| 771 | 805 | ;; And the picker, when this is the message it was opened on: under the |
| 772 | 806 | ;; line it is about, where the reader is already looking. |
| 773 | 807 | ;; |
| @@ -623,11 +623,17 @@ | |||
| 623 | are stacked in: a row is what puts an emoji *in* a sentence instead of | 623 | 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 it | 625 | 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 either | 638 | ;; Runs alternate text and picture, so this gap only ever falls either |
| 633 | ;; side of an emoji — never between two words, whose spacing is the | 639 | ;; 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-punctuation | 651 | (defn- trim-trailing-punctuation |
| 646 | "A URL at the end of a sentence would otherwise keep the sentence's | 652 | "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-runs | 684 | (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 stacked | 687 | + 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 horizontal | 688 | + 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 drags | 689 | + 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-body | 705 | (defn- message-body |
| 680 | "A message without its face: the sender's line, the words, and what hangs | 706 | "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 the | 805 | ;; 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 @@ | ||
| 26 | 26 | read it — fine at this size, and the thing to revisit if a message list ever |
| 27 | 27 | feels it." |
| 28 | 28 | (:require ["dart:io" :as io] |
| 29 | + ["package:flutter/gestures.dart" :as g] | |
| 29 | 30 | ["package:flutter/material.dart" :as m] |
| 30 | 31 | [cljd.flutter :as f] |
| 31 | 32 | [frq.theme :as t])) |
| @@ -517,6 +518,81 @@ | ||
| 517 | 518 | .height 1.35 |
| 518 | 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 | 596 | ;; ---------------------------------------------------------------- buttons |
| 521 | 597 | |
| 522 | 598 | (defn- cosmic-button |
| @@ -657,12 +733,30 @@ | ||
| 657 | 733 | ;; Wrap. Without it the longest message decided the width of the |
| 658 | 734 | ;; conversation and the rest overflowed off the right. |
| 659 | 735 | :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 | 754 | (m/Wrap |
| 662 | 755 | .spacing (dbl (:spacing p) 0.0) |
| 663 | 756 | .runSpacing (dbl (:spacing p) 0.0) |
| 664 | 757 | .crossAxisAlignment m/WrapCrossAlignment.center |
| 665 | 758 | .children kids) |
| 759 | + | |
| 666 | 760 | ;; `:align` is where in the row its children sit, and it is |
| 667 | 761 | ;; glimmer's meaning that the shared screens are written |
| 668 | 762 | ;; against: `:end` lays them out *from* the right, so the |
| @@ -672,6 +766,7 @@ | ||
| 672 | 766 | ;; first so it would land beside the words — hanging off the |
| 673 | 767 | ;; end of a phone's width, which is why a line you wrote had |
| 674 | 768 | ;; no way to rewrite it. |
| 769 | + :else | |
| 675 | 770 | (let [end? (= :end (:align p)) |
| 676 | 771 | kids (flexed fills-row? (body node))] |
| 677 | 772 | (m/Row |
| @@ -26,6 +26,7 @@ | |||
| 26 | read it — fine at this size, and the thing to revisit if a message list ever | 26 | 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.35 | 518 | .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 | ;; ---------------------------------------------------------------- buttons | 596 | ;; ---------------------------------------------------------------- buttons |
| 521 | 597 | ||
| 522 | (defn- cosmic-button | 598 | (defn- cosmic-button |
| @@ -657,12 +733,30 @@ | |||
| 657 | ;; Wrap. Without it the longest message decided the width of the | 733 | ;; 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 | :hbox | 735 | :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/Wrap | 754 | (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.center | 757 | .crossAxisAlignment m/WrapCrossAlignment.center |
| 665 | .children kids) | 758 | .children kids) |
| 759 | + | ||
| 666 | ;; `:align` is where in the row its children sit, and it is | 760 | ;; `:align` is where in the row its children sit, and it is |
| 667 | ;; glimmer's meaning that the shared screens are written | 761 | ;; glimmer's meaning that the shared screens are written |
| 668 | ;; against: `:end` lays them out *from* the right, so the | 762 | ;; 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 the | 766 | ;; 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 had | 767 | ;; 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/Row | 772 | (m/Row |