diff --git a/display/src/glyphs.rs b/display/src/glyphs.rs new file mode 100644 index 0000000..73f6b0e --- /dev/null +++ b/display/src/glyphs.rs @@ -0,0 +1,341 @@ +//! Extra glyphs the ISO-8859-15 render font lacks. +//! +//! The buffer is UTF-8, so it can hold codepoints outside Latin-9. The +//! `embedded-graphics` `iso_8859_15` font draws those as a fallback box. This +//! module carries hand-authored 10×20 bitmaps for a curated set of common +//! single-width characters — typographic punctuation that arrives in imported +//! prose (curly quotes, en/em dashes, ellipsis, bullet) and the math symbols the +//! user's Markdown snippets insert (→ ≠ Σ). [`Editor::draw`] overlays these over +//! the fallback box after the font has laid out the line. +//! +//! Every glyph is exactly one 10×20 cell (matching the body `FONT_10X20`, i.e. +//! `editor::CW`×`editor::CH`), so the editor's 1-char = 1-cell layout invariant +//! is untouched. A row is a 10-bit pattern: the leftmost column (x = 0) is the +//! high bit, so a binary literal reads left-to-right as the pixels of that row. +//! Rows are top (y = 0) to bottom (y = 19); 1 = ink. + +use crate::Frame; +use embedded_graphics::pixelcolor::BinaryColor; +use embedded_graphics::prelude::*; + +const GLYPH_W: usize = 10; +const GLYPH_H: usize = 20; + +/// A 10×20 1-bit glyph: one `u16` per row, low `GLYPH_W` bits used, high bit = +/// leftmost pixel. +pub type Glyph = [u16; GLYPH_H]; + +// → U+2192 RIGHTWARDS ARROW: a horizontal shaft with a `>` head at the right. +#[rustfmt::skip] +const ARROW_RIGHT: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000010000, + 0b0000001000, + 0b0000000100, + 0b0000000010, + 0b0111111111, + 0b0111111111, + 0b0000000010, + 0b0000000100, + 0b0000001000, + 0b0000010000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// ≠ U+2260 NOT EQUAL TO: two equals bars with a slash through them. +#[rustfmt::skip] +const NOT_EQUAL: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000010, + 0b0000000100, + 0b0000000100, + 0b0000001000, + 0b0111111110, + 0b0000010000, + 0b0000010000, + 0b0000100000, + 0b0111111110, + 0b0001000000, + 0b0001000000, + 0b0010000000, + 0b0010000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// Σ U+03A3 GREEK CAPITAL LETTER SIGMA: top & bottom bars with the diagonals +// meeting at a vertex on the centre-right. +#[rustfmt::skip] +const SIGMA: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0111111110, + 0b0100000000, + 0b0010000000, + 0b0001000000, + 0b0000100000, + 0b0000010000, + 0b0000100000, + 0b0001000000, + 0b0010000000, + 0b0100000000, + 0b0100000000, + 0b0111111110, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// • U+2022 BULLET: a filled dot at mid-height. +#[rustfmt::skip] +const BULLET: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0001111000, + 0b0011111100, + 0b0011111100, + 0b0011111100, + 0b0001111000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// … U+2026 HORIZONTAL ELLIPSIS: three dots on the baseline. +#[rustfmt::skip] +const ELLIPSIS: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0110110110, + 0b0110110110, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// – U+2013 EN DASH: a short mid-height bar. +#[rustfmt::skip] +const EN_DASH: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0011111100, + 0b0011111100, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// — U+2014 EM DASH: a full-width mid-height bar. +#[rustfmt::skip] +const EM_DASH: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b1111111111, + 0b1111111111, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// ' U+2018 LEFT SINGLE QUOTATION MARK: raised, tail up (opening). +#[rustfmt::skip] +const LEFT_SINGLE_QUOTE: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0000100000, + 0b0001100000, + 0b0001100000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// ' U+2019 RIGHT SINGLE QUOTATION MARK / apostrophe: raised, tail down (closing). +#[rustfmt::skip] +const RIGHT_SINGLE_QUOTE: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0001100000, + 0b0001100000, + 0b0000100000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// " U+201C LEFT DOUBLE QUOTATION MARK: two opening quotes. +#[rustfmt::skip] +const LEFT_DOUBLE_QUOTE: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0010010000, + 0b0011011000, + 0b0011011000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +// " U+201D RIGHT DOUBLE QUOTATION MARK: two closing quotes. +#[rustfmt::skip] +const RIGHT_DOUBLE_QUOTE: Glyph = [ + 0b0000000000, + 0b0000000000, + 0b0011011000, + 0b0011011000, + 0b0010010000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, + 0b0000000000, +]; + +/// The 10×20 bitmap for a character the ISO-8859-15 font can't draw, or `None` +/// if the base font already covers it (all ASCII and Latin-9, including `œ €`). +pub fn extra_glyph(c: char) -> Option<&'static Glyph> { + Some(match c { + '\u{2192}' => &ARROW_RIGHT, // → + '\u{2260}' => &NOT_EQUAL, // ≠ + '\u{03A3}' => &SIGMA, // Σ + '\u{2022}' => &BULLET, // • + '\u{2026}' => &ELLIPSIS, // … + '\u{2013}' => &EN_DASH, // – + '\u{2014}' => &EM_DASH, // — + '\u{2018}' => &LEFT_SINGLE_QUOTE, // ' + '\u{2019}' => &RIGHT_SINGLE_QUOTE, // ' + '\u{201C}' => &LEFT_DOUBLE_QUOTE, // " + '\u{201D}' => &RIGHT_DOUBLE_QUOTE, // " + _ => return None, + }) +} + +/// Paint a glyph over one 10×20 cell at `(x, y)`. Fills the whole cell — set +/// bits in `ink`, unset bits in `ink.invert()` — so it fully overwrites whatever +/// the base font drew there (e.g. the fallback box). Pass `ink = On` for a +/// normal black-on-white cell, `ink = Off` for a reverse-video cell (a white +/// glyph over the black selection/caret fill). +pub fn blit_glyph(f: &mut Frame, x: i32, y: i32, g: &Glyph, ink: BinaryColor) { + let bg = ink.invert(); + let pixels = g.iter().enumerate().flat_map(move |(row, &bits)| { + (0..GLYPH_W).map(move |c| { + let on = (bits >> (GLYPH_W - 1 - c)) & 1 == 1; + Pixel( + Point::new(x + c as i32, y + row as i32), + if on { ink } else { bg }, + ) + }) + }); + // Frame's DrawTarget error is Infallible. + let _ = f.draw_iter(pixels); +} diff --git a/display/src/lib.rs b/display/src/lib.rs index 269a36b..ad5ae0f 100644 --- a/display/src/lib.rs +++ b/display/src/lib.rs @@ -13,6 +13,9 @@ use embedded_graphics::prelude::*; use embedded_graphics::primitives::{Circle, PrimitiveStyleBuilder}; use embedded_graphics::text::{Alignment, Baseline, Text, TextStyleBuilder}; +mod glyphs; +pub use glyphs::{blit_glyph, extra_glyph, Glyph}; + pub const WIDTH: u16 = 792; pub const HEIGHT: u16 = 272; diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 4dbf9eb..26d43d5 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -20,7 +20,7 @@ use embedded_graphics::prelude::*; use embedded_graphics::primitives::{PrimitiveStyle, Rectangle}; use embedded_graphics::text::{Baseline, Text}; -use display::{Frame, HEIGHT, WIDTH}; +use display::{blit_glyph, extra_glyph, Frame, HEIGHT, WIDTH}; use keymap::Key; /// FONT_10X20 cell size (writing column) and the grid it tiles into. @@ -3109,6 +3109,34 @@ impl Editor { (1..=6).contains(&hashes) && b.get(i) == Some(&b' ') } + /// Paint the non-Latin-9 glyphs (`→ ≠ Σ … – — ' ' " " •`) that the base font + /// can't draw, overlaying them onto the cells `embedded-graphics` just filled + /// with fallback boxes. `line` is a laid-out display line; `x0` is its + /// x-origin (`gx`, or `gx + 1` for the heading double-strike). Only the + /// half-open display-column range `[col_start, col_end)` is painted, so the + /// visual-selection pass can invert just the selected span. `ink = On` draws + /// black-on-white; `ink = Off` draws white-on-black for reverse-video cells. + /// Because every extra glyph is one cell wide (like the font), cell x is + /// `x0 + col * CW` — the same grid the layout and caret math already use. + fn overlay_extras( + f: &mut Frame, + line: &str, + x0: i32, + y: i32, + col_start: usize, + col_end: usize, + ink: BinaryColor, + ) { + for (col, ch) in line.chars().enumerate() { + if col < col_start || col >= col_end { + continue; + } + if let Some(g) = extra_glyph(ch) { + blit_glyph(f, x0 + col as i32 * CW, y, g, ink); + } + } + } + /// Render the current state into a frame. `cursor_on` gates the caret: the /// Insert bar caret is suppressed while typing and shown after a pause, and /// `false` also suppresses the Normal block caret so callers can render pure @@ -3156,11 +3184,18 @@ impl Editor { // Markdown heading (`#`..`######` + space): faux-bold by double- // striking the whole display line 1px to the right (no bold Latin-9 // font exists). Checks the logical line so wrapped headings stay bold. - if self.is_heading_at(self.line_start(lay[li].start)) { + let heading = self.is_heading_at(self.line_start(lay[li].start)); + if heading { Text::with_baseline(&lay[li].text, Point::new(gx + 1, y), text_style, Baseline::Top) .draw(&mut f) .unwrap(); } + // Repaint any non-Latin-9 glyphs over the fallback boxes the font + // left. Double-struck at gx+1 too on headings, to stay faux-bold. + Self::overlay_extras(&mut f, &lay[li].text, gx, y, 0, usize::MAX, BinaryColor::On); + if heading { + Self::overlay_extras(&mut f, &lay[li].text, gx + 1, y, 0, usize::MAX, BinaryColor::On); + } } // Visual selection: reverse-video the selected cells (black fill, glyphs @@ -3196,6 +3231,8 @@ impl Editor { .draw(&mut f) .unwrap(); } + // Any extra glyphs in the selected span: repaint white-on-black. + Self::overlay_extras(&mut f, &lay[li].text, gx, y, col_a, col_b, BinaryColor::Off); } } @@ -3210,16 +3247,20 @@ impl Editor { .draw(&mut f) .unwrap(); if let Some(ch) = lay[crow].text.chars().nth(ccol) { - let mut buf = [0u8; 4]; - let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off); - Text::with_baseline( - ch.encode_utf8(&mut buf), - Point::new(x, y), - inv, - Baseline::Top, - ) - .draw(&mut f) - .unwrap(); + if let Some(g) = extra_glyph(ch) { + blit_glyph(&mut f, x, y, g, BinaryColor::Off); + } else { + let mut buf = [0u8; 4]; + let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off); + Text::with_baseline( + ch.encode_utf8(&mut buf), + Point::new(x, y), + inv, + Baseline::Top, + ) + .draw(&mut f) + .unwrap(); + } } } Mode::Insert if cursor_on => { @@ -3238,15 +3279,19 @@ impl Editor { .draw(&mut f) .unwrap(); if let Some(ch) = lay[crow].text.chars().nth(ccol) { - let mut buf = [0u8; 4]; - Text::with_baseline( - ch.encode_utf8(&mut buf), - Point::new(x, y), - text_style, - Baseline::Top, - ) - .draw(&mut f) - .unwrap(); + if let Some(g) = extra_glyph(ch) { + blit_glyph(&mut f, x, y, g, BinaryColor::On); + } else { + let mut buf = [0u8; 4]; + Text::with_baseline( + ch.encode_utf8(&mut buf), + Point::new(x, y), + text_style, + Baseline::Top, + ) + .draw(&mut f) + .unwrap(); + } } } _ => {} @@ -3950,6 +3995,63 @@ mod tests { assert_eq!(frame.bytes().len(), display::FB_BYTES); } + #[test] + fn extra_glyph_covers_snippet_and_prose_symbols() { + // The curated non-Latin-9 set the overlay draws (→ ≠ Σ … – — ' ' " " •). + let targets = [ + '\u{2192}', '\u{2260}', '\u{03A3}', '\u{2026}', '\u{2013}', '\u{2014}', '\u{2018}', + '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', + ]; + for c in targets { + assert!(extra_glyph(c).is_some(), "missing glyph for U+{:04X}", c as u32); + } + // The base font already draws these — the overlay must defer to it, + // including œ/€ (which *are* in ISO-8859-15, at 0xBD/0xA4). + for c in ['a', 'é', 'œ', '€', ' ', '#', '-'] { + assert!(extra_glyph(c).is_none(), "should defer to base font: {c}"); + } + } + + #[test] + fn draw_runs_for_symbol_buffer() { + // Insert the whole extra set and render with a caret — no panic, right size. + let mut e = typed("\u{2192} \u{2260} \u{03A3} \u{2026} \u{2013} \u{2014} \u{2018}x\u{2019} \u{201C}y\u{201D} \u{2022}"); + let frame = e.draw(true); + assert_eq!(frame.bytes().len(), display::FB_BYTES); + assert!(e.text.is_char_boundary(e.caret)); + } + + /// 1 = white paper, 0 = black ink (SSD16xx convention). Reads one pixel. + fn ink_at(frame: &Frame, x: usize, y: usize) -> bool { + frame.bytes()[y * display::FB_BYTES_W + x / 8] & (0x80 >> (x % 8)) == 0 + } + + #[test] + fn overlay_paints_extra_glyph_over_fallback_box() { + // The em dash is two solid mid-height bars and nothing else; a fallback + // box would ink the cell's top row. Gutter off so it lands in column 0. + let mut e = Editor::with_text("\u{2014}".into()); // — + e.prefs.line_numbers = false; + let f = e.draw(false); // no caret + assert!((0..10).all(|x| !ink_at(&f, x, 0)), "cell top row must be blank"); + assert!((0..10).all(|x| ink_at(&f, x, 9)), "row 9 must be solid ink"); + assert!((0..10).all(|x| ink_at(&f, x, 10)), "row 10 must be solid ink"); + } + + #[test] + fn overlay_inverts_extra_glyph_under_selection() { + // Same em dash, but selected: reverse-video flips the cell — the fill + // goes black and the dash bars punch back to white paper. + let mut e = Editor::with_text("\u{2014}x".into()); + e.prefs.line_numbers = false; + e.handle(Key::Char('0')); // to column 0 (the em dash) + e.handle(Key::Char('v')); // charwise Visual selects the char under the caret + let f = e.draw(false); // no active-end caret punch, just the selection + assert!((0..10).all(|x| ink_at(&f, x, 0)), "selected cell top row must be inked"); + assert!((0..10).all(|x| !ink_at(&f, x, 9)), "row 9 dash must punch to white"); + assert!((0..10).all(|x| !ink_at(&f, x, 10)), "row 10 dash must punch to white"); + } + #[test] fn w_command_signals_save_and_returns_to_normal() { let (e, effs) = command("w");