feat(shell): chevron + close-on-globe + multi-script font fallback

TopBar Globe button is now a wider compound (44 px) carrying both
the globe glyph AND a small chevron-down — visually signals the
dropdown affordance the way the TS i18n switcher does.

Click-while-open behaviour fixed: any click outside the dropdown
(including a second click on the Globe itself) closes the picker
and swallows the press, instead of close→re-toggle-open which left
the picker stuck open.

Native font path now resolves a typeface PER CODEPOINT and renders
each contiguous-typeface segment with its own `Font`. Korean
한국어 / Devanagari हिन्दी / Thai ไทย / Vietnamese precomposed
`Tiếng Việt` now render against the right system font instead
of dropping through the Han-only fallback. Per-codepoint cache
keyed on `char as i32` keeps repeat lookups free.
This commit is contained in:
Kayshen-X 2026-05-10 19:26:48 +08:00
parent 4f95c0860b
commit 7b800f67bd
4 changed files with 133 additions and 64 deletions

View file

@ -14,6 +14,10 @@ use crate::{Color, Point2D, Rect, TextLayout};
pub const TOP_BAR_HEIGHT: f32 = 48.0;
const ICON_SIZE: f32 = 18.0;
const ICON_BUTTON: f32 = 32.0;
/// Globe locale-picker button — wider than a normal icon button so a
/// chevron-down sits next to the globe glyph (signals the dropdown).
const GLOBE_BUTTON_WIDTH: f32 = 44.0;
const CHEVRON_SIZE: f32 = 12.0;
const PAD: f32 = 12.0;
/// What a click in the top bar resolved to.
@ -73,14 +77,20 @@ impl TopBar {
}
}
/// Returns the on-screen rect of the Globe icon button. Used
/// by the host to anchor the LocalePicker dropdown directly
/// underneath when `Document.ui.locale_picker_open == true`.
/// Returns the on-screen rect of the Globe-plus-chevron locale
/// button. Used by the host to anchor the LocalePicker dropdown
/// directly underneath when `Document.ui.locale_picker_open ==
/// true`. The button itself is wider than a normal icon button
/// so the chevron-down has room to render.
pub fn globe_rect(top_bar_rect: Rect) -> Rect {
let right = top_bar_rect.origin.x + top_bar_rect.size.x;
// Right-cluster layout (right → left): Maximize | Sun | Globe.
// Maximize + Sun are normal ICON_BUTTON wide; Globe is the
// wider GLOBE_BUTTON_WIDTH so the chevron fits inside it.
let globe_x = right - PAD - ICON_BUTTON * 2.0 - GLOBE_BUTTON_WIDTH;
Rect {
origin: Point2D::new(right - PAD - ICON_BUTTON * 3.0, top_bar_rect.origin.y + 8.0),
size: Point2D::new(ICON_BUTTON, ICON_BUTTON),
origin: Point2D::new(globe_x, top_bar_rect.origin.y + 8.0),
size: Point2D::new(GLOBE_BUTTON_WIDTH, ICON_BUTTON),
}
}
@ -99,14 +109,11 @@ impl TopBar {
if rect_contains(panel_left_rect, point) {
return Some(TopBarHit::ToggleSidebar);
}
// Right cluster: Maximize / Sun / Globe (right→left).
// Each ICON_BUTTON wide, no extra gap. Mirror of paint:
// rx = right - PAD - ICON_BUTTON → Maximize
// rx -= ICON_BUTTON → Sun
// rx -= ICON_BUTTON → Globe
// Right cluster: Maximize / Sun / Globe-with-chevron (right→left).
// Maximize + Sun are normal ICON_BUTTON wide; Globe is
// GLOBE_BUTTON_WIDTH wide because it carries a chevron.
let right = rect.origin.x + rect.size.x;
let sun_x = right - PAD - ICON_BUTTON * 2.0;
let globe_x = right - PAD - ICON_BUTTON * 3.0;
let icon_y = rect.origin.y + 8.0;
let sun_rect = Rect {
origin: Point2D::new(sun_x, icon_y),
@ -115,11 +122,7 @@ impl TopBar {
if rect_contains(sun_rect, point) {
return Some(TopBarHit::ToggleTheme);
}
let globe_rect = Rect {
origin: Point2D::new(globe_x, icon_y),
size: Point2D::new(ICON_BUTTON, ICON_BUTTON),
};
if rect_contains(globe_rect, point) {
if rect_contains(Self::globe_rect(rect), point) {
return Some(TopBarHit::ToggleLocale);
}
None
@ -186,9 +189,8 @@ impl Widget for TopBar {
);
// ── Right cluster ──────────────────────────────────────
// Tight spacing: icons sit close together (4 px) and the
// agent chip sits flush against them (just 4 px gap, not
// the previous 8 + ICON_BUTTON).
// Right → left: Maximize | Sun | Globe+Chevron. Globe is a
// wider compound button (signals the dropdown affordance).
let mut rx = rect.origin.x + rect.size.x - PAD - ICON_BUTTON;
// Fullscreen.
@ -197,10 +199,36 @@ impl Widget for TopBar {
// Theme toggle.
paint_icon_button(cx, &self.theme, rx, center_y, Icon::Sun);
rx -= ICON_BUTTON;
rx -= GLOBE_BUTTON_WIDTH;
// i18n globe.
paint_icon_button(cx, &self.theme, rx, center_y, Icon::Globe);
// i18n globe + chevron-down (single hit-target).
let globe_button = Rect {
origin: Point2D::new(rx, center_y - ICON_BUTTON / 2.0),
size: Point2D::new(GLOBE_BUTTON_WIDTH, ICON_BUTTON),
};
// Globe glyph at the left half.
draw_icon(
cx.backend,
Icon::Globe,
Point2D::new(globe_button.origin.x + 4.0, center_y - ICON_SIZE / 2.0),
ICON_SIZE,
self.theme.muted_foreground,
1.4,
);
// Chevron-down at the right side, smaller.
draw_icon(
cx.backend,
Icon::ChevronDown,
Point2D::new(
globe_button.origin.x + 4.0 + ICON_SIZE + 4.0,
center_y - CHEVRON_SIZE / 2.0,
),
CHEVRON_SIZE,
self.theme.muted_foreground,
1.4,
);
// `rx` now points at the LEFT edge of the globe button —
// the chip anchors immediately to its left (small gap).
// Agent chip — two states:
// - empty (agent_count == 0): LayoutGrid icon + "Agents

View file

@ -81,6 +81,13 @@ pub struct NativeBackend {
typeface_tried: bool,
cjk_typeface: Option<skia_safe::Typeface>,
cjk_typeface_tried: bool,
/// Per-codepoint typeface cache. Populated on first sight of a
/// non-ASCII character so multi-script chrome (Korean 한국어,
/// Devanagari हिन्दी, Thai ไทย, Vietnamese precomposed
/// `Tiếng Việt` …) renders against the right system font
/// instead of falling through to the single `cjk_typeface`
/// (which only covers Han / Hiragana / Katakana on most OSes).
char_typeface_cache: std::collections::HashMap<i32, Option<skia_safe::Typeface>>,
}
const ROBOTO_TTF: &[u8] = include_bytes!("../../../openpencil-shell-web/assets/Roboto-Regular.ttf");
@ -99,9 +106,53 @@ impl NativeBackend {
typeface_tried: false,
cjk_typeface: None,
cjk_typeface_tried: false,
char_typeface_cache: std::collections::HashMap::new(),
}
}
/// Resolve a typeface that covers `c`. Cached per codepoint —
/// `FontMgr::match_family_style_character` is fast on first call
/// but we still avoid the look-up on every chrome paint.
/// Falls back to the cached CJK typeface, then the cached
/// Roboto, so a worst-case missing-font system still renders
/// something rather than dropping the glyph.
fn typeface_for_char(&mut self, c: char) -> Option<skia_safe::Typeface> {
if c.is_ascii() {
return self.ensure_typeface().cloned();
}
let cp = c as i32;
if let Some(cached) = self.char_typeface_cache.get(&cp) {
return cached.clone();
}
let mgr = skia_safe::FontMgr::new();
let tf = mgr.match_family_style_character("", skia_safe::FontStyle::default(), &[], cp);
let resolved = tf.or_else(|| self.ensure_cjk_typeface().cloned());
self.char_typeface_cache.insert(cp, resolved.clone());
resolved
}
/// Split `text` into contiguous segments that share a typeface,
/// preserving char order. Glyphs without any covering typeface
/// are bucketed with the previous segment so they at least
/// occupy space (rather than disappearing).
fn segment_text(&mut self, text: &str) -> Vec<(skia_safe::Typeface, String)> {
let mut segments: Vec<(skia_safe::Typeface, String)> = Vec::new();
for c in text.chars() {
let tf = self.typeface_for_char(c);
let Some(tf) = tf else {
if let Some(last) = segments.last_mut() {
last.1.push(c);
}
continue;
};
match segments.last_mut() {
Some(last) if last.0.unique_id() == tf.unique_id() => last.1.push(c),
_ => segments.push((tf, c.to_string())),
}
}
segments
}
/// Lazy-init the Step 4 cached Roboto typeface (ASCII path).
fn ensure_typeface(&mut self) -> Option<&skia_safe::Typeface> {
if !self.typeface_tried {
@ -213,16 +264,16 @@ impl NativeBackend {
/// Falls back to a conservative heuristic when typefaces
/// aren't available.
pub fn measure_text(&mut self, text: &str, font_size: f32) -> f32 {
let typeface = if text.is_ascii() {
self.ensure_typeface().cloned()
} else {
self.ensure_cjk_typeface().cloned()
};
let Some(typeface) = typeface else {
return text.chars().count() as f32 * font_size * 0.55;
};
let font = skia_safe::Font::new(&typeface, font_size);
let (advance, _) = font.measure_str(text, None);
let segments = self.segment_text(text);
if segments.is_empty() {
return 0.0;
}
let mut advance = 0.0_f32;
for (typeface, segment) in segments {
let font = skia_safe::Font::new(&typeface, font_size);
let (a, _) = font.measure_str(&segment, None);
advance += a;
}
advance
}
@ -242,28 +293,11 @@ impl NativeBackend {
#[tracing::instrument(skip(self, canvas, layout))]
pub fn draw_text(&mut self, canvas: &skia_safe::Canvas, layout: &TextLayout, origin: Point2D) {
let runs: Vec<_> = layout.runs().to_vec();
let ascii_typeface = self.ensure_typeface().cloned();
for run in runs {
let is_ascii = run.content.is_ascii();
let typeface = if is_ascii {
ascii_typeface.clone()
} else {
self.ensure_cjk_typeface().cloned()
};
let Some(typeface) = typeface else {
// No suitable typeface — use jian-skia's textlayout
// fallback (slow but correct) so we never silently
// drop user-visible text.
let mut translated = run.clone();
translated.origin = jian_core::geometry::Point::new(
origin.x + run.origin.x,
origin.y + run.origin.y,
);
let op = jian_core::render::DrawOp::Text(translated);
self.draw_op(canvas, &op);
let segments = self.segment_text(run.content.as_str());
if segments.is_empty() {
continue;
};
let font = skia_safe::Font::new(&typeface, run.font_size);
}
let jc = run.color;
let paint = skia_safe::Paint::new(
skia_safe::Color4f::new(
@ -274,12 +308,14 @@ impl NativeBackend {
),
None,
);
canvas.draw_str(
run.content.as_str(),
(origin.x + run.origin.x, origin.y + run.origin.y),
&font,
&paint,
);
let mut x = origin.x + run.origin.x;
let y = origin.y + run.origin.y;
for (typeface, segment) in segments {
let font = skia_safe::Font::new(&typeface, run.font_size);
canvas.draw_str(&segment, (x, y), &font, &paint);
let (advance, _) = font.measure_str(&segment, None);
x += advance;
}
}
}

View file

@ -275,8 +275,12 @@ impl WidgetHostNative {
viewport_height: f32,
) -> bool {
// 0a. Locale picker overlay — when open, it sits on top of
// everything. Row click sets locale + closes; outside
// click closes without changing locale.
// everything. Row click sets locale + closes; ANY
// other click (including the Globe button itself, the
// canvas, the toolbar, the chip) just closes the
// picker. The click is swallowed so the same press
// doesn't simultaneously re-toggle the picker open
// via the Globe button hit-test below.
if self.document.ui.locale_picker_open {
let panel_rect = self.locale_picker_rect(viewport_width);
let picker = LocalePicker::for_document(&self.document);
@ -286,9 +290,7 @@ impl WidgetHostNative {
return true;
}
self.document.ui.locale_picker_open = false;
// Fall through so the underlying click can also act
// (e.g. clicking the Globe again should re-toggle, not
// require a second click).
return true;
}
// 0b. TopBar — sidebar toggle button + theme + locale picker.

View file

@ -172,7 +172,9 @@ impl WidgetHost {
viewport_height: f32,
) -> bool {
// 0a. Locale picker overlay — top-most when open. Row hit
// sets locale + closes; outside hit closes silently.
// sets locale + closes; ANY other hit (including the
// Globe button itself) closes the picker AND swallows
// the click so the same press doesn't re-toggle open.
if self.document.ui.locale_picker_open {
let panel_rect = self.locale_picker_rect(viewport_width);
let picker = LocalePicker::for_document(&self.document);
@ -182,6 +184,7 @@ impl WidgetHost {
return true;
}
self.document.ui.locale_picker_open = false;
return true;
}
// 0b. TopBar — sidebar toggle button. Mirrors the native