diff --git a/crates/openpencil-shell-core/src/widgets/top_bar.rs b/crates/openpencil-shell-core/src/widgets/top_bar.rs index 109ee783e..0dcea25d8 100644 --- a/crates/openpencil-shell-core/src/widgets/top_bar.rs +++ b/crates/openpencil-shell-core/src/widgets/top_bar.rs @@ -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 diff --git a/crates/openpencil-shell-native/src/backend/skia.rs b/crates/openpencil-shell-native/src/backend/skia.rs index 41e3c00ea..27aa0d599 100644 --- a/crates/openpencil-shell-native/src/backend/skia.rs +++ b/crates/openpencil-shell-native/src/backend/skia.rs @@ -81,6 +81,13 @@ pub struct NativeBackend { typeface_tried: bool, cjk_typeface: Option, 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>, } 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 { + 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; + } } } diff --git a/crates/openpencil-shell-native/src/widget_host.rs b/crates/openpencil-shell-native/src/widget_host.rs index ae7bed5e9..2cbf34cc8 100644 --- a/crates/openpencil-shell-native/src/widget_host.rs +++ b/crates/openpencil-shell-native/src/widget_host.rs @@ -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. diff --git a/crates/openpencil-shell-web/src/widget_host.rs b/crates/openpencil-shell-web/src/widget_host.rs index c4564b13a..26e45790e 100644 --- a/crates/openpencil-shell-web/src/widget_host.rs +++ b/crates/openpencil-shell-web/src/widget_host.rs @@ -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