Skip to content

icy_engine/icy_parser_core: embedder and WASM API (optional fonts, active hyperlink, scroll dirty lines, parse pause) - #198

Merged
mkrueger merged 4 commits into
mkrueger:masterfrom
NuSkooler:pr/embedder-api
Oct 27, 2026
Merged

mkrueger merged 4 commits into
mkrueger:masterfrom
NuSkooler:pr/embedder-api

Conversation

@NuSkooler

Copy link
Copy Markdown
Contributor

This PR adds a few small APIs for embedding icy_engine outside icy_term. They come from enig-term, a browser terminal that compiles icy_engine to WASM and today carries local patches or workarounds for each of them. Every change is additive. The defaults keep icy_term's behaviour as it is, and each commit has its own test. These are your API decisions, so the choices are explained below and are easy to change.

Add an embedded-fonts feature and install_ansi_font to icy_engine

A new cargo feature, embedded-fonts, is on by default. The font files under data/fonts are now included through two crate-internal macros, font_bytes! and font_str!. With the feature on they are include_bytes! and include_str!. With it off they expand to empty data.

With the feature off, nothing panics and the lookups report the font as missing:

  • from_ansi_font_page returns None, from_sauce_name returns Err, and the Amiga font lookups return None;
  • the font statics (C64, Atari, Viewdata, IGS, RIP, SkyPix) and BitFont::default() fall back to a blank 8x16 font, or to the font installed in slot 0;
  • ansi_v2's font map skips slots that have no font instead of unwrapping.

fonts::install_ansi_font(slot, font) fills an ANSI slot at run time, for the font's own height (8, 14 or 16). from_ansi_font_page, and so CSI Ps1 ; Ps2 SP D, then finds it. It shares the existing lazy OnceLock cache, so a slot and height can be set once. With embedded fonts on it works only before that slot is first used.

The BGI .CHR stroke fonts stay embedded. RIP needs them, the host has no other way to supply them, and they are about 120 KB.

With the feature off, cargo build -p icy_engine --no-default-features --target wasm32-unknown-unknown builds. In enig-term this cuts the gzipped WASM from 1.93 MB to 1.60 MB, and it replaces our patch that shadowed include_bytes! crate-wide.

Tests: fonts::embedded_fonts_tests. lookups_without_embedded_fonts runs only with the feature off (cargo test -p icy_engine --no-default-features --lib embedded_fonts). It checks every SAUCE name and every slot at every height, the statics, create_screen for the screen modes that load fonts, and installing a slot font, including the fallback to an installed font of another height. install_ansi_font_rejects_bad_slot_and_height runs in both configurations. install_ansi_font_fills_a_slot_height_once runs with the feature on: it fills a height that was never loaded, leaves the embedded heights alone, and refuses a second install or one for a height that is already loaded. With the feature off, 9 existing lib tests fail because they assert things about the real fonts (for example the C64 fonts' 8x8 size). They could be gated on the feature if CI ever builds without it.

Add TerminalState::active_hyperlink

active_hyperlink() returns the URI and start position of the open OSC 8 link, or None. An embedder that tags cells with links as they are written, for its own scrollback, needs this. The doc comment also notes that the finished links stored on the screen are screen-relative and do not move when the screen scrolls.

Test: test_active_osc8_hyperlink_is_readable in tests/text_screen.rs.

Mark scrolled lines dirty and move whole lines on full-width scrolls

TextScreen::scroll_up and scroll_down now mark the scroll region dirty, and so do insert_terminal_line and remove_terminal_line (CSI L, CSI M) for the lines they move. Before, get_dirty_lines() returned None after CSI S, CSI T, CSI L, CSI M or a line feed on the bottom row, so a renderer that redraws only the dirty lines missed the scroll.

When the region spans the whole layer, they now rotate the Line slice and blank the freed row instead of copying cell by cell. Layer::scroll_full_width_rows (pub(crate)) takes this path only when the result is the same as the cell copy: the layer is not locked or hidden, the alpha channel is not locked, the rows have no graphemes or sixels, and every row is exactly one layer wide. Otherwise it returns false and the cell copy runs as before. In enig-term's scrolling benchmark, moving lines was about 9x faster.

Tests, in text_screen.rs's dirty_line_tests:

  • scrolling_marks_the_scroll_region_dirty fails without the change, and test_scroll_sequences_mark_the_scrolled_lines_dirty (in tests/text_screen.rs) checks CSI S, CSI T, CSI L, CSI M, a line feed and ESC D through the parser;
  • full_width_scroll_moves_lines_with_the_cell_copy_result checks that the line move is taken and that the rows and the blank attribute match the cell copy;
  • full_width_scroll_keeps_every_attribute_like_the_cell_copy scrolls cells with differing colours, blink and RGB, with and without top/bottom margins, and compares the whole layer with a cell-by-cell copy;
  • line_move_declines_what_the_cell_copy_handles_differently checks the fallback cases.

Let the sink stop the ANSI parse after a blocking construct

Foreground ANSI music and APC SyncTERM:A;Wait hold the stream until the audio is done, but parse() consumes the whole buffer. There are two new provided methods:

  • CommandSink::should_pause(&mut self) -> bool defaults to false. AnsiParser calls it right after play_music and aps;
  • CommandParser::parse_until_pause(&mut self, input, sink) -> usize works like parse and returns the bytes consumed. It stops after the construct when the sink returns true. The caller feeds &input[n..] later. The default implementation calls parse and returns input.len().

parse() itself never pauses and never calls should_pause, so existing callers, sinks and parsers behave as before. A music string or APC inside a CTerm macro does not pause, because the macro is parsed from stored bytes and not from the caller's input.

I picked this shape because it is additive. Changing play_music or aps to return a value would break every sink. A flag checked after every event would add a virtual call per event for a case that only these two constructs need.

Tests: tests/ansi/pause.rs. One test pauses after the music and after the Wait APC, does not pause after another APC, and resumes to the same events. Another checks that parse() ignores pause requests and gives the same event stream. A third uses a sink that pauses after every construct and feeds the rest each time, for several inputs (back to back constructs, one as the last byte, an ESC inside an APC string, an unfinished sequence at the end), and compares the events with a single parse().

Deferred

Cell size per font (issue draft 17)

The renderer takes the cell size from the screen (slot 0), and font_for_render draws any slot's glyphs into that cell. A DCS-loaded 8x8 font, or an 8x14 font selected over 8x16, is drawn at the wrong size. There are two options:

  • Check at selection time, as SyncTERM does. In CsiFontSelection, if the slot's font in font_table has a height other than font_dimensions().height, set FontSelectionState::Failure and keep the current font. from_ansi_font_page already picks the variant for the cell height, so only DCS-loaded and host-installed fonts are affected. This is small, but it changes what icy_term does with a mismatched DCS font today, so it may want an opt-in flag on TerminalState.
  • Scale at render time. The three font_for_render sites in buffers_rendering.rs (and the GPU path in icy_engine_gui) would scale each glyph from its font's size to the cell size. This keeps every font usable, but it is a larger change to the hot rendering loops and the shaders.

enig-term does the first one outside the engine today.

iCE colours applied at render time (issue draft 09)

ScreenSink::display_attribute rewrites blink into a bright background when a character is written, so CSI ? 33 h/l does not change cells already on screen. A render-time version would:

  • store the blink bit as sent and drop the rewrite in display_attribute;
  • in the renderers (the three blink checks in buffers_rendering.rs, and the shader inputs in icy_engine_gui), when ice_colors is set, treat blink as background index + 8 for palette backgrounds below 8, and never blink;
  • make TextAttribute::as_u8 and the savers take the mode from the screen at save time, which they mostly already do through IceMode.

The risk is in the files and tests that expect the rewritten attribute, such as ice_rgb_background_cell_renders_foreground. It also needs a decision on how ice_colors (terminal state) and IceMode (buffer) relate, so this seems better done by you or after we talk.

API choices you may want to change

  • I used a feature named embedded-fonts, on by default, rather than a runtime switch. The macros also mean every font file is still named in the source, so nothing moves.
  • install_ansi_font is a process-wide install into the existing OnceLock cache. It is set once and is not per screen. A trait-based font provider consulted by get_ansi_font and load_sauce_font, or a replaceable cache, would allow lazy loading and replacement, but the lookups return &'static BitFont, so that needs a cache redesign. There is no SAUCE-name install yet. An embedder can load those with BitFont::from_bytes and set_font.
  • With fonts off, BitFont::default() returns the font installed in slot 0 at 16px, or a blank 8x16 font. A blank default never panics, but it draws nothing until the host supplies a font.
  • The BGI stroke fonts stay embedded with the feature off. They could get their own feature if you prefer.
  • active_hyperlink() returns Option<(&str, Position)>. It could instead return a small struct or the HyperLink type. The draft also asked whether the stored links should move on scroll. I only documented that they don't.
  • The scroll fast path is limited to TextScreen's non-unicode path and to full-width regions. scroll_left/scroll_right and the Amiga and palette screens are unchanged.
  • The pause hook is should_pause(&mut self), asked only after play_music and aps. If you would rather the sink can stop after any event (a DCS font load, a sixel), the same check could move to every emit, at the cost of a call per event. AvatarParser, PcBoardParser and the other wrappers around AnsiParser use the default and don't pause yet.

The bitmap fonts under data/fonts are compiled in through font_bytes!/
font_str!, which expand to empty data when the new embedded-fonts
feature (on by default) is off. The lookups then report those fonts as
missing instead of panicking: from_ansi_font_page returns None,
from_sauce_name returns Err, the Amiga fonts return None, and the font
statics and BitFont::default() fall back to a blank 8x16 font.

install_ansi_font(slot, font) fills an ANSI slot at run time, so an
embedder that ships its own fonts can make CSI font selection work.
The BGI stroke fonts stay embedded; RIP needs them and they are small.
The open OSC 8 link was pub(crate), so an embedder that tags cells with
links as they are written (for its scrollback, say) couldn't see it.
The getter returns the URI and the start position. Its doc notes that
the finished links on the screen are screen-relative and don't move on
scroll.
TextScreen::scroll_up and scroll_down changed every row of the scroll
region without marking it dirty, so a renderer that redraws only the
dirty lines missed CSI S/T and a line feed on the bottom row. Insert
and delete line (CSI L, CSI M) had the same gap. All four now mark the
lines they change.

When the region spans the whole layer, scroll_up and scroll_down rotate
the Line slice and blank the freed row instead of copying cell by cell.
The line move is taken only when it gives the same result as the cell
copy (no locked layer or alpha channel, no graphemes or sixels in the
rows, every row a full line); otherwise the cell copy runs as before.
Foreground ANSI music and APC SyncTERM:A;Wait hold the stream until
the audio is done, but parse() always consumes the whole buffer, so an
embedder that feeds large chunks had to find those points itself.

CommandParser::parse_until_pause works like parse() but returns the
number of bytes consumed. AnsiParser asks CommandSink::should_pause
right after play_music and aps, and stops there when it returns true;
the caller passes the rest of the input later. Both new methods have
defaults (never pause, consume everything), so parse() and existing
sinks and parsers behave as before.
@mkrueger
mkrueger merged commit c34a9a5 into mkrueger:master Oct 27, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants