feat: add glyphs

This commit is contained in:
Julien Calixte
2026-07-12 10:43:41 +02:00
parent 56f5d18bd8
commit 45664fc956
3 changed files with 467 additions and 21 deletions

341
display/src/glyphs.rs Normal file
View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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");