Skip to content

fix: 431 always-bundled emoji font + 453 pane-divider phantom selection - #456

Merged
fredclausen merged 4 commits into
mainfrom
task-431-453/emoji-font-and-resize-artifact
Jul 27, 2026
Merged

fix: 431 always-bundled emoji font + 453 pane-divider phantom selection#456
fredclausen merged 4 commits into
mainfrom
task-431-453/emoji-font-and-resize-artifact

Conversation

@fredclausen

@fredclausen fredclausen commented Jul 26, 2026

Copy link
Copy Markdown
Member

Two related-in-scope-only bug fixes, one atomic commit each.

Closes #431 — always use the bundled emoji font (e5bafe12)

Freminal ranked system-installed emoji fonts (by name priority, then by
scanning each font's cmap for emoji-block coverage) and only fell back
to the bundled Noto Color Emoji when nothing capable was found. But a
system font's aggregate coverage is not a stable guarantee that it maps
any particular codepoint: a font could win the ranking and still render
tofu for a codepoint it happens not to carry. Observed concretely with a
starship prompt's NixOS snowflake glyph (U+2744) — and two
"Noto Color Emoji"-named builds on one machine were measured to differ by
9 codepoints under the identical family name.

Now the terminal grid always uses the bundled res/NotoColorEmoji.ttf:
the set of codepoints that render is fixed and known at build time,
independent of the host. Removes the dead ranking machinery
(EMOJI_CANDIDATES, best_system_emoji_source, resolve_emoji_source,
EMOJI_SOURCE_CACHE, EmojiSource, load_emoji_face_from_source);
has_color_glyphs / emoji_coverage / EMOJI_BLOCKS survive only as
#[cfg(test)] regression helpers. The egui chrome path (fonts.rs)
is deliberately untouched (its grayscale atlas can't render colour glyphs
so it keeps its own monochrome fallback list). Regression test asserts
the bundled face maps U+2744 to a real colour glyph.

Closes #453 — pane-divider drag painted a phantom selection (95a9182b)

Dragging a mux pane divider flashed inverted (selection-coloured) pixels
on a row of the adjacent pane. Root-caused via frame-by-frame diagnostic
logging (after a bisect proved noisy and an initial GPU-path theory was
disproved): the invisible border-drag sensor sits inside the adjacent
pane's terminal_rect, so the same press+drag that resizes the split
also starts and extends a real text selection there.

Fix threads a border_drag_active flag into the terminal widget, folds it
into input suppression, and fully clears the phantom selection during a
drag. Plus, from review + user request:

  • Focus freeze/restore: focus-follows-mouse is frozen during a divider
    drag (no active-pane flicker between panes); on release, the pane under
    the pointer becomes active.
  • Stuck-state defense: win.border_drag is force-cleared any frame the
    primary button isn't held, so an overlay opening mid-drag can't strand it
    Some and freeze all input.
  • One-frame release tail so the drag-release click can't leak into the
    terminal (matters for mouse-tracking PTY apps).

Verification

  • cargo test --all — all pass (new tests for emoji bundling, pane_at_pos
    hit-test, border-drag suppression/clear, one-frame release tail)
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cargo machete — clean
  • cargo xtask check-windows — clean
  • Both fixes manually verified against a live build by the maintainer.

Summary by CodeRabbit

  • Bug Fixes

    • Improved pane divider resizing to prevent focus flicker and ensure keyboard focus follows the pane under the pointer when dragging ends.
    • Prevented input from getting stuck during divider drag edge cases.
    • Improved selection behavior during resizing (including clearing phantom selections and blocking stray keystrokes).
  • Improvements

    • Emoji rendering is now consistently driven by the bundled Noto Color Emoji font for more predictable cross-system output.
    • Cursor-to-pane detection was refined to correctly identify the pane under the pointer (or none when outside the layout).
  • Documentation

    • Updated attribution/usage notes for the bundled emoji font.

Freminal ranked system-installed emoji fonts by name priority
(EMOJI_CANDIDATES) and, failing that, by scanning every font's cmap for
emoji-block coverage, only falling back to the bundled Noto Color Emoji
when nothing capable was found. But a system font's aggregate coverage
count is not a stable guarantee that it maps any *particular* codepoint
the terminal needs: a system font could win the ranking and still render
tofu for a codepoint it happens not to carry. Concretely, a user's
starship prompt showed the NixOS snowflake glyph (U+2744) as tofu because
the ranked system "Noto Color Emoji" build was an older/narrower snapshot
that had dropped the codepoint -- two "Noto Color Emoji"-named builds on
one machine were observed to differ by 9 codepoints under the identical
family name.

Always use the bundled Noto Color Emoji face for the terminal grid. The
set of codepoints that render correctly is now fixed and known at build
time, independent of what is (or isn't) installed on the host, and the
bundled face can be refreshed/replaced centrally if a gap ever appears.

Removes the now-dead ranking machinery (EMOJI_CANDIDATES,
best_system_emoji_source, resolve_emoji_source, EMOJI_SOURCE_CACHE,
enum EmojiSource, load_emoji_face_from_source). has_color_glyphs,
emoji_coverage, and EMOJI_BLOCKS survive only as #[cfg(test)] regression
helpers (they had no production callers left). discover_emoji_face() no
longer takes a font database and unconditionally loads the bundled face.

The egui chrome path (fonts.rs) is deliberately untouched -- egui's
grayscale atlas cannot render color glyphs, so it keeps its own separate
monochrome-capable fallback list; only its stale doc reference to the
deleted terminal-side ranking is corrected.

Adds a regression test asserting the always-bundled face maps U+2744
(the snowflake from this report) to a real color glyph, not tofu.
Dragging a mux pane divider produced brief flashes of inverted
(selection-coloured) pixels on a row of the adjacent pane. Root-caused
via frame-by-frame diagnostic logging: the invisible border-drag sensor
(egui `Sense::click_and_drag()`) sits geometrically inside the adjacent
pane's `terminal_rect`, so the same primary press+drag that resizes the
split ALSO starts and extends a real `ViewState` text selection in that
pane. One endpoint pinned at the mouse-down anchor, the other tracked the
drag; the selection survived past drag-end. (The earlier suspicion that
this was a GPU deco-vbo / cursor-blink regression from the #432 fix was
disproved by bisect + logging -- the deco-vbo path is self-consistent and
this input-side gap predates it.)

Fix: thread a `border_drag_active` flag (from `win.border_drag.is_some()`)
into `FreminalTerminalWidget::show`, fold it into `suppress_input`, and
fully `clear()` the selection while a border drag is active (rather than
`finalize_interrupted_drag()`, which keeps a real range -- exactly the
phantom we must discard, since the gesture was a resize, not a selection).

Also, from review + user-requested behaviour:

- Freeze focus-follows-mouse during a divider drag so the active-pane
  highlight/cursor no longer flickers between panes as the pointer moves
  over them mid-drag; on release, focus the pane the pointer ends in
  (new pure helper `panes::pane_at_pos` + the standard FocusChange dance).
- Defensive per-frame reset of `win.border_drag` whenever the primary
  button is not held: the drag-sensor loop that normally clears it is
  gated behind `!ui_overlay_open`, so an overlay opening mid-drag (a
  keybind firing while the button is held) could otherwise strand
  `border_drag = Some` forever and -- now that it feeds global input
  suppression -- permanently freeze all input in the window.
- One-frame suppression tail for border drag (`border_drag_was_active_
  last_frame`, a separate latch mirroring `overlay_was_open_last_frame`)
  so the drag-release click cannot leak into the terminal on the frame
  the drag ends (matters for mouse-tracking-aware PTY apps).

Tests: `pane_at_pos` hit-test (inside A / inside B / outside / empty);
`border_drag_active_suppresses_input`; `border_drag_clears_phantom_
selection` (clear() discards vs finalize_interrupted_drag() keeping);
`border_drag_one_frame_release_tail`.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 9667fc00-2519-417b-ad37-696ebd34b3e0

📥 Commits

Reviewing files that changed from the base of the PR and between 95a9182 and 78d2c57.

📒 Files selected for processing (3)
  • freminal/src/gui/app_impl.rs
  • freminal/src/gui/fonts.rs
  • freminal/src/gui/terminal/input.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • freminal/src/gui/app_impl.rs

📝 Walkthrough

Walkthrough

Changes

Bundled Emoji Selection

Layer / File(s) Summary
Bundled emoji face resolution
ATTRIBUTIONS.md, freminal/src/gui/font_manager.rs
Terminal emoji rendering now resolves the bundled Noto Color Emoji face, with related inspection helpers limited to tests.
Monochrome egui fallback filtering
freminal/src/gui/fonts.rs
The egui fallback path skips color-capable fonts and tests bundled color and monochrome font detection.

Pane Resize Input Handling

Layer / File(s) Summary
Pane position resolution
freminal/src/gui/panes/mod.rs
Adds and tests pane_at_pos for locating panes from cursor coordinates.
Divider release and focus wiring
freminal/src/gui/app_impl.rs, freminal/src/gui/terminal/input.rs
Divider release retargets focus, clears stale drag state, passes drag status into rendering, and drops queued raw keys during dragging.
Terminal drag suppression and release tail
freminal/src/gui/terminal/widget.rs
Border dragging suppresses input, clears phantom selections, and maintains one-frame post-drag suppression with dedicated tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Pointer
  participant FreminalGui
  participant pane_at_pos
  participant TerminalWidget
  participant PTY
  Pointer->>FreminalGui: release divider drag
  FreminalGui->>pane_at_pos: resolve release position
  pane_at_pos-->>FreminalGui: target PaneId or None
  FreminalGui->>TerminalWidget: render with border_drag_active
  TerminalWidget->>TerminalWidget: suppress input and selection updates
  FreminalGui->>PTY: drop queued raw keys during drag
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The new egui chrome emoji color-font filtering in fonts.rs is unrelated to the linked issues and goes beyond the terminal-font and resize-fix scope. Remove or split the egui chrome fallback change into a separate PR unless it is required for the linked issue, keeping this PR focused on #431 and #453.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately reflects the two main fixes: bundled emoji font behavior and pane-divider drag selection handling.
Linked Issues check ✅ Passed The changes satisfy both linked issues: #431 switches terminal emoji rendering to the bundled font, and #453 fixes divider-drag selection artifacts and focus/input handling.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch task-431-453/emoji-font-and-resize-artifact

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Jul 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 66.30435% with 62 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
freminal/src/gui/app_impl.rs 0.00% 30 Missing ⚠️
freminal/src/gui/terminal/widget.rs 66.66% 28 Missing ⚠️
freminal/src/gui/fonts.rs 76.47% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
freminal/src/gui/fonts.rs (1)

347-368: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

CANDIDATES order doesn't match the chrome fallback goal. find_candidate returns the first installed match, but the list prefers several color emoji faces before Symbola, so egui can still end up picking a font its grayscale atlas won’t use properly. Move a monochrome-safe face first, or narrow the list to fonts that work for chrome text.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freminal/src/gui/fonts.rs` around lines 347 - 368, Update the CANDIDATES
priority used by add_emoji_fallback so a monochrome-renderable face such as
Symbola is selected before color-only emoji fonts. Reorder or narrow the list to
preserve reliable egui chrome rendering while retaining find_candidate’s
first-installed-match behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@freminal/src/gui/app_impl.rs`:
- Around line 1886-1914: Update the release-focus transfer in the active-pane
handler to refresh the local active-pane ID and active-pane-changed state after
assigning tab.active_pane, so later rendering and damage calculations use the
target pane immediately. Re-arm the target pane’s blink anchor when focus
transfers, using the existing blink state mechanism and symbols near the
active-pane snapshot.
- Line 2278: Update the pending_raw_keys clearing and draining logic surrounding
the GUI show flow to suppress queued raw-key input while border_drag_active is
true and for the existing one-frame release tail. Ensure events accumulated
during divider resizing are discarded rather than forwarded to the PTY, while
preserving normal draining once the release tail completes.

---

Outside diff comments:
In `@freminal/src/gui/fonts.rs`:
- Around line 347-368: Update the CANDIDATES priority used by add_emoji_fallback
so a monochrome-renderable face such as Symbola is selected before color-only
emoji fonts. Reorder or narrow the list to preserve reliable egui chrome
rendering while retaining find_candidate’s first-installed-match behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 5d4ccbd9-9ae8-4881-9389-fdadb906c154

📥 Commits

Reviewing files that changed from the base of the PR and between 5e2883e and 95a9182.

📒 Files selected for processing (6)
  • ATTRIBUTIONS.md
  • freminal/src/gui/app_impl.rs
  • freminal/src/gui/font_manager.rs
  • freminal/src/gui/fonts.rs
  • freminal/src/gui/panes/mod.rs
  • freminal/src/gui/terminal/widget.rs

Comment on lines +1886 to +1914
// On release, focus the pane the pointer ends in (focus
// was frozen during the drag, issue #453). Use the
// release pointer position; fall back to leaving focus
// unchanged if it isn't over any pane.
if let Some(pos) = ui.ctx().pointer_hover_pos()
&& let Some(under_id) = panes::pane_at_pos(&pane_layout, pos)
{
let tab = win.tabs.active_tab_mut();
if tab.active_pane != under_id {
let old_active = tab.active_pane;
if let Some(old_pane) = tab.pane_tree.find(old_active)
&& let Err(e) =
old_pane.input_tx.send(InputEvent::FocusChange(false))
{
error!(
"Failed to send FocusChange(false) to pane {old_active}: {e}"
);
}
tab.active_pane = under_id;
if let Some(new_pane) = tab.pane_tree.find(under_id)
&& let Err(e) =
new_pane.input_tx.send(InputEvent::FocusChange(true))
{
error!(
"Failed to send FocusChange(true) to pane {under_id}: {e}"
);
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Refresh current-frame active-pane state after release.

Line 1574 snapshots active_pane_id before this handler, but Lines 1904-1912 change tab.active_pane. The rest of this frame still renders the old pane as active and computes damage from that stale ID; with no later repaint request, the cursor/border can remain visually focused on the wrong pane until another event. Update the local active-pane and active-pane-changed state when transferring focus, and re-arm the target pane’s blink anchor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freminal/src/gui/app_impl.rs` around lines 1886 - 1914, Update the
release-focus transfer in the active-pane handler to refresh the local
active-pane ID and active-pane-changed state after assigning tab.active_pane, so
later rendering and damage calculations use the target pane immediately. Re-arm
the target pane’s blink anchor when focus transfers, using the existing blink
state mechanism and symbols near the active-pane snapshot.

&pane.clipboard_rx,
&pane.search_buffer_rx,
ui_overlay_open,
border_drag_active,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Suppress queued raw-key input during divider drags.

Line 2278 suppresses input handled inside show, but the pending_raw_keys drain after show does not check border-drag state or its release tail. Raw keypad/media events typed during a resize can therefore still reach the PTY. Apply the same drag suppression—including the one-frame release tail—when clearing or draining that queue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@freminal/src/gui/app_impl.rs` at line 2278, Update the pending_raw_keys
clearing and draining logic surrounding the GUI show flow to suppress queued
raw-key input while border_drag_active is true and for the existing one-frame
release tail. Ensure events accumulated during divider resizing are discarded
rather than forwarded to the PTY, while preserving normal draining once the
release tail completes.

Follow-up to PR #456 review: the Task-114 raw-key queue
(`win.pending_raw_keys`, keypad/media/print/pause/menu keys that egui
blocks) was drained straight to the PTY even while a pane divider was
being dragged, so those keys could leak mid-resize. `show()` already
receives `border_drag_active` and suppresses normal input, but this
separate out-of-`show()` drain path only gated on overlay state.

Add `border_drag_active` to the drain gate so queued raw keys are
dropped (not forwarded) during a divider drag, upholding the same
"no terminal input while a border drag is active" invariant. No
one-frame release tail is needed here (unlike the pointer path): a raw
key is only queued if physically pressed, and drag-end synthesizes no
key event, so gating on the current-frame flag is sufficient. Doc on
`drain_pending_raw_keys` updated to record the border-drag suppression
cause and why it needs no tail.
Follow-up to PR #456 review (pre-existing bug surfaced by the diff): the
egui chrome-text emoji fallback (`add_emoji_fallback`) picked the first
system emoji font by NAME with no color-vs-monochrome check. egui's
chrome atlas is grayscale-only and cannot rasterize color glyphs
(COLR/CPAL, CBDT/CBLC, sbix) — it locks glyph ownership by cmap and then
renders a color-only glyph as blank. So on a common setup (Noto Color
Emoji installed, no Symbola) chrome emoji/symbols rendered blank even
though the doc comment claimed the list was ordered to prefer a
monochrome-renderable face.

Enforce that claim: `find_candidate` now parses each name-matching face
with swash and skips any that carries color glyph tables (mirroring the
terminal-side `has_color_glyphs` technique), so only a face egui can
actually rasterize is returned; the color-font entries in the candidate
list become harmless no-ops. Fail-closed: a face swash cannot parse is
also skipped. This only affects the egui chrome path; the terminal grid
keeps its own color-capable swash/atlas pipeline (bundled Noto Color
Emoji, issue #431).

Tests: `is_color_font` classifies the bundled Noto Color Emoji as color
(skip) and bundled CaskaydiaCove as monochrome (keep).
@fredclausen

Copy link
Copy Markdown
Member Author

CodeRabbit review — dispositions

Thanks for the review. All three findings verified against the code; two fixed, one intentionally left unchanged with rationale.

✅ Fixed — raw-key drain during divider drag (app_impl.rs)

Real gap. The Task-114 pending_raw_keys drain ran outside show() and only gated on overlay state, so keypad/media keys could leak to the PTY during a resize. Fixed in 3908de5b by adding border_drag_active to the drain gate. No one-frame release tail is needed here (a raw key is only queued if physically pressed; drag-end synthesizes no key event, unlike the pointer-release click) — documented on drain_pending_raw_keys.

✅ Fixed — chrome emoji fallback picks color-only fonts (fonts.rs, outside-diff)

Confirmed technically correct and reproducible (Noto Color Emoji installed, no Symbola → blank chrome glyphs, because egui locks glyph ownership by cmap before checking render capability). Pre-existing (predates this PR; only the doc comment was diff-touched here), but fixed in 78d2c573: find_candidate now parses each name-matching face with swash and skips any with color glyph tables (COLR/CPAL, CBDT/CBLC, sbix), so only an egui-rasterizable monochrome face is returned. Fail-closed on unparseable faces. Tests classify bundled Noto Color Emoji (color→skip) vs CaskaydiaCove (mono→keep).

⏭️ No change — stale active_pane_id after drag-release focus transfer (app_impl.rs:1886-1914)

Verified this is not a novel defect. The new release-focus transfer is behaviorally identical to the pre-existing focus-follows-mouse / click-to-focus transfer (the in-loop block that also sets tab.active_pane mid-frame). Both rely on the same, already-shipped mechanisms:

  • Blink re-arm is handled by the generic top-of-frame active_pane_changed block, which fires on any tab.active_pane mutation the next frame — not by FocusChange (that only drives PTY-side xterm focus reporting).
  • The release WindowEvent::MouseInput unconditionally requests a repaint, so the next frame runs and recomputes active_pane_id / active_pane_changed / blink against the updated active pane.

So the described effect is a self-correcting one-frame lag that already exists for ordinary click-to-focus pane switching; fixing only the release handler would make it inconsistent with the established pattern. If we want to eliminate the one-frame lag, it should be a separate change that updates the frame-local active-pane state at every transfer site — out of scope for this PR.

All green after the two fixes: cargo test --all, clippy --all-targets --all-features -D warnings, cargo machete, cargo xtask check-windows.

@fredclausen
fredclausen merged commit 86fcd5f into main Jul 27, 2026
20 of 21 checks passed
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.

Muxxing: Resize visual artifacting Remove emoji font ranking and just use the bundled font

1 participant