Keep a glyph whole, selector and all, and copy it across whole
`↩️` is an arrow and a variation selector saying to draw it as a picture rather than as the small mono arrow a terminal draws without one. This painted a cell a character and spent nothing on the selector, so what reached the screen was the arrow alone — a reply chip a third the size of the 🙂 beside it, and one column where the layout had counted two. A cell now holds the whole glyph: its first character, and the rest where there is a rest — the selector, the joiners in a family. Splitting text into glyphs is what measuring, wrapping and painting all do now, so the three of them agree about a line with an emoji in it. And a scroll copies cells rather than characters. It paints its content into a buffer and copies the visible window across, cell by cell through `set` — which took the character and the style and left behind the rest of the glyph and the mark that says a cell is the right half of a wide one. Everything in frq's backlog goes through that copy, which is why the arrow lost its selector there and nowhere else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3dd441e parent: dce285f modified
crates/jolt-tui/src/layout.rs +4 -4 | @@ -10,7 +10,7 @@ | ||
| 10 | 10 | //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on |
| 11 | 11 | //! the tree, which is why the layout tests below need no TTY. |
| 12 | 12 | |
| 13 | -use crate::screen::{char_cols, text_cols, Rect}; | |
| 13 | +use crate::screen::{glyph_cols, glyphs, text_cols, Rect}; | |
| 14 | 14 | use crate::tree::{Props, Tag, Tree}; |
| 15 | 15 | |
| 16 | 16 | /// How a child that is not filling its cross axis sits in the space it was |
| @@ -97,13 +97,13 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> { | ||
| 97 | 97 | if word_len > width { |
| 98 | 98 | // Longer than the whole line: break it where the line ends |
| 99 | 99 | // rather than let it run off the edge. |
| 100 | - for ch in word.chars() { | |
| 101 | - let cols = char_cols(ch) as usize; | |
| 100 | + for glyph in glyphs(word) { | |
| 101 | + let cols = glyph_cols(&glyph) as usize; | |
| 102 | 102 | if len + cols > width && len > 0 { |
| 103 | 103 | lines.push(std::mem::take(&mut line)); |
| 104 | 104 | len = 0; |
| 105 | 105 | } |
| 106 | - line.push(ch); | |
| 106 | + line.push_str(&glyph); | |
| 107 | 107 | len += cols; |
| 108 | 108 | } |
| 109 | 109 | continue; |
| @@ -10,7 +10,7 @@ | |||
| 10 | //! Nothing here touches a terminal or the screen grid: sizes are arithmetic on | 10 | //! 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::{char_cols, text_cols, Rect}; | 13 | +use crate::screen::{glyph_cols, glyphs, 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 was | 16 | /// How a child that is not filling its cross axis sits in the space it was |
| @@ -97,13 +97,13 @@ pub fn wrap(text: &str, width: u16) -> Vec<String> { | |||
| 97 | if word_len > width { | 97 | if word_len > width { |
| 98 | // Longer than the whole line: break it where the line ends | 98 | // Longer than the whole line: break it where the line ends |
| 99 | // rather than let it run off the edge. | 99 | // rather than let it run off the edge. |
| 100 | - for ch in word.chars() { | 100 | + for glyph in glyphs(word) { |
| 101 | - let cols = char_cols(ch) as usize; | 101 | + let cols = glyph_cols(&glyph) as usize; |
| 102 | if len + cols > width && len > 0 { | 102 | if len + cols > width && len > 0 { |
| 103 | lines.push(std::mem::take(&mut line)); | 103 | lines.push(std::mem::take(&mut line)); |
| 104 | len = 0; | 104 | len = 0; |
| 105 | } | 105 | } |
| 106 | - line.push(ch); | 106 | + line.push_str(&glyph); |
| 107 | len += cols; | 107 | len += cols; |
| 108 | } | 108 | } |
| 109 | continue; | 109 | continue; |
modified
crates/jolt-tui/src/paint.rs +1 -1 | @@ -400,7 +400,7 @@ impl Painter<'_> { | ||
| 400 | 400 | for y in 0..area.h { |
| 401 | 401 | for x in 0..area.w { |
| 402 | 402 | if let Some(cell) = buffer.cell(x, y + offset) { |
| 403 | - self.screen.set(area.x + x, area.y + y, cell.ch, cell.style); | |
| 403 | + self.screen.put(area.x + x, area.y + y, cell.clone()); | |
| 404 | 404 | } |
| 405 | 405 | } |
| 406 | 406 | } |
| @@ -400,7 +400,7 @@ impl Painter<'_> { | |||
| 400 | for y in 0..area.h { | 400 | for y in 0..area.h { |
| 401 | for x in 0..area.w { | 401 | for x in 0..area.w { |
| 402 | if let Some(cell) = buffer.cell(x, y + offset) { | 402 | if let Some(cell) = buffer.cell(x, y + offset) { |
| 403 | - self.screen.set(area.x + x, area.y + y, cell.ch, cell.style); | 403 | + self.screen.put(area.x + x, area.y + y, cell.clone()); |
| 404 | } | 404 | } |
| 405 | } | 405 | } |
| 406 | } | 406 | } |
modified
crates/jolt-tui/src/screen.rs +94 -27 | @@ -80,26 +80,60 @@ impl Color { | ||
| 80 | 80 | } |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | -/// How many columns one character takes on screen. | |
| 83 | +/// True for a character that is part of the glyph before it rather than one of | |
| 84 | +/// its own: a variation selector, a zero-width joiner, a skin tone. | |
| 85 | +fn continues_glyph(ch: char) -> bool { | |
| 86 | + matches!(ch as u32, 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF) | |
| 87 | +} | |
| 88 | + | |
| 89 | +/// Split `text` into what a terminal draws as single glyphs. | |
| 84 | 90 | /// |
| 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, | |
| 91 | +/// One character is usually one glyph, but an emoji is often several: `↩️` is | |
| 92 | +/// an arrow and a variation selector saying to draw it as a picture, and a | |
| 93 | +/// family is three people and the joiners between them. Cutting between those | |
| 94 | +/// characters is what makes a reply arrow come out as the small mono arrow | |
| 95 | +/// rather than the emoji — the selector says which of the two a terminal | |
| 96 | +/// draws, and it has to travel with the character it is about. | |
| 97 | +pub fn glyphs(text: &str) -> Vec<String> { | |
| 98 | + let mut out: Vec<String> = Vec::new(); | |
| 99 | + let mut joining = false; | |
| 100 | + for ch in text.chars() { | |
| 101 | + let continues = continues_glyph(ch); | |
| 102 | + if (continues || joining) && !out.is_empty() { | |
| 103 | + out.last_mut().expect("not empty").push(ch); | |
| 104 | + } else { | |
| 105 | + out.push(ch.to_string()); | |
| 106 | + } | |
| 107 | + // A joiner welds what follows it onto what came before. | |
| 108 | + joining = ch as u32 == 0x200D; | |
| 109 | + } | |
| 110 | + out | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// How many columns one glyph takes on screen. | |
| 114 | +/// | |
| 115 | +/// Two for the emoji a terminal draws double width, one for everything else. | |
| 116 | +/// A character out of the emoji blocks is drawn wide on its own; an older | |
| 117 | +/// symbol or dingbat is drawn wide when it carries the selector that asks for | |
| 118 | +/// the picture, and narrow — a character among characters — when it does not. | |
| 119 | +/// This is the whole of what this backend knows about width, and it is enough | |
| 120 | +/// for what a chat client puts on a screen: text, and the emoji in it. | |
| 121 | +pub fn glyph_cols(glyph: &str) -> u16 { | |
| 122 | + let mut chars = glyph.chars(); | |
| 123 | + let Some(base) = chars.next() else { | |
| 124 | + return 0; | |
| 125 | + }; | |
| 126 | + let emoji_presentation = glyph.chars().any(|c| c as u32 == 0xFE0F); | |
| 127 | + match base as u32 { | |
| 94 | 128 | 0x1F000.. => 2, |
| 95 | - 0x2600..=0x27BF => 2, | |
| 129 | + 0x2000..=0x2BFF if emoji_presentation => 2, | |
| 96 | 130 | _ => 1, |
| 97 | 131 | } |
| 98 | 132 | } |
| 99 | 133 | |
| 100 | 134 | /// The columns `text` takes, the same way [`Screen::text`] spends them. |
| 101 | 135 | pub fn text_cols(text: &str) -> u16 { |
| 102 | - text.chars().map(char_cols).sum::<u16>().max(0) | |
| 136 | + glyphs(text).iter().map(|g| glyph_cols(g)).sum() | |
| 103 | 137 | } |
| 104 | 138 | |
| 105 | 139 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s |
| @@ -136,9 +170,14 @@ impl Style { | ||
| 136 | 170 | } |
| 137 | 171 | } |
| 138 | 172 | |
| 139 | -#[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 173 | +#[derive(Clone, Debug, PartialEq, Eq)] | |
| 140 | 174 | pub struct Cell { |
| 141 | 175 | pub ch: char, |
| 176 | + /// The rest of the glyph, where it took more than one character: the | |
| 177 | + /// selector that asks for a picture, the joiners in a family. Printed | |
| 178 | + /// straight after `ch`, which is the only way a terminal draws them as | |
| 179 | + /// the one glyph they are. | |
| 180 | + pub tail: Option<Box<str>>, | |
| 142 | 181 | pub style: Style, |
| 143 | 182 | /// The right half of a double-width character. It holds no character of |
| 144 | 183 | /// its own: the glyph in the cell to its left is drawn across both, and |
| @@ -150,6 +189,7 @@ impl Default for Cell { | ||
| 150 | 189 | fn default() -> Self { |
| 151 | 190 | Self { |
| 152 | 191 | ch: ' ', |
| 192 | + tail: None, | |
| 153 | 193 | style: Style::default(), |
| 154 | 194 | trail: false, |
| 155 | 195 | } |
| @@ -242,6 +282,7 @@ impl Screen { | ||
| 242 | 282 | let i = y as usize * self.w as usize + x as usize; |
| 243 | 283 | self.cells[i] = Cell { |
| 244 | 284 | ch, |
| 285 | + tail: None, | |
| 245 | 286 | style, |
| 246 | 287 | trail: false, |
| 247 | 288 | }; |
| @@ -254,6 +295,7 @@ impl Screen { | ||
| 254 | 295 | let i = y as usize * self.w as usize + x as usize; |
| 255 | 296 | self.cells[i] = Cell { |
| 256 | 297 | ch: ' ', |
| 298 | + tail: None, | |
| 257 | 299 | style, |
| 258 | 300 | trail: true, |
| 259 | 301 | }; |
| @@ -264,26 +306,27 @@ impl Screen { | ||
| 264 | 306 | /// columns were used. |
| 265 | 307 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { |
| 266 | 308 | let mut col = 0u16; |
| 267 | - for ch in text.chars() { | |
| 309 | + for glyph in glyphs(text) { | |
| 268 | 310 | if col >= width { |
| 269 | 311 | break; |
| 270 | 312 | } |
| 313 | + let mut chars = glyph.chars(); | |
| 314 | + let ch = chars.next().unwrap_or(' '); | |
| 271 | 315 | // A control character in a label would move the cursor; show it as |
| 272 | 316 | // a dot instead of letting it rearrange the screen. |
| 273 | 317 | 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 | - } | |
| 318 | + let cols = glyph_cols(&glyph); | |
| 281 | 319 | if col + cols > width { |
| 282 | 320 | // Half of a wide glyph is a different character, so the last |
| 283 | 321 | // column stays blank rather than showing one. |
| 284 | 322 | break; |
| 285 | 323 | } |
| 286 | - self.set(x.saturating_add(col), y, ch, style); | |
| 324 | + let at = x.saturating_add(col); | |
| 325 | + self.set(at, y, ch, style); | |
| 326 | + let rest: String = chars.collect(); | |
| 327 | + if !rest.is_empty() { | |
| 328 | + self.set_tail(at, y, rest); | |
| 329 | + } | |
| 287 | 330 | if cols == 2 { |
| 288 | 331 | self.set_trail(x.saturating_add(col + 1), y, style); |
| 289 | 332 | } |
| @@ -292,6 +335,25 @@ impl Screen { | ||
| 292 | 335 | col |
| 293 | 336 | } |
| 294 | 337 | |
| 338 | + /// Put a whole cell down as it is — what a scroll does when it copies the | |
| 339 | + /// visible window of its content across. Cell by character would drop the | |
| 340 | + /// rest of a glyph and the right half of a wide one, which is a reply | |
| 341 | + /// arrow painted as the small mono arrow and a row a column out. | |
| 342 | + pub fn put(&mut self, x: u16, y: u16, cell: Cell) { | |
| 343 | + if x < self.w && y < self.h { | |
| 344 | + let i = y as usize * self.w as usize + x as usize; | |
| 345 | + self.cells[i] = cell; | |
| 346 | + } | |
| 347 | + } | |
| 348 | + | |
| 349 | + /// The rest of a glyph, on the cell its first character went in. | |
| 350 | + fn set_tail(&mut self, x: u16, y: u16, rest: String) { | |
| 351 | + if x < self.w && y < self.h { | |
| 352 | + let i = y as usize * self.w as usize + x as usize; | |
| 353 | + self.cells[i].tail = Some(rest.into_boxed_str()); | |
| 354 | + } | |
| 355 | + } | |
| 356 | + | |
| 295 | 357 | /// Paint every cell of `rect` with `style`, keeping the characters — that |
| 296 | 358 | /// is what a background is: a colour behind whatever is already there. |
| 297 | 359 | pub fn fill(&mut self, rect: Rect, style: Style) { |
| @@ -316,11 +378,16 @@ impl Screen { | ||
| 316 | 378 | let start = y as usize * self.w as usize; |
| 317 | 379 | // Without the trailing halves: they hold no character, and a reader — |
| 318 | 380 | // a test, a bug report — wants the line as it looks. |
| 319 | - let row: String = self.cells[start..start + self.w as usize] | |
| 320 | - .iter() | |
| 321 | - .filter(|c| !c.trail) | |
| 322 | - .map(|c| c.ch) | |
| 323 | - .collect(); | |
| 381 | + let mut row = String::new(); | |
| 382 | + for cell in self.cells[start..start + self.w as usize].iter() { | |
| 383 | + if cell.trail { | |
| 384 | + continue; | |
| 385 | + } | |
| 386 | + row.push(cell.ch); | |
| 387 | + if let Some(tail) = &cell.tail { | |
| 388 | + row.push_str(tail); | |
| 389 | + } | |
| 390 | + } | |
| 324 | 391 | row.trim_end().to_owned() |
| 325 | 392 | } |
| 326 | 393 | |
| @@ -80,26 +80,60 @@ impl Color { | |||
| 80 | } | 80 | } |
| 81 | } | 81 | } |
| 82 | 82 | ||
| 83 | -/// How many columns one character takes on screen. | 83 | +/// True for a character that is part of the glyph before it rather than one of |
| 84 | +/// its own: a variation selector, a zero-width joiner, a skin tone. | ||
| 85 | +fn continues_glyph(ch: char) -> bool { | ||
| 86 | + matches!(ch as u32, 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF) | ||
| 87 | +} | ||
| 88 | + | ||
| 89 | +/// Split `text` into what a terminal draws as single glyphs. | ||
| 84 | /// | 90 | /// |
| 85 | -/// Zero for the parts of a glyph that are not drawn — a variation selector, a | 91 | +/// One character is usually one glyph, but an emoji is often several: `↩️` is |
| 86 | -/// zero-width joiner, a skin tone — two for the emoji a terminal draws double | 92 | +/// an arrow and a variation selector saying to draw it as a picture, and a |
| 87 | -/// width, and one for everything else. This is the whole of what this backend | 93 | +/// family is three people and the joiners between them. Cutting between those |
| 88 | -/// knows about character width, and it is enough for what a chat client puts | 94 | +/// characters is what makes a reply arrow come out as the small mono arrow |
| 89 | -/// on a screen: text, and the emoji in it. | 95 | +/// rather than the emoji — the selector says which of the two a terminal |
| 90 | -pub fn char_cols(ch: char) -> u16 { | 96 | +/// draws, and it has to travel with the character it is about. |
| 91 | - let c = ch as u32; | 97 | +pub fn glyphs(text: &str) -> Vec<String> { |
| 92 | - match c { | 98 | + let mut out: Vec<String> = Vec::new(); |
| 93 | - 0xFE00..=0xFE0F | 0x200D | 0x1F3FB..=0x1F3FF => 0, | 99 | + let mut joining = false; |
| 100 | + for ch in text.chars() { | ||
| 101 | + let continues = continues_glyph(ch); | ||
| 102 | + if (continues || joining) && !out.is_empty() { | ||
| 103 | + out.last_mut().expect("not empty").push(ch); | ||
| 104 | + } else { | ||
| 105 | + out.push(ch.to_string()); | ||
| 106 | + } | ||
| 107 | + // A joiner welds what follows it onto what came before. | ||
| 108 | + joining = ch as u32 == 0x200D; | ||
| 109 | + } | ||
| 110 | + out | ||
| 111 | +} | ||
| 112 | + | ||
| 113 | +/// How many columns one glyph takes on screen. | ||
| 114 | +/// | ||
| 115 | +/// Two for the emoji a terminal draws double width, one for everything else. | ||
| 116 | +/// A character out of the emoji blocks is drawn wide on its own; an older | ||
| 117 | +/// symbol or dingbat is drawn wide when it carries the selector that asks for | ||
| 118 | +/// the picture, and narrow — a character among characters — when it does not. | ||
| 119 | +/// This is the whole of what this backend knows about width, and it is enough | ||
| 120 | +/// for what a chat client puts on a screen: text, and the emoji in it. | ||
| 121 | +pub fn glyph_cols(glyph: &str) -> u16 { | ||
| 122 | + let mut chars = glyph.chars(); | ||
| 123 | + let Some(base) = chars.next() else { | ||
| 124 | + return 0; | ||
| 125 | + }; | ||
| 126 | + let emoji_presentation = glyph.chars().any(|c| c as u32 == 0xFE0F); | ||
| 127 | + match base as u32 { | ||
| 94 | 0x1F000.. => 2, | 128 | 0x1F000.. => 2, |
| 95 | - 0x2600..=0x27BF => 2, | 129 | + 0x2000..=0x2BFF if emoji_presentation => 2, |
| 96 | _ => 1, | 130 | _ => 1, |
| 97 | } | 131 | } |
| 98 | } | 132 | } |
| 99 | 133 | ||
| 100 | /// The columns `text` takes, the same way [`Screen::text`] spends them. | 134 | /// The columns `text` takes, the same way [`Screen::text`] spends them. |
| 101 | pub fn text_cols(text: &str) -> u16 { | 135 | pub fn text_cols(text: &str) -> u16 { |
| 102 | - text.chars().map(char_cols).sum::<u16>().max(0) | 136 | + glyphs(text).iter().map(|g| glyph_cols(g)).sum() |
| 103 | } | 137 | } |
| 104 | 138 | ||
| 105 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s | 139 | /// The attribute bits a cell can carry. A `u8` rather than a set of `bool`s |
| @@ -136,9 +170,14 @@ impl Style { | |||
| 136 | } | 170 | } |
| 137 | } | 171 | } |
| 138 | 172 | ||
| 139 | -#[derive(Clone, Copy, Debug, PartialEq, Eq)] | 173 | +#[derive(Clone, Debug, PartialEq, Eq)] |
| 140 | pub struct Cell { | 174 | pub struct Cell { |
| 141 | pub ch: char, | 175 | pub ch: char, |
| 176 | + /// The rest of the glyph, where it took more than one character: the | ||
| 177 | + /// selector that asks for a picture, the joiners in a family. Printed | ||
| 178 | + /// straight after `ch`, which is the only way a terminal draws them as | ||
| 179 | + /// the one glyph they are. | ||
| 180 | + pub tail: Option<Box<str>>, | ||
| 142 | pub style: Style, | 181 | pub style: Style, |
| 143 | /// The right half of a double-width character. It holds no character of | 182 | /// 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 | 183 | /// its own: the glyph in the cell to its left is drawn across both, and |
| @@ -150,6 +189,7 @@ impl Default for Cell { | |||
| 150 | fn default() -> Self { | 189 | fn default() -> Self { |
| 151 | Self { | 190 | Self { |
| 152 | ch: ' ', | 191 | ch: ' ', |
| 192 | + tail: None, | ||
| 153 | style: Style::default(), | 193 | style: Style::default(), |
| 154 | trail: false, | 194 | trail: false, |
| 155 | } | 195 | } |
| @@ -242,6 +282,7 @@ impl Screen { | |||
| 242 | let i = y as usize * self.w as usize + x as usize; | 282 | let i = y as usize * self.w as usize + x as usize; |
| 243 | self.cells[i] = Cell { | 283 | self.cells[i] = Cell { |
| 244 | ch, | 284 | ch, |
| 285 | + tail: None, | ||
| 245 | style, | 286 | style, |
| 246 | trail: false, | 287 | trail: false, |
| 247 | }; | 288 | }; |
| @@ -254,6 +295,7 @@ impl Screen { | |||
| 254 | let i = y as usize * self.w as usize + x as usize; | 295 | let i = y as usize * self.w as usize + x as usize; |
| 255 | self.cells[i] = Cell { | 296 | self.cells[i] = Cell { |
| 256 | ch: ' ', | 297 | ch: ' ', |
| 298 | + tail: None, | ||
| 257 | style, | 299 | style, |
| 258 | trail: true, | 300 | trail: true, |
| 259 | }; | 301 | }; |
| @@ -264,26 +306,27 @@ impl Screen { | |||
| 264 | /// columns were used. | 306 | /// columns were used. |
| 265 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { | 307 | pub fn text(&mut self, x: u16, y: u16, width: u16, text: &str, style: Style) -> u16 { |
| 266 | let mut col = 0u16; | 308 | let mut col = 0u16; |
| 267 | - for ch in text.chars() { | 309 | + for glyph in glyphs(text) { |
| 268 | if col >= width { | 310 | if col >= width { |
| 269 | break; | 311 | break; |
| 270 | } | 312 | } |
| 313 | + let mut chars = glyph.chars(); | ||
| 314 | + let ch = chars.next().unwrap_or(' '); | ||
| 271 | // A control character in a label would move the cursor; show it as | 315 | // A control character in a label would move the cursor; show it as |
| 272 | // a dot instead of letting it rearrange the screen. | 316 | // a dot instead of letting it rearrange the screen. |
| 273 | let ch = if (ch as u32) < 0x20 { '·' } else { ch }; | 317 | let ch = if (ch as u32) < 0x20 { '·' } else { ch }; |
| 274 | - let cols = char_cols(ch); | 318 | + let cols = glyph_cols(&glyph); |
| 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 { | 319 | if col + cols > width { |
| 282 | // Half of a wide glyph is a different character, so the last | 320 | // Half of a wide glyph is a different character, so the last |
| 283 | // column stays blank rather than showing one. | 321 | // column stays blank rather than showing one. |
| 284 | break; | 322 | break; |
| 285 | } | 323 | } |
| 286 | - self.set(x.saturating_add(col), y, ch, style); | 324 | + let at = x.saturating_add(col); |
| 325 | + self.set(at, y, ch, style); | ||
| 326 | + let rest: String = chars.collect(); | ||
| 327 | + if !rest.is_empty() { | ||
| 328 | + self.set_tail(at, y, rest); | ||
| 329 | + } | ||
| 287 | if cols == 2 { | 330 | if cols == 2 { |
| 288 | self.set_trail(x.saturating_add(col + 1), y, style); | 331 | self.set_trail(x.saturating_add(col + 1), y, style); |
| 289 | } | 332 | } |
| @@ -292,6 +335,25 @@ impl Screen { | |||
| 292 | col | 335 | col |
| 293 | } | 336 | } |
| 294 | 337 | ||
| 338 | + /// Put a whole cell down as it is — what a scroll does when it copies the | ||
| 339 | + /// visible window of its content across. Cell by character would drop the | ||
| 340 | + /// rest of a glyph and the right half of a wide one, which is a reply | ||
| 341 | + /// arrow painted as the small mono arrow and a row a column out. | ||
| 342 | + pub fn put(&mut self, x: u16, y: u16, cell: Cell) { | ||
| 343 | + if x < self.w && y < self.h { | ||
| 344 | + let i = y as usize * self.w as usize + x as usize; | ||
| 345 | + self.cells[i] = cell; | ||
| 346 | + } | ||
| 347 | + } | ||
| 348 | + | ||
| 349 | + /// The rest of a glyph, on the cell its first character went in. | ||
| 350 | + fn set_tail(&mut self, x: u16, y: u16, rest: String) { | ||
| 351 | + if x < self.w && y < self.h { | ||
| 352 | + let i = y as usize * self.w as usize + x as usize; | ||
| 353 | + self.cells[i].tail = Some(rest.into_boxed_str()); | ||
| 354 | + } | ||
| 355 | + } | ||
| 356 | + | ||
| 295 | /// Paint every cell of `rect` with `style`, keeping the characters — that | 357 | /// Paint every cell of `rect` with `style`, keeping the characters — that |
| 296 | /// is what a background is: a colour behind whatever is already there. | 358 | /// is what a background is: a colour behind whatever is already there. |
| 297 | pub fn fill(&mut self, rect: Rect, style: Style) { | 359 | pub fn fill(&mut self, rect: Rect, style: Style) { |
| @@ -316,11 +378,16 @@ impl Screen { | |||
| 316 | let start = y as usize * self.w as usize; | 378 | let start = y as usize * self.w as usize; |
| 317 | // Without the trailing halves: they hold no character, and a reader — | 379 | // Without the trailing halves: they hold no character, and a reader — |
| 318 | // a test, a bug report — wants the line as it looks. | 380 | // a test, a bug report — wants the line as it looks. |
| 319 | - let row: String = self.cells[start..start + self.w as usize] | 381 | + let mut row = String::new(); |
| 320 | - .iter() | 382 | + for cell in self.cells[start..start + self.w as usize].iter() { |
| 321 | - .filter(|c| !c.trail) | 383 | + if cell.trail { |
| 322 | - .map(|c| c.ch) | 384 | + continue; |
| 323 | - .collect(); | 385 | + } |
| 386 | + row.push(cell.ch); | ||
| 387 | + if let Some(tail) = &cell.tail { | ||
| 388 | + row.push_str(tail); | ||
| 389 | + } | ||
| 390 | + } | ||
| 324 | row.trim_end().to_owned() | 391 | row.trim_end().to_owned() |
| 325 | } | 392 | } |
| 326 | 393 | ||
modified
crates/jolt-tui/src/term.rs +9 -2 | @@ -156,8 +156,15 @@ impl Term { | ||
| 156 | 156 | write_style(&mut self.out, cell.style)?; |
| 157 | 157 | style = Some(cell.style); |
| 158 | 158 | } |
| 159 | - queue!(self.out, style::Print(cell.ch))?; | |
| 160 | - at = Some((x + screen::char_cols(cell.ch), y)); | |
| 159 | + // The whole glyph, first character and rest: a terminal handed an | |
| 160 | + // arrow without the selector that follows it draws the small mono | |
| 161 | + // arrow rather than the emoji. | |
| 162 | + let mut glyph = cell.ch.to_string(); | |
| 163 | + if let Some(tail) = &cell.tail { | |
| 164 | + glyph.push_str(tail); | |
| 165 | + } | |
| 166 | + queue!(self.out, style::Print(&glyph))?; | |
| 167 | + at = Some((x + screen::glyph_cols(&glyph), y)); | |
| 161 | 168 | } |
| 162 | 169 | queue!(self.out, style::ResetColor)?; |
| 163 | 170 | match cursor { |
| @@ -156,8 +156,15 @@ impl Term { | |||
| 156 | write_style(&mut self.out, cell.style)?; | 156 | write_style(&mut self.out, cell.style)?; |
| 157 | style = Some(cell.style); | 157 | style = Some(cell.style); |
| 158 | } | 158 | } |
| 159 | - queue!(self.out, style::Print(cell.ch))?; | 159 | + // The whole glyph, first character and rest: a terminal handed an |
| 160 | - at = Some((x + screen::char_cols(cell.ch), y)); | 160 | + // arrow without the selector that follows it draws the small mono |
| 161 | + // arrow rather than the emoji. | ||
| 162 | + let mut glyph = cell.ch.to_string(); | ||
| 163 | + if let Some(tail) = &cell.tail { | ||
| 164 | + glyph.push_str(tail); | ||
| 165 | + } | ||
| 166 | + queue!(self.out, style::Print(&glyph))?; | ||
| 167 | + at = Some((x + screen::glyph_cols(&glyph), y)); | ||
| 161 | } | 168 | } |
| 162 | queue!(self.out, style::ResetColor)?; | 169 | queue!(self.out, style::ResetColor)?; |
| 163 | match cursor { | 170 | match cursor { |
modified
crates/jolt-tui/src/tests.rs +13 -0 | @@ -425,6 +425,19 @@ fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() { | ||
| 425 | 425 | ); |
| 426 | 426 | } |
| 427 | 427 | |
| 428 | +#[test] | |
| 429 | +fn a_glyph_keeps_the_selector_that_says_to_draw_it_as_a_picture() { | |
| 430 | + let mut ui = Ui::new(10, 2); | |
| 431 | + let root = ui.tree.root(); | |
| 432 | + // The reply chip: an arrow, and a selector asking for the emoji rather | |
| 433 | + // than the small mono arrow a terminal draws without it. | |
| 434 | + node(&mut ui, root, "reaction", &[("emoji", "↩️")]); | |
| 435 | + ui.frame(); | |
| 436 | + assert_eq!(ui.screen.line(0), "↩\u{fe0f}"); | |
| 437 | + // And it is two columns, like the emoji beside it on the row. | |
| 438 | + assert_eq!(ui.screen.cell(1, 0).map(|c| c.trail), Some(true)); | |
| 439 | +} | |
| 440 | + | |
| 428 | 441 | #[test] |
| 429 | 442 | fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { |
| 430 | 443 | let mut ui = Ui::new(12, 3); |
| @@ -425,6 +425,19 @@ fn a_reaction_paints_its_glyph_and_answers_a_click_on_it() { | |||
| 425 | ); | 425 | ); |
| 426 | } | 426 | } |
| 427 | 427 | ||
| 428 | +#[test] | ||
| 429 | +fn a_glyph_keeps_the_selector_that_says_to_draw_it_as_a_picture() { | ||
| 430 | + let mut ui = Ui::new(10, 2); | ||
| 431 | + let root = ui.tree.root(); | ||
| 432 | + // The reply chip: an arrow, and a selector asking for the emoji rather | ||
| 433 | + // than the small mono arrow a terminal draws without it. | ||
| 434 | + node(&mut ui, root, "reaction", &[("emoji", "↩️")]); | ||
| 435 | + ui.frame(); | ||
| 436 | + assert_eq!(ui.screen.line(0), "↩\u{fe0f}"); | ||
| 437 | + // And it is two columns, like the emoji beside it on the row. | ||
| 438 | + assert_eq!(ui.screen.cell(1, 0).map(|c| c.trail), Some(true)); | ||
| 439 | +} | ||
| 440 | + | ||
| 428 | #[test] | 441 | #[test] |
| 429 | fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { | 442 | fn an_emoji_is_two_columns_wide_and_what_follows_it_knows_that() { |
| 430 | let mut ui = Ui::new(12, 3); | 443 | let mut ui = Ui::new(12, 3); |