nandi/jolt-nativepublic Fork 0
dce285f
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

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

Paint a reaction, and give an emoji the two columns it takes

`:reaction` was a tag this backend had never heard of, and an unknown
leaf paints its label — which a reaction has not got. Its glyph is in
`:emoji`, so every chip and every pill was nought columns wide, painted
nothing, and took no clicks: in frq's terminal there was no way to react
to a line, to answer one, or to edit one, and no way to see that anybody
else had. The picker opened onto an empty box.

So the two glyph nodes are tags now. A `:reaction` is the glyph and its
tally, bold when it is yours, and it is focusable — a click or a Return
on it is the same `click` a button raises, which is what puts a reaction
on or takes it off again. An `:emoji` is the same glyph with none of
that: a character in a sentence.

No lozenge around either. A window draws one because it has half-cells to
draw it in; brackets here would cost two columns of a row that already
carries three chips, and would say "button" about a thing whose whole
picture is the glyph.

And the grid learned that an emoji is two columns. It is drawn across two
cells by every terminal, and this counted characters — so a row with one
in it was a column out from there to the right edge, which is what a
mouse click landed on. A cell now knows whether it is the right half of
something, `line` and the flush skip those halves, and measuring and
wrapping ask the same question painting does. That is a chip you can hit,
and it is also `[ 🖼 ]` and every ✏️ and ↩️ in a message sitting where
they look like they sit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-03T00:04:15-07:00 Browse files
dce285f parent: a785201
modified crates/jolt-tui/src/layout.rs +30 -15
@@ -10,7 +10,7 @@
1010 //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on
1111 //! the tree, which is why the layout tests below need no TTY.
1212
13-use crate::screen::Rect;
13+use crate::screen::{char_cols, text_cols, Rect};
1414 use crate::tree::{Props, Tag, Tree};
1515
1616 /// How a child that is not filling its cross axis sits in the space it was
@@ -63,6 +63,17 @@ pub fn inset(tag: &Tag, props: &Props) -> u16 {
6363 }
6464 }
6565
66+/// What a `:reaction` reads as: the glyph, and the tally when there is one.
67+/// A pill with no count is the chip you press to put one there — the same
68+/// picture the picker offers, which is the point of it being the same node.
69+pub fn pill_text(props: &Props) -> String {
70+ let glyph = props.str("emoji");
71+ match props.cells("count", 0) {
72+ 0 => glyph.to_owned(),
73+ n => format!("{glyph} {n}"),
74+ }
75+}
76+
6677 /// Break `text` to `width` columns, on spaces where it can and mid-word where
6778 /// it must. Explicit newlines are always breaks.
6879 pub fn wrap(text: &str, width: u16) -> Vec<String> {
@@ -75,7 +86,10 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
7586 let mut line = String::new();
7687 let mut len = 0usize;
7788 for word in paragraph.split(' ') {
78- let word_len = word.chars().count();
89+ // In columns, not characters: an emoji is drawn two cells wide, so
90+ // a line of them measured by character is twice the width it was
91+ // wrapped to and runs off the edge.
92+ let word_len = text_cols(word) as usize;
7993 if len > 0 && len + 1 + word_len > width {
8094 lines.push(std::mem::take(&mut line));
8195 len = 0;
@@ -84,12 +98,13 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
8498 // Longer than the whole line: break it where the line ends
8599 // rather than let it run off the edge.
86100 for ch in word.chars() {
87- if len == width {
101+ let cols = char_cols(ch) as usize;
102+ if len + cols > width && len > 0 {
88103 lines.push(std::mem::take(&mut line));
89104 len = 0;
90105 }
91106 line.push(ch);
92- len += 1;
107+ len += cols;
93108 }
94109 continue;
95110 }
@@ -106,20 +121,12 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
106121 }
107122
108123 fn columns(text: &str) -> u16 {
109- text.split('\n')
110- .map(|line| line.chars().count())
111- .max()
112- .unwrap_or(0)
113- .min(u16::MAX as usize) as u16
124+ text.split('\n').map(text_cols).max().unwrap_or(0)
114125 }
115126
116127 /// The longest single word — a label cannot usefully be narrower than this.
117128 fn longest_word(text: &str) -> u16 {
118- text.split([' ', '\n'])
119- .map(|w| w.chars().count())
120- .max()
121- .unwrap_or(0)
122- .min(u16::MAX as usize) as u16
129+ text.split([' ', '\n']).map(text_cols).max().unwrap_or(0)
123130 }
124131
125132 /// The text an entry shows: its own, or its placeholder when it has none.
@@ -172,6 +179,8 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
172179 }
173180 Tag::Separator => 1,
174181 Tag::Spacer => props.cells("size", 1),
182+ Tag::Emoji => text_cols(props.str("emoji")),
183+ Tag::Reaction => text_cols(&pill_text(&props)),
175184 Tag::Progress => {
176185 if minimum {
177186 4
@@ -253,7 +262,13 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
253262 Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
254263 wrap(props.label(), inner).len() as u16
255264 }
256- Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,
265+ Tag::Button
266+ | Tag::CheckButton
267+ | Tag::Separator
268+ | Tag::Progress
269+ | Tag::Spinner
270+ | Tag::Reaction
271+ | Tag::Emoji => 1,
257272 Tag::Entry => props.cells("rows", 1).max(1),
258273 Tag::Spacer => props.cells("size", 1),
259274 Tag::Listbox => tree.child_count(id) as u16,
@@ -10,7 +10,7 @@
10 //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on10 //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on
11 //! the tree, which is why the layout tests below need no TTY.11 //! the tree, which is why the layout tests below need no TTY.
12 12
13-use crate::screen::Rect;13+use crate::screen::{char_cols, text_cols, Rect};
14 use crate::tree::{Props, Tag, Tree};14 use crate::tree::{Props, Tag, Tree};
15 15
16 /// How a child that is not filling its cross axis sits in the space it was16 /// How a child that is not filling its cross axis sits in the space it was
@@ -63,6 +63,17 @@ pub fn inset(tag: &Tag, props: &Props) -> u16 {
63 }63 }
64 }64 }
65 65
66+/// What a `:reaction` reads as: the glyph, and the tally when there is one.
67+/// A pill with no count is the chip you press to put one there — the same
68+/// picture the picker offers, which is the point of it being the same node.
69+pub fn pill_text(props: &Props) -> String {
70+ let glyph = props.str("emoji");
71+ match props.cells("count", 0) {
72+ 0 => glyph.to_owned(),
73+ n => format!("{glyph} {n}"),
74+ }
75+}
76+
66 /// Break `text` to `width` columns, on spaces where it can and mid-word where77 /// Break `text` to `width` columns, on spaces where it can and mid-word where
67 /// it must. Explicit newlines are always breaks.78 /// it must. Explicit newlines are always breaks.
68 pub fn wrap(text: &str, width: u16) -> Vec<String> {79 pub fn wrap(text: &str, width: u16) -> Vec<String> {
@@ -75,7 +86,10 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
75 let mut line = String::new();86 let mut line = String::new();
76 let mut len = 0usize;87 let mut len = 0usize;
77 for word in paragraph.split(' ') {88 for word in paragraph.split(' ') {
78- let word_len = word.chars().count();89+ // In columns, not characters: an emoji is drawn two cells wide, so
90+ // a line of them measured by character is twice the width it was
91+ // wrapped to and runs off the edge.
92+ let word_len = text_cols(word) as usize;
79 if len > 0 && len + 1 + word_len > width {93 if len > 0 && len + 1 + word_len > width {
80 lines.push(std::mem::take(&mut line));94 lines.push(std::mem::take(&mut line));
81 len = 0;95 len = 0;
@@ -84,12 +98,13 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
84 // Longer than the whole line: break it where the line ends98 // Longer than the whole line: break it where the line ends
85 // rather than let it run off the edge.99 // rather than let it run off the edge.
86 for ch in word.chars() {100 for ch in word.chars() {
87- if len == width {101+ let cols = char_cols(ch) as usize;
102+ if len + cols > width && len > 0 {
88 lines.push(std::mem::take(&mut line));103 lines.push(std::mem::take(&mut line));
89 len = 0;104 len = 0;
90 }105 }
91 line.push(ch);106 line.push(ch);
92- len += 1;107+ len += cols;
93 }108 }
94 continue;109 continue;
95 }110 }
@@ -106,20 +121,12 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> {
106 }121 }
107 122
108 fn columns(text: &str) -> u16 {123 fn columns(text: &str) -> u16 {
109- text.split('\n')124+ text.split('\n').map(text_cols).max().unwrap_or(0)
110- .map(|line| line.chars().count())
111- .max()
112- .unwrap_or(0)
113- .min(u16::MAX as usize) as u16
114 }125 }
115 126
116 /// The longest single word — a label cannot usefully be narrower than this.127 /// The longest single word — a label cannot usefully be narrower than this.
117 fn longest_word(text: &str) -> u16 {128 fn longest_word(text: &str) -> u16 {
118- text.split([' ', '\n'])129+ text.split([' ', '\n']).map(text_cols).max().unwrap_or(0)
119- .map(|w| w.chars().count())
120- .max()
121- .unwrap_or(0)
122- .min(u16::MAX as usize) as u16
123 }130 }
124 131
125 /// The text an entry shows: its own, or its placeholder when it has none.132 /// The text an entry shows: its own, or its placeholder when it has none.
@@ -172,6 +179,8 @@ fn intrinsic_width(tree: &Tree, id: u32, minimum: bool) -> u16 {
172 }179 }
173 Tag::Separator => 1,180 Tag::Separator => 1,
174 Tag::Spacer => props.cells("size", 1),181 Tag::Spacer => props.cells("size", 1),
182+ Tag::Emoji => text_cols(props.str("emoji")),
183+ Tag::Reaction => text_cols(&pill_text(&props)),
175 Tag::Progress => {184 Tag::Progress => {
176 if minimum {185 if minimum {
177 4186 4
@@ -253,7 +262,13 @@ pub fn height_for_width(tree: &Tree, id: u32, avail: u16) -> u16 {
253 Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {262 Tag::Unknown(_) if tree.child_count(id) == 0 && !has_picture(&props) => {
254 wrap(props.label(), inner).len() as u16263 wrap(props.label(), inner).len() as u16
255 }264 }
256- Tag::Button | Tag::CheckButton | Tag::Separator | Tag::Progress | Tag::Spinner => 1,265+ Tag::Button
266+ | Tag::CheckButton
267+ | Tag::Separator
268+ | Tag::Progress
269+ | Tag::Spinner
270+ | Tag::Reaction
271+ | Tag::Emoji => 1,
257 Tag::Entry => props.cells("rows", 1).max(1),272 Tag::Entry => props.cells("rows", 1).max(1),
258 Tag::Spacer => props.cells("size", 1),273 Tag::Spacer => props.cells("size", 1),
259 Tag::Listbox => tree.child_count(id) as u16,274 Tag::Listbox => tree.child_count(id) as u16,
modified crates/jolt-tui/src/paint.rs +38 -5
@@ -10,7 +10,7 @@
1010 //! asked for, in the middle.
1111
1212 use crate::layout::{self, wrap, Align};
13-use crate::screen::{attr, Color, Rect, Screen, Style};
13+use crate::screen::{self, attr, Color, Rect, Screen, Style};
1414 use crate::tree::{Props, Tag, Tree};
1515
1616 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
@@ -155,6 +155,13 @@ impl Painter<'_> {
155155 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
156156 self.screen.set(inner.x, inner.y, ch, style);
157157 }
158+ Tag::Reaction => self.reaction(id, inner, &props, style),
159+ // The same glyph with nothing around it: a character in a line,
160+ // and the line is what says anything about it.
161+ Tag::Emoji => {
162+ self.screen
163+ .text(inner.x, inner.y, inner.w, props.str("emoji"), style);
164+ }
158165 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
159166 // A spacer is the absence of anything; the clear at the top of the
160167 // frame has already drawn it.
@@ -212,6 +219,27 @@ impl Painter<'_> {
212219 self.screen.text(area.x, area.y, area.w, &label, style);
213220 }
214221
222+ /// A reaction pill: the glyph, the tally where there is one, and whether
223+ /// you are on it.
224+ ///
225+ /// No border around it. A window draws a lozenge because it has half-cells
226+ /// to draw one in; here brackets would cost two columns of a row that
227+ /// already carries three chips, and would say "button" about a thing whose
228+ /// whole picture is the glyph. Yours is bold, which is the one bit of the
229+ /// pill a reader actually reads off it.
230+ fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
231+ let mut style = if props.bool("mine", false) {
232+ style.with(attr::BOLD)
233+ } else {
234+ style
235+ };
236+ if self.focused(id) {
237+ style = style.with(attr::REVERSE);
238+ }
239+ self.screen
240+ .text(area.x, area.y, area.w, &layout::pill_text(props), style);
241+ }
242+
215243 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
216244 let style = if self.focused(id) {
217245 style.with(attr::REVERSE)
@@ -265,14 +293,19 @@ impl Painter<'_> {
265293 self.screen
266294 .text(area.x, area.y + i as u16, area.w, &visible, style);
267295 if focused && last {
296+ // In columns rather than characters: an emoji typed into the
297+ // line is two cells wide, and a caret counted in characters
298+ // sits a column left of the text for each one.
268299 let col = if showing_placeholder {
269300 0
270301 } else {
271- caret
272- .saturating_sub(from)
273- .min(area.w.saturating_sub(1) as usize)
302+ let typed: String = visible
303+ .chars()
304+ .take(caret.saturating_sub(from))
305+ .collect();
306+ (screen::text_cols(&typed)).min(area.w.saturating_sub(1))
274307 };
275- self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));
308+ self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16));
276309 }
277310 }
278311 }
@@ -10,7 +10,7 @@
10 //! asked for, in the middle.10 //! asked for, in the middle.
11 11
12 use crate::layout::{self, wrap, Align};12 use crate::layout::{self, wrap, Align};
13-use crate::screen::{attr, Color, Rect, Screen, Style};13+use crate::screen::{self, attr, Color, Rect, Screen, Style};
14 use crate::tree::{Props, Tag, Tree};14 use crate::tree::{Props, Tag, Tree};
15 15
16 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];16 const SPINNER: [char; 8] = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'];
@@ -155,6 +155,13 @@ impl Painter<'_> {
155 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];155 let ch = SPINNER[(self.tick as usize) % SPINNER.len()];
156 self.screen.set(inner.x, inner.y, ch, style);156 self.screen.set(inner.x, inner.y, ch, style);
157 }157 }
158+ Tag::Reaction => self.reaction(id, inner, &props, style),
159+ // The same glyph with nothing around it: a character in a line,
160+ // and the line is what says anything about it.
161+ Tag::Emoji => {
162+ self.screen
163+ .text(inner.x, inner.y, inner.w, props.str("emoji"), style);
164+ }
158 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),165 Tag::Listbox => self.listbox(id, inner, &props, style, enabled),
159 // A spacer is the absence of anything; the clear at the top of the166 // A spacer is the absence of anything; the clear at the top of the
160 // frame has already drawn it.167 // frame has already drawn it.
@@ -212,6 +219,27 @@ impl Painter<'_> {
212 self.screen.text(area.x, area.y, area.w, &label, style);219 self.screen.text(area.x, area.y, area.w, &label, style);
213 }220 }
214 221
222+ /// A reaction pill: the glyph, the tally where there is one, and whether
223+ /// you are on it.
224+ ///
225+ /// No border around it. A window draws a lozenge because it has half-cells
226+ /// to draw one in; here brackets would cost two columns of a row that
227+ /// already carries three chips, and would say "button" about a thing whose
228+ /// whole picture is the glyph. Yours is bold, which is the one bit of the
229+ /// pill a reader actually reads off it.
230+ fn reaction(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
231+ let mut style = if props.bool("mine", false) {
232+ style.with(attr::BOLD)
233+ } else {
234+ style
235+ };
236+ if self.focused(id) {
237+ style = style.with(attr::REVERSE);
238+ }
239+ self.screen
240+ .text(area.x, area.y, area.w, &layout::pill_text(props), style);
241+ }
242+
215 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {243 fn check(&mut self, id: u32, area: Rect, props: &Props, style: Style) {
216 let style = if self.focused(id) {244 let style = if self.focused(id) {
217 style.with(attr::REVERSE)245 style.with(attr::REVERSE)
@@ -265,14 +293,19 @@ impl Painter<'_> {
265 self.screen293 self.screen
266 .text(area.x, area.y + i as u16, area.w, &visible, style);294 .text(area.x, area.y + i as u16, area.w, &visible, style);
267 if focused && last {295 if focused && last {
296+ // In columns rather than characters: an emoji typed into the
297+ // line is two cells wide, and a caret counted in characters
298+ // sits a column left of the text for each one.
268 let col = if showing_placeholder {299 let col = if showing_placeholder {
269 0300 0
270 } else {301 } else {
271- caret302+ let typed: String = visible
272- .saturating_sub(from)303+ .chars()
273- .min(area.w.saturating_sub(1) as usize)304+ .take(caret.saturating_sub(from))
305+ .collect();
306+ (screen::text_cols(&typed)).min(area.w.saturating_sub(1))
274 };307 };
275- self.out.cursor = Some((area.x.saturating_add(col as u16), area.y + i as u16));308+ self.out.cursor = Some((area.x.saturating_add(col), area.y + i as u16));
276 }309 }
277 }310 }
278 }311 }
modified crates/jolt-tui/src/screen.rs +66 -4
@@ -80,6 +80,28 @@ impl Color {
8080 }
8181 }
8282
83+/// How many columns one character takes on screen.
84+///
85+/// Zero for the parts of a glyph that are not drawn — a variation selector, a
86+/// zero-width joiner, a skin tone — two for the emoji a terminal draws double
87+/// width, and one for everything else. This is the whole of what this backend
88+/// knows about character width, and it is enough for what a chat client puts
89+/// on a screen: text, and the emoji in it.
90+pub fn char_cols(ch: char) -> u16 {
91+ let c = ch as u32;
92+ match c {
93+ 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF => 0,
94+ 0x1F000.. => 2,
95+ 0x2600..=0x27BF => 2,
96+ _ => 1,
97+ }
98+}
99+
100+/// The columns `text` takes, the same way [`Screen::text`] spends them.
101+pub fn text_cols(text: &str) -> u16 {
102+ text.chars().map(char_cols).sum::<u16>().max(0)
103+}
104+
83105 /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s
84106 /// because a cell is copied a great many times a frame.
85107 pub mod attr {
@@ -118,6 +140,10 @@ impl Style {
118140 pub struct Cell {
119141 pub ch: char,
120142 pub style: Style,
143+ /// The right half of a double-width character. It holds no character of
144+ /// its own: the glyph in the cell to its left is drawn across both, and
145+ /// writing anything here would print a second copy one column over.
146+ pub trail: bool,
121147 }
122148
123149 impl Default for Cell {
@@ -125,6 +151,7 @@ impl Default for Cell {
125151 Self {
126152 ch: ' ',
127153 style: Style::default(),
154+ trail: false,
128155 }
129156 }
130157 }
@@ -213,7 +240,23 @@ impl Screen {
213240 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
214241 if x < self.w && y < self.h {
215242 let i = y as usize * self.w as usize + x as usize;
216- self.cells[i] = Cell { ch, style };
243+ self.cells[i] = Cell {
244+ ch,
245+ style,
246+ trail: false,
247+ };
248+ }
249+ }
250+
251+ /// The cell a double-width character's right half sits in.
252+ fn set_trail(&mut self, x: u16, y: u16, style: Style) {
253+ if x < self.w && y < self.h {
254+ let i = y as usize * self.w as usize + x as usize;
255+ self.cells[i] = Cell {
256+ ch: ' ',
257+ style,
258+ trail: true,
259+ };
217260 }
218261 }
219262
@@ -228,8 +271,23 @@ impl Screen {
228271 // A control character in a label would move the cursor; show it as
229272 // a dot instead of letting it rearrange the screen.
230273 let ch = if (ch as u32) < 0x20 { '·' } else { ch };
274+ let cols = char_cols(ch);
275+ if cols == 0 {
276+ // A joiner or a variation selector: part of the glyph before
277+ // it, and drawn with it. Keeping it in a cell of its own would
278+ // spend a column on something with no picture.
279+ continue;
280+ }
281+ if col + cols > width {
282+ // Half of a wide glyph is a different character, so the last
283+ // column stays blank rather than showing one.
284+ break;
285+ }
231286 self.set(x.saturating_add(col), y, ch, style);
232- col += 1;
287+ if cols == 2 {
288+ self.set_trail(x.saturating_add(col + 1), y, style);
289+ }
290+ col += cols;
233291 }
234292 col
235293 }
@@ -241,8 +299,9 @@ impl Screen {
241299 for x in rect.x..rect.x.saturating_add(rect.w) {
242300 if x < self.w && y < self.h {
243301 let i = y as usize * self.w as usize + x as usize;
244- let ch = self.cells[i].ch;
245- self.cells[i] = Cell { ch, style };
302+ // The character stays, and so does whether it is the half
303+ // of one: a background is a colour, not a repaint.
304+ self.cells[i].style = style;
246305 }
247306 }
248307 }
@@ -255,8 +314,11 @@ impl Screen {
255314 return String::new();
256315 }
257316 let start = y as usize * self.w as usize;
317+ // Without the trailing halves: they hold no character, and a reader —
318+ // a test, a bug report — wants the line as it looks.
258319 let row: String = self.cells[start..start + self.w as usize]
259320 .iter()
321+ .filter(|c| !c.trail)
260322 .map(|c| c.ch)
261323 .collect();
262324 row.trim_end().to_owned()
@@ -80,6 +80,28 @@ impl Color {
80 }80 }
81 }81 }
82 82
83+/// How many columns one character takes on screen.
84+///
85+/// Zero for the parts of a glyph that are not drawn — a variation selector, a
86+/// zero-width joiner, a skin tone — two for the emoji a terminal draws double
87+/// width, and one for everything else. This is the whole of what this backend
88+/// knows about character width, and it is enough for what a chat client puts
89+/// on a screen: text, and the emoji in it.
90+pub fn char_cols(ch: char) -> u16 {
91+ let c = ch as u32;
92+ match c {
93+ 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF => 0,
94+ 0x1F000.. => 2,
95+ 0x2600..=0x27BF => 2,
96+ _ => 1,
97+ }
98+}
99+
100+/// The columns `text` takes, the same way [`Screen::text`] spends them.
101+pub fn text_cols(text: &str) -> u16 {
102+ text.chars().map(char_cols).sum::<u16>().max(0)
103+}
104+
83 /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s105 /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s
84 /// because a cell is copied a great many times a frame.106 /// because a cell is copied a great many times a frame.
85 pub mod attr {107 pub mod attr {
@@ -118,6 +140,10 @@ impl Style {
118 pub struct Cell {140 pub struct Cell {
119 pub ch: char,141 pub ch: char,
120 pub style: Style,142 pub style: Style,
143+ /// The right half of a double-width character. It holds no character of
144+ /// its own: the glyph in the cell to its left is drawn across both, and
145+ /// writing anything here would print a second copy one column over.
146+ pub trail: bool,
121 }147 }
122 148
123 impl Default for Cell {149 impl Default for Cell {
@@ -125,6 +151,7 @@ impl Default for Cell {
125 Self {151 Self {
126 ch: ' ',152 ch: ' ',
127 style: Style::default(),153 style: Style::default(),
154+ trail: false,
128 }155 }
129 }156 }
130 }157 }
@@ -213,7 +240,23 @@ impl Screen {
213 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {240 pub fn set(&mut self, x: u16, y: u16, ch: char, style: Style) {
214 if x < self.w && y < self.h {241 if x < self.w && y < self.h {
215 let i = y as usize * self.w as usize + x as usize;242 let i = y as usize * self.w as usize + x as usize;
216- self.cells[i] = Cell { ch, style };243+ self.cells[i] = Cell {
244+ ch,
245+ style,
246+ trail: false,
247+ };
248+ }
249+ }
250+
251+ /// The cell a double-width character's right half sits in.
252+ fn set_trail(&mut self, x: u16, y: u16, style: Style) {
253+ if x < self.w && y < self.h {
254+ let i = y as usize * self.w as usize + x as usize;
255+ self.cells[i] = Cell {
256+ ch: ' ',
257+ style,
258+ trail: true,
259+ };
217 }260 }
218 }261 }
219 262
@@ -228,8 +271,23 @@ impl Screen {
228 // A control character in a label would move the cursor; show it as271 // A control character in a label would move the cursor; show it as
229 // a dot instead of letting it rearrange the screen.272 // a dot instead of letting it rearrange the screen.
230 let ch = if (ch as u32) < 0x20 { '·' } else { ch };273 let ch = if (ch as u32) < 0x20 { '·' } else { ch };
274+ let cols = char_cols(ch);
275+ if cols == 0 {
276+ // A joiner or a variation selector: part of the glyph before
277+ // it, and drawn with it. Keeping it in a cell of its own would
278+ // spend a column on something with no picture.
279+ continue;
280+ }
281+ if col + cols > width {
282+ // Half of a wide glyph is a different character, so the last
283+ // column stays blank rather than showing one.
284+ break;
285+ }
231 self.set(x.saturating_add(col), y, ch, style);286 self.set(x.saturating_add(col), y, ch, style);
232- col += 1;287+ if cols == 2 {
288+ self.set_trail(x.saturating_add(col + 1), y, style);
289+ }
290+ col += cols;
233 }291 }
234 col292 col
235 }293 }
@@ -241,8 +299,9 @@ impl Screen {
241 for x in rect.x..rect.x.saturating_add(rect.w) {299 for x in rect.x..rect.x.saturating_add(rect.w) {
242 if x < self.w && y < self.h {300 if x < self.w && y < self.h {
243 let i = y as usize * self.w as usize + x as usize;301 let i = y as usize * self.w as usize + x as usize;
244- let ch = self.cells[i].ch;302+ // The character stays, and so does whether it is the half
245- self.cells[i] = Cell { ch, style };303+ // of one: a background is a colour, not a repaint.
304+ self.cells[i].style = style;
246 }305 }
247 }306 }
248 }307 }
@@ -255,8 +314,11 @@ impl Screen {
255 return String::new();314 return String::new();
256 }315 }
257 let start = y as usize * self.w as usize;316 let start = y as usize * self.w as usize;
317+ // Without the trailing halves: they hold no character, and a reader —
318+ // a test, a bug report — wants the line as it looks.
258 let row: String = self.cells[start..start + self.w as usize]319 let row: String = self.cells[start..start + self.w as usize]
259 .iter()320 .iter()
321+ .filter(|c| !c.trail)
260 .map(|c| c.ch)322 .map(|c| c.ch)
261 .collect();323 .collect();
262 row.trim_end().to_owned()324 row.trim_end().to_owned()
modified crates/jolt-tui/src/term.rs +11 -2
@@ -17,7 +17,7 @@ use crossterm::terminal::{
1717 use crossterm::{cursor, execute, queue, style};
1818
1919 use crate::keys;
20-use crate::screen::{attr, Color, Screen, Style};
20+use crate::screen::{self, attr, Color, Screen, Style};
2121
2222 /// How far one notch of the wheel moves a list, in rows.
2323 const WHEEL_ROWS: i32 = 3;
@@ -138,6 +138,15 @@ impl Term {
138138 if self.last.cell(x, y) == Some(cell) {
139139 continue;
140140 }
141+ // The right half of a double-width glyph is not written: the
142+ // character to its left was drawn across both cells and left the
143+ // cursor past them. Writing here would put a second copy of
144+ // whatever follows one column over, and every column after it on
145+ // that row would be a column out — which is what a mouse click is
146+ // then aimed at.
147+ if cell.trail {
148+ continue;
149+ }
141150 // Only move when the run breaks: a full-width change is one seek
142151 // and a line of text, not a seek a cell.
143152 if at != Some((x, y)) {
@@ -148,7 +157,7 @@ impl Term {
148157 style = Some(cell.style);
149158 }
150159 queue!(self.out, style::Print(cell.ch))?;
151- at = Some((x + 1, y));
160+ at = Some((x + screen::char_cols(cell.ch), y));
152161 }
153162 queue!(self.out, style::ResetColor)?;
154163 match cursor {
@@ -17,7 +17,7 @@ use crossterm::terminal::{
17 use crossterm::{cursor, execute, queue, style};17 use crossterm::{cursor, execute, queue, style};
18 18
19 use crate::keys;19 use crate::keys;
20-use crate::screen::{attr, Color, Screen, Style};20+use crate::screen::{self, attr, Color, Screen, Style};
21 21
22 /// How far one notch of the wheel moves a list, in rows.22 /// How far one notch of the wheel moves a list, in rows.
23 const WHEEL_ROWS: i32 = 3;23 const WHEEL_ROWS: i32 = 3;
@@ -138,6 +138,15 @@ impl Term {
138 if self.last.cell(x, y) == Some(cell) {138 if self.last.cell(x, y) == Some(cell) {
139 continue;139 continue;
140 }140 }
141+ // The right half of a double-width glyph is not written: the
142+ // character to its left was drawn across both cells and left the
143+ // cursor past them. Writing here would put a second copy of
144+ // whatever follows one column over, and every column after it on
145+ // that row would be a column out — which is what a mouse click is
146+ // then aimed at.
147+ if cell.trail {
148+ continue;
149+ }
141 // Only move when the run breaks: a full-width change is one seek150 // Only move when the run breaks: a full-width change is one seek
142 // and a line of text, not a seek a cell.151 // and a line of text, not a seek a cell.
143 if at != Some((x, y)) {152 if at != Some((x, y)) {
@@ -148,7 +157,7 @@ impl Term {
148 style = Some(cell.style);157 style = Some(cell.style);
149 }158 }
150 queue!(self.out, style::Print(cell.ch))?;159 queue!(self.out, style::Print(cell.ch))?;
151- at = Some((x + 1, y));160+ at = Some((x + screen::char_cols(cell.ch), y));
152 }161 }
153 queue!(self.out, style::ResetColor)?;162 queue!(self.out, style::ResetColor)?;
154 match cursor {163 match cursor {
modified crates/jolt-tui/src/tests.rs +53 -0
@@ -393,6 +393,59 @@ fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() {
393393 assert_eq!(ui.screen.line(0), "row 5");
394394 }
395395
396+#[test]
397+fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() {
398+ let mut ui = Ui::new(20, 2);
399+ let root = ui.tree.root();
400+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
401+ ui.tree.set(row, "spacing", Value::Num(1.0));
402+ let pill = node(&mut ui, row, "reaction", &[("emoji", "👍")]);
403+ ui.tree.set(pill, "count", Value::Num(2.0));
404+ ui.tree.set(pill, "mine", Value::Bool(true));
405+ let chip = node(&mut ui, row, "reaction", &[("emoji", "🙂")]);
406+ ui.frame();
407+ // The tally where there is one; the bare glyph where there is not — that
408+ // is the chip you press to start one.
409+ assert_eq!(ui.screen.line(0), "👍 2 🙂");
410+
411+ // Two cells for the glyph, so the chip after it starts where it looks
412+ // like it starts.
413+ assert!(ui.click(5, 0), "the chip took the click");
414+ assert_eq!(
415+ events(&mut ui),
416+ vec![(chip, "click".into(), String::new(), 0.0)]
417+ );
418+
419+ // And by keyboard, for a terminal with no pointer at all.
420+ ui.key("shift+tab");
421+ ui.key("enter");
422+ assert_eq!(
423+ events(&mut ui),
424+ vec![(pill, "click".into(), String::new(), 0.0)]
425+ );
426+}
427+
428+#[test]
429+fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() {
430+ let mut ui = Ui::new(12, 3);
431+ let root = ui.tree.root();
432+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
433+ node(&mut ui, row, "label", &[("label", "👍")]);
434+ let after = node(&mut ui, row, "button", &[("label", "ok")]);
435+ // A label that has to wrap wraps by column, not by character.
436+ node(&mut ui, root, "label", &[("label", "👍👍👍👍👍👍👍")]);
437+ ui.frame();
438+ assert_eq!(ui.screen.line(0), "👍[ ok ]");
439+ assert_eq!(ui.screen.line(1), "👍👍👍👍👍👍");
440+
441+ // The button starts at column 2, because the glyph before it took two.
442+ assert!(ui.click(2, 0));
443+ assert_eq!(
444+ events(&mut ui),
445+ vec![(after, "click".into(), String::new(), 0.0)]
446+ );
447+}
448+
396449 #[test]
397450 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
398451 let mut ui = Ui::new(14, 5);
@@ -393,6 +393,59 @@ fn a_wheel_between_a_re_render_and_a_frame_moves_from_where_the_list_was() {
393 assert_eq!(ui.screen.line(0), "row 5");393 assert_eq!(ui.screen.line(0), "row 5");
394 }394 }
395 395
396+#[test]
397+fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() {
398+ let mut ui = Ui::new(20, 2);
399+ let root = ui.tree.root();
400+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
401+ ui.tree.set(row, "spacing", Value::Num(1.0));
402+ let pill = node(&mut ui, row, "reaction", &[("emoji", "👍")]);
403+ ui.tree.set(pill, "count", Value::Num(2.0));
404+ ui.tree.set(pill, "mine", Value::Bool(true));
405+ let chip = node(&mut ui, row, "reaction", &[("emoji", "🙂")]);
406+ ui.frame();
407+ // The tally where there is one; the bare glyph where there is not — that
408+ // is the chip you press to start one.
409+ assert_eq!(ui.screen.line(0), "👍 2 🙂");
410+
411+ // Two cells for the glyph, so the chip after it starts where it looks
412+ // like it starts.
413+ assert!(ui.click(5, 0), "the chip took the click");
414+ assert_eq!(
415+ events(&mut ui),
416+ vec![(chip, "click".into(), String::new(), 0.0)]
417+ );
418+
419+ // And by keyboard, for a terminal with no pointer at all.
420+ ui.key("shift+tab");
421+ ui.key("enter");
422+ assert_eq!(
423+ events(&mut ui),
424+ vec![(pill, "click".into(), String::new(), 0.0)]
425+ );
426+}
427+
428+#[test]
429+fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() {
430+ let mut ui = Ui::new(12, 3);
431+ let root = ui.tree.root();
432+ let row = node(&mut ui, root, "hbox", &[("orientation", "horizontal")]);
433+ node(&mut ui, row, "label", &[("label", "👍")]);
434+ let after = node(&mut ui, row, "button", &[("label", "ok")]);
435+ // A label that has to wrap wraps by column, not by character.
436+ node(&mut ui, root, "label", &[("label", "👍👍👍👍👍👍👍")]);
437+ ui.frame();
438+ assert_eq!(ui.screen.line(0), "👍[ ok ]");
439+ assert_eq!(ui.screen.line(1), "👍👍👍👍👍👍");
440+
441+ // The button starts at column 2, because the glyph before it took two.
442+ assert!(ui.click(2, 0));
443+ assert_eq!(
444+ events(&mut ui),
445+ vec![(after, "click".into(), String::new(), 0.0)]
446+ );
447+}
448+
396 #[test]449 #[test]
397 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {450 fn an_overlay_floats_in_the_middle_over_whatever_was_under_it() {
398 let mut ui = Ui::new(14, 5);451 let mut ui = Ui::new(14, 5);
modified crates/jolt-tui/src/tree.rs +12 -1
@@ -43,6 +43,8 @@ pub enum Tag {
4343 Listbox,
4444 Progress,
4545 Spinner,
46+ Reaction,
47+ Emoji,
4648 Unknown(String),
4749 }
4850
@@ -71,6 +73,13 @@ impl Tag {
7173 "listbox" => Self::Listbox,
7274 "progress" => Self::Progress,
7375 "spinner" => Self::Spinner,
76+ // The two glyph nodes. A reaction is a pill somebody can press —
77+ // the count of who is on it, and whether you are one of them; an
78+ // emoji is the same glyph with none of that, a character in a
79+ // sentence. Both carry what to draw in `:emoji` rather than in a
80+ // label, which is why an unknown tag painted neither.
81+ "reaction" => Self::Reaction,
82+ "emoji" => Self::Emoji,
7483 other => Self::Unknown(other.to_owned()),
7584 }
7685 }
@@ -95,6 +104,8 @@ impl Tag {
95104 Self::Listbox => "listbox",
96105 Self::Progress => "progress",
97106 Self::Spinner => "spinner",
107+ Self::Reaction => "reaction",
108+ Self::Emoji => "emoji",
98109 Self::Unknown(name) => name,
99110 }
100111 }
@@ -104,7 +115,7 @@ impl Tag {
104115 pub fn focusable(&self) -> bool {
105116 matches!(
106117 self,
107- Self::Button | Self::CheckButton | Self::Entry | Self::Listbox
118+ Self::Button | Self::CheckButton | Self::Entry | Self::Listbox | Self::Reaction
108119 )
109120 }
110121 }
@@ -43,6 +43,8 @@ pub enum Tag {
43 Listbox,43 Listbox,
44 Progress,44 Progress,
45 Spinner,45 Spinner,
46+ Reaction,
47+ Emoji,
46 Unknown(String),48 Unknown(String),
47 }49 }
48 50
@@ -71,6 +73,13 @@ impl Tag {
71 "listbox" => Self::Listbox,73 "listbox" => Self::Listbox,
72 "progress" => Self::Progress,74 "progress" => Self::Progress,
73 "spinner" => Self::Spinner,75 "spinner" => Self::Spinner,
76+ // The two glyph nodes. A reaction is a pill somebody can press —
77+ // the count of who is on it, and whether you are one of them; an
78+ // emoji is the same glyph with none of that, a character in a
79+ // sentence. Both carry what to draw in `:emoji` rather than in a
80+ // label, which is why an unknown tag painted neither.
81+ "reaction" => Self::Reaction,
82+ "emoji" => Self::Emoji,
74 other => Self::Unknown(other.to_owned()),83 other => Self::Unknown(other.to_owned()),
75 }84 }
76 }85 }
@@ -95,6 +104,8 @@ impl Tag {
95 Self::Listbox => "listbox",104 Self::Listbox => "listbox",
96 Self::Progress => "progress",105 Self::Progress => "progress",
97 Self::Spinner => "spinner",106 Self::Spinner => "spinner",
107+ Self::Reaction => "reaction",
108+ Self::Emoji => "emoji",
98 Self::Unknown(name) => name,109 Self::Unknown(name) => name,
99 }110 }
100 }111 }
@@ -104,7 +115,7 @@ impl Tag {
104 pub fn focusable(&self) -> bool {115 pub fn focusable(&self) -> bool {
105 matches!(116 matches!(
106 self,117 self,
107- Self::Button | Self::CheckButton | Self::Entry | Self::Listbox118+ Self::Button | Self::CheckButton | Self::Entry | Self::Listbox | Self::Reaction
108 )119 )
109 }120 }
110 }121 }
modified crates/jolt-tui/src/ui.rs +4 -2
@@ -252,7 +252,7 @@ impl Ui {
252252 let focus = self.focus;
253253 let handled = match self.tree.tag(focus) {
254254 Tag::Entry => self.entry_key(focus, name),
255- Tag::Button => self.activate_key(focus, name, "click"),
255+ Tag::Button | Tag::Reaction => self.activate_key(focus, name, "click"),
256256 Tag::CheckButton => {
257257 if matches!(name, "enter" | "space") {
258258 self.toggle(focus);
@@ -443,7 +443,9 @@ impl Ui {
443443 };
444444 self.set_focus(node);
445445 match self.tree.tag(node) {
446- Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),
446+ // A pill is pressed the way a button is: the caller's `:on-click`
447+ // is what puts a reaction on or takes it off again.
448+ Tag::Button | Tag::Reaction => self.tree.emit(node, "click", String::new(), 0.0),
447449 Tag::CheckButton => self.toggle(node),
448450 Tag::Listbox => {
449451 let row = (y - rect.y) as i64;
@@ -252,7 +252,7 @@ impl Ui {
252 let focus = self.focus;252 let focus = self.focus;
253 let handled = match self.tree.tag(focus) {253 let handled = match self.tree.tag(focus) {
254 Tag::Entry => self.entry_key(focus, name),254 Tag::Entry => self.entry_key(focus, name),
255- Tag::Button => self.activate_key(focus, name, "click"),255+ Tag::Button | Tag::Reaction => self.activate_key(focus, name, "click"),
256 Tag::CheckButton => {256 Tag::CheckButton => {
257 if matches!(name, "enter" | "space") {257 if matches!(name, "enter" | "space") {
258 self.toggle(focus);258 self.toggle(focus);
@@ -443,7 +443,9 @@ impl Ui {
443 };443 };
444 self.set_focus(node);444 self.set_focus(node);
445 match self.tree.tag(node) {445 match self.tree.tag(node) {
446- Tag::Button => self.tree.emit(node, "click", String::new(), 0.0),446+ // A pill is pressed the way a button is: the caller's `:on-click`
447+ // is what puts a reaction on or takes it off again.
448+ Tag::Button | Tag::Reaction => self.tree.emit(node, "click", String::new(), 0.0),
447 Tag::CheckButton => self.toggle(node),449 Tag::CheckButton => self.toggle(node),
448 Tag::Listbox => {450 Tag::Listbox => {
449 let row = (y - rect.y) as i64;451 let row = (y - rect.y) as i64;