feat: add glyphs
This commit is contained in:
341
display/src/glyphs.rs
Normal file
341
display/src/glyphs.rs
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user