From cbfbd00a4a74fcef671f10bb2bb82f88a551a88f Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 19 Jul 2026 20:41:36 +0800 Subject: [PATCH] feat(panels): missing-fonts prompt widget --- .../src/widgets/missing_fonts_panel.rs | 353 ++++++++++++++++++ crates/op-editor-ui/src/widgets/mod.rs | 2 + 2 files changed, 355 insertions(+) create mode 100644 crates/op-editor-ui/src/widgets/missing_fonts_panel.rs diff --git a/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs b/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs new file mode 100644 index 000000000..ac02bce1b --- /dev/null +++ b/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs @@ -0,0 +1,353 @@ +//! Shared missing-font modal used by native and web hosts. + +use crate::theme::Theme; +use crate::widgets::editor_state_ext::{theme_for, translate}; +use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; +use crate::{Point2D, Rect, TextLayout}; +use jian_widgets::centered_text_baseline_y; +use op_editor_core::missing_fonts::MissingFontsPrompt; +use op_editor_core::{EditorState, EditorUiState}; + +const PANEL_WIDTH: f32 = 480.0; +const BASE_HEIGHT: f32 = 140.0; +const ROW_HEIGHT: f32 = 44.0; +const ROWS_TOP: f32 = 68.0; +const HORIZONTAL_PAD: f32 = 20.0; +const BUTTON_WIDTH: f32 = 150.0; +const BUTTON_HEIGHT: f32 = 28.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingFontsHit { + ChooseFile(usize), + Dismiss, + Inside, + Outside, +} + +pub struct MissingFontsPanel<'a> { + id: WidgetId, + theme: Theme, + prompt: &'a MissingFontsPrompt, + ui: &'a EditorUiState, +} + +impl<'a> MissingFontsPanel<'a> { + pub fn for_editor(state: &'a EditorState) -> Option { + let prompt = state.editor_ui.missing_fonts_prompt.as_ref()?; + if !state.editor_ui.missing_fonts_modal_open || prompt.entries.is_empty() { + return None; + } + Some(Self { + id: WidgetId::new(5460), + theme: theme_for(&state.editor_ui), + prompt, + ui: &state.editor_ui, + }) + } + + pub fn rect(&self, viewport_w: f32, viewport_h: f32) -> Rect { + let height = BASE_HEIGHT + ROW_HEIGHT * self.prompt.entries.len() as f32; + let x = ((viewport_w - PANEL_WIDTH) / 2.0).max(8.0); + let y = ((viewport_h - height) / 2.0).max(crate::widgets::TOP_BAR_HEIGHT + 8.0); + Rect { + origin: Point2D::new(x, y), + size: Point2D::new(PANEL_WIDTH, height), + } + } + + pub fn hit_test(&self, panel: Rect, point: Point2D) -> MissingFontsHit { + if !panel.contains(point) { + return MissingFontsHit::Outside; + } + + for (row, entry) in self.prompt.entries.iter().enumerate() { + if !entry.resolved && self.row_button_rect(panel, row).contains(point) { + return MissingFontsHit::ChooseFile(row); + } + } + + if dismiss_rect(panel).contains(point) { + MissingFontsHit::Dismiss + } else { + MissingFontsHit::Inside + } + } + + pub(crate) fn row_button_rect(&self, panel: Rect, row: usize) -> Rect { + row_button_rect(panel, row) + } +} + +pub(crate) fn row_button_rect(panel: Rect, row: usize) -> Rect { + Rect { + origin: Point2D::new( + panel.origin.x + panel.size.x - HORIZONTAL_PAD - BUTTON_WIDTH, + panel.origin.y + ROWS_TOP + row as f32 * ROW_HEIGHT + 8.0, + ), + size: Point2D::new(BUTTON_WIDTH, BUTTON_HEIGHT), + } +} + +fn dismiss_rect(panel: Rect) -> Rect { + Rect { + origin: Point2D::new( + panel.origin.x + panel.size.x - HORIZONTAL_PAD - 100.0, + panel.origin.y + panel.size.y - 44.0, + ), + size: Point2D::new(100.0, BUTTON_HEIGHT), + } +} + +fn paint_text( + cx: &mut PaintCx<'_>, + text: &str, + origin: Point2D, + font_size: f32, + weight: u16, + color: crate::Color, +) { + let layout = TextLayout::single_run( + text, + "system-ui", + font_size, + color.to_jian(), + Point2D::new(0.0, 0.0), + ) + .with_font_weight(weight); + cx.backend.draw_text(&layout, origin); +} + +impl Widget for MissingFontsPanel<'_> { + fn id(&self) -> WidgetId { + self.id + } + + fn layout(&self, _cx: &LayoutCx) -> LayoutBox { + LayoutBox { + rect: Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new( + PANEL_WIDTH, + BASE_HEIGHT + ROW_HEIGHT * self.prompt.entries.len() as f32, + ), + }, + } + } + + fn paint(&self, cx: &mut PaintCx<'_>, panel: Rect) { + cx.backend.fill_round_rect(panel, 14.0, self.theme.card); + cx.backend + .stroke_round_rect(panel, 14.0, self.theme.border, 1.0); + + paint_text( + cx, + translate(self.ui, "missingFonts.title"), + Point2D::new(panel.origin.x + HORIZONTAL_PAD, panel.origin.y + 28.0), + 16.0, + 600, + self.theme.foreground, + ); + paint_text( + cx, + translate(self.ui, "missingFonts.subtitle"), + Point2D::new(panel.origin.x + HORIZONTAL_PAD, panel.origin.y + 50.0), + 11.0, + 400, + self.theme.muted_foreground, + ); + + for (row, entry) in self.prompt.entries.iter().enumerate() { + let row_y = panel.origin.y + ROWS_TOP + row as f32 * ROW_HEIGHT; + if row > 0 { + cx.backend.fill_rect( + Rect { + origin: Point2D::new(panel.origin.x + HORIZONTAL_PAD, row_y), + size: Point2D::new(panel.size.x - HORIZONTAL_PAD * 2.0, 1.0), + }, + self.theme.border, + ); + } + paint_text( + cx, + &entry.family, + Point2D::new(panel.origin.x + HORIZONTAL_PAD, row_y + 17.0), + 13.0, + 600, + self.theme.foreground, + ); + let usage = translate(self.ui, "missingFonts.usage") + .replace("{n}", &entry.run_count.to_string()); + paint_text( + cx, + &usage, + Point2D::new(panel.origin.x + HORIZONTAL_PAD, row_y + 33.0), + 11.0, + 400, + self.theme.muted_foreground, + ); + + let action = self.row_button_rect(panel, row); + if entry.resolved { + let chip = Rect { + origin: Point2D::new(action.origin.x + action.size.x - 76.0, action.origin.y), + size: Point2D::new(76.0, action.size.y), + }; + cx.backend + .fill_round_rect(chip, 6.0, self.theme.row_selected_primary); + paint_text( + cx, + translate(self.ui, "missingFonts.resolved"), + Point2D::new(chip.origin.x + 12.0, centered_text_baseline_y(chip, 11.0)), + 11.0, + 500, + self.theme.primary, + ); + } else { + cx.backend.fill_round_rect(action, 6.0, self.theme.muted); + paint_text( + cx, + translate(self.ui, "missingFonts.chooseFile"), + Point2D::new( + action.origin.x + 12.0, + centered_text_baseline_y(action, 11.0), + ), + 11.0, + 500, + self.theme.foreground, + ); + } + + if let Some(note) = &entry.mismatch_note { + paint_text( + cx, + note, + Point2D::new(panel.origin.x + HORIZONTAL_PAD, row_y + 43.0), + 11.0, + 400, + self.theme.destructive, + ); + } + } + + let dismiss = dismiss_rect(panel); + paint_text( + cx, + translate(self.ui, "missingFonts.dismiss"), + Point2D::new( + dismiss.origin.x + 12.0, + centered_text_baseline_y(dismiss, 12.0), + ), + 12.0, + 500, + self.theme.muted_foreground, + ); + } + + fn access_node(&self) -> accesskit::Node { + let mut node = accesskit::Node::new(accesskit::Role::Dialog); + node.set_label(translate(self.ui, "missingFonts.title")); + node + } +} + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_core::missing_fonts::MissingFontEntry; + + fn prompt_state(rows: usize) -> EditorState { + let mut state = EditorState::new(); + state.editor_ui.missing_fonts_prompt = Some(MissingFontsPrompt { + entries: (0..rows) + .map(|row| MissingFontEntry { + family: format!("F{row}"), + run_count: 1, + mismatch_note: None, + resolved: false, + }) + .collect(), + }); + state.editor_ui.missing_fonts_modal_open = true; + state + } + + #[test] + fn choose_file_button_hit_maps_to_row() { + let state = prompt_state(2); + let panel = MissingFontsPanel::for_editor(&state).expect("open"); + let rect = panel.rect(1200.0, 800.0); + let button = panel.row_button_rect(rect, 1); + let centre = Point2D::new( + button.origin.x + button.size.x / 2.0, + button.origin.y + button.size.y / 2.0, + ); + + assert_eq!(panel.hit_test(rect, centre), MissingFontsHit::ChooseFile(1)); + } + + #[test] + fn outside_click_does_not_choose() { + let state = prompt_state(1); + let panel = MissingFontsPanel::for_editor(&state).expect("open"); + let rect = panel.rect(1200.0, 800.0); + + assert_eq!( + panel.hit_test(rect, Point2D::new(1.0, 1.0)), + MissingFontsHit::Outside + ); + } + + #[test] + fn resolved_row_has_no_button() { + let mut state = prompt_state(1); + state + .editor_ui + .missing_fonts_prompt + .as_mut() + .expect("prompt") + .entries[0] + .resolved = true; + let panel = MissingFontsPanel::for_editor(&state).expect("open"); + let rect = panel.rect(1200.0, 800.0); + let button = panel.row_button_rect(rect, 0); + let centre = Point2D::new( + button.origin.x + button.size.x / 2.0, + button.origin.y + button.size.y / 2.0, + ); + + assert_eq!(panel.hit_test(rect, centre), MissingFontsHit::Inside); + } + + #[test] + fn dismiss_button_has_a_dedicated_hit() { + let state = prompt_state(1); + let panel = MissingFontsPanel::for_editor(&state).expect("open"); + let rect = panel.rect(1200.0, 800.0); + let dismiss = dismiss_rect(rect); + let centre = Point2D::new( + dismiss.origin.x + dismiss.size.x / 2.0, + dismiss.origin.y + dismiss.size.y / 2.0, + ); + + assert_eq!(panel.hit_test(rect, centre), MissingFontsHit::Dismiss); + } + + #[test] + fn closed_or_empty_prompt_builds_none() { + let state = EditorState::new(); + assert!(MissingFontsPanel::for_editor(&state).is_none()); + + let mut state = prompt_state(0); + assert!(MissingFontsPanel::for_editor(&state).is_none()); + state.editor_ui.missing_fonts_prompt = Some(MissingFontsPrompt { + entries: vec![MissingFontEntry { + family: "Katibeh".into(), + run_count: 1, + mismatch_note: None, + resolved: false, + }], + }); + state.editor_ui.missing_fonts_modal_open = false; + assert!(MissingFontsPanel::for_editor(&state).is_none()); + } +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 5b7023c58..8647f56df 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -264,6 +264,7 @@ pub mod icon_picker_panel; pub mod ime_preedit_overlay; pub mod locale_picker; pub mod login_modal; +pub mod missing_fonts_panel; mod property_panel_color_variables; pub mod shape_picker; pub mod status_bar; @@ -317,6 +318,7 @@ pub use icon_picker_panel::{ ICON_PICKER_PANEL_W, }; pub use locale_picker::{LocalePicker, LOCALE_PICKER_WIDTH}; +pub use missing_fonts_panel::{MissingFontsHit, MissingFontsPanel}; pub use shape_picker::{ShapeChoice, ShapePicker, SHAPE_PICKER_WIDTH}; pub use status_bar::{StatusBar, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH}; pub use top_bar::{TopBar, TopBarHit, TOP_BAR_HEIGHT};