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:
parent
4f95c0860b
commit
7b800f67bd
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue