diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index aca0e8ed8..5dc170479 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -1267,6 +1267,9 @@ pub struct EditorUiState { pub missing_fonts_pending_detect: bool, /// Row whose choose-file action is waiting for a platform import drain. pub missing_fonts_import_row: Option, + /// Hovered missing-fonts control (modal button / settings row) — + /// cursor-move updates it, paint tints from it. + pub missing_fonts_hover: Option, /// Raised by `PropertyPanelAction::ImportFont` — the desktop host /// drains it to open a native font-file dialog + `FontStore::import`. pub pending_font_import: bool, @@ -1595,6 +1598,7 @@ impl Default for EditorUiState { missing_fonts_modal_open: false, missing_fonts_pending_detect: false, missing_fonts_import_row: None, + missing_fonts_hover: None, pending_font_import: false, pending_font_remove: None, image_panel: crate::image_panel_state::ImagePanelState::default(), diff --git a/crates/op-editor-core/src/missing_fonts.rs b/crates/op-editor-core/src/missing_fonts.rs index fc6cb2b52..09842bd13 100644 --- a/crates/op-editor-core/src/missing_fonts.rs +++ b/crates/op-editor-core/src/missing_fonts.rs @@ -16,6 +16,15 @@ pub struct MissingFontEntry { pub resolved: bool, } +/// Hoverable controls of the missing-fonts modal and the shared row +/// component — cursor-move hover wash state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MissingFontsHover { + ChooseFile(usize), + RemoveImported(usize), + Dismiss, +} + #[derive(Debug, Clone, Default, PartialEq)] pub struct MissingFontsPrompt { pub entries: Vec, diff --git a/crates/op-editor-ui/src/widgets/agent_settings_fonts.rs b/crates/op-editor-ui/src/widgets/agent_settings_fonts.rs index 75f7bc6bb..7d5663c55 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_fonts.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_fonts.rs @@ -132,6 +132,7 @@ pub(super) fn paint_fonts_tab( theme, ui, entry, + row, missing_row_rect(content, row), row > 0, ); diff --git a/crates/op-editor-ui/src/widgets/editor_state_ext.rs b/crates/op-editor-ui/src/widgets/editor_state_ext.rs index 9ede59fbc..04fb4e939 100644 --- a/crates/op-editor-ui/src/widgets/editor_state_ext.rs +++ b/crates/op-editor-ui/src/widgets/editor_state_ext.rs @@ -146,6 +146,20 @@ pub fn agent_settings_button( /// `op_editor_core::ExportDialogButton` for the modal export dialog's /// hover wash. The `Format` arm reuses [`export_format`] to canonicalise /// the widget `ExportFormat`. +/// Map a missing-fonts modal hit onto the hoverable-control enum — +/// `Some` for the per-row choose-file buttons and the dismiss action. +pub fn missing_fonts_button( + hit: crate::widgets::missing_fonts_panel::MissingFontsHit, +) -> Option { + use crate::widgets::missing_fonts_panel::MissingFontsHit; + use op_editor_core::missing_fonts::MissingFontsHover; + match hit { + MissingFontsHit::ChooseFile(row) => Some(MissingFontsHover::ChooseFile(row)), + MissingFontsHit::Dismiss => Some(MissingFontsHover::Dismiss), + MissingFontsHit::Inside | MissingFontsHit::Outside => None, + } +} + pub fn export_dialog_button( hit: crate::widgets::export_dialog::ExportDialogHit, ) -> op_editor_core::ExportDialogButton { diff --git a/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs b/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs index 1dd3ddcae..dddced7b7 100644 --- a/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs +++ b/crates/op-editor-ui/src/widgets/missing_fonts_panel.rs @@ -132,9 +132,12 @@ pub(crate) fn paint_missing_font_row( theme: &Theme, ui: &EditorUiState, entry: &MissingFontEntry, + row_index: usize, row: Rect, divider: bool, ) { + let choose_hovered = ui.missing_fonts_hover + == Some(op_editor_core::missing_fonts::MissingFontsHover::ChooseFile(row_index)); if divider { cx.backend.fill_rect( Rect { @@ -179,7 +182,13 @@ pub(crate) fn paint_missing_font_row( theme.primary, ); } else { - cx.backend.fill_round_rect(action, 6.0, theme.muted); + // Hover wash mirrors the settings-card affordance (accent). + let bg = if choose_hovered { + theme.accent + } else { + theme.muted + }; + cx.backend.fill_round_rect(action, 6.0, bg); paint_text( cx, translate(ui, "missingFonts.chooseFile"), @@ -250,12 +259,18 @@ impl Widget for MissingFontsPanel<'_> { &self.theme, self.ui, entry, + row, modal_row_rect(panel, row), row > 0, ); } let dismiss = dismiss_rect(panel); + let dismiss_hovered = self.ui.missing_fonts_hover + == Some(op_editor_core::missing_fonts::MissingFontsHover::Dismiss); + if dismiss_hovered { + cx.backend.fill_round_rect(dismiss, 6.0, self.theme.muted); + } paint_text( cx, translate(self.ui, "missingFonts.dismiss"), @@ -265,7 +280,11 @@ impl Widget for MissingFontsPanel<'_> { ), 12.0, 500, - self.theme.muted_foreground, + if dismiss_hovered { + self.theme.foreground + } else { + self.theme.muted_foreground + }, ); } diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 317f9f203..a83395f0e 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -497,6 +497,23 @@ impl WidgetHostNative { { return true; } + // Missing-fonts modal — owns the cursor while open. Hover the + // per-row choose-file buttons + the dismiss action. + if self.editor_state.editor_ui.missing_fonts_modal_open { + use op_editor_ui::widgets::missing_fonts_panel::MissingFontsPanel; + let new_hover = MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| { + let rect = panel.rect(self.last_viewport_w, self.last_viewport_h); + panel.hit_test(rect, Point2D::new(x, y)) + }) + .and_then(op_editor_ui::widgets::editor_state_ext::missing_fonts_button); + let changed = new_hover != self.editor_state.editor_ui.missing_fonts_hover; + if changed { + self.editor_state.editor_ui.missing_fonts_hover = new_hover; + self.mark_dirty(); + } + return changed; + } if self.editor_state.editor_ui.agent_settings_open && self.update_agent_settings_hover(x, y) { return true; diff --git a/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs b/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs index 4efd3a30a..e41f4f9f4 100644 --- a/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs +++ b/crates/op-host-native/src/widget_host/missing_fonts_dispatch.rs @@ -23,6 +23,7 @@ impl WidgetHostNative { } MissingFontsHit::Dismiss => { self.editor_state.editor_ui.missing_fonts_modal_open = false; + self.editor_state.editor_ui.missing_fonts_hover = None; } MissingFontsHit::Inside | MissingFontsHit::Outside => {} } @@ -77,6 +78,7 @@ impl WidgetHostNative { self.editor_state.editor_ui.missing_fonts_prompt = Some(prompt); if all_resolved { self.editor_state.editor_ui.missing_fonts_modal_open = false; + self.editor_state.editor_ui.missing_fonts_hover = None; } self.mark_dirty(); } diff --git a/crates/op-host-web/src/widget_host/cursor_input.rs b/crates/op-host-web/src/widget_host/cursor_input.rs index cccaf61ae..1004b1bb5 100644 --- a/crates/op-host-web/src/widget_host/cursor_input.rs +++ b/crates/op-host-web/src/widget_host/cursor_input.rs @@ -357,6 +357,23 @@ impl WidgetHost { { return true; } + // Missing-fonts modal — owns the cursor while open. Hover the + // per-row choose-file buttons + the dismiss action. + if self.editor_state.editor_ui.missing_fonts_modal_open { + use op_editor_ui::widgets::missing_fonts_panel::MissingFontsPanel; + let new_hover = MissingFontsPanel::for_editor(&self.editor_state) + .map(|panel| { + let rect = panel.rect(self.last_viewport_w, self.last_viewport_h); + panel.hit_test(rect, Point2D::new(x, y)) + }) + .and_then(op_editor_ui::widgets::editor_state_ext::missing_fonts_button); + let changed = new_hover != self.editor_state.editor_ui.missing_fonts_hover; + if changed { + self.editor_state.editor_ui.missing_fonts_hover = new_hover; + self.mark_dirty(); + } + return changed; + } if self.editor_state.editor_ui.agent_settings_open && self.update_agent_settings_hover(x, y) { return true; diff --git a/crates/op-host-web/src/widget_host/missing_fonts_press.rs b/crates/op-host-web/src/widget_host/missing_fonts_press.rs index ca2f98948..af66f958a 100644 --- a/crates/op-host-web/src/widget_host/missing_fonts_press.rs +++ b/crates/op-host-web/src/widget_host/missing_fonts_press.rs @@ -22,6 +22,7 @@ impl WidgetHost { } MissingFontsHit::Dismiss => { self.editor_state.editor_ui.missing_fonts_modal_open = false; + self.editor_state.editor_ui.missing_fonts_hover = None; } MissingFontsHit::Inside | MissingFontsHit::Outside => {} } @@ -86,6 +87,7 @@ impl WidgetHost { self.editor_state.editor_ui.missing_fonts_prompt = Some(prompt); if all_resolved { self.editor_state.editor_ui.missing_fonts_modal_open = false; + self.editor_state.editor_ui.missing_fonts_hover = None; } self.mark_dirty(); } diff --git a/crates/op-web-sdk/bindings/ops.ts b/crates/op-web-sdk/bindings/ops.ts index 82d9be5c6..4005839ab 100644 --- a/crates/op-web-sdk/bindings/ops.ts +++ b/crates/op-web-sdk/bindings/ops.ts @@ -45,7 +45,7 @@ export type AppLifecycleHooks = { onLaunch: Array | null, onResume: Arra export type BlendMode = "normal" | "darken" | "multiply" | "screen" | "overlay" | "lighten" | "difference" | "hue" | "saturation" | "color" | "luminosity"; -export type BlurBody = { radius: number, }; +export type BlurBody = { radius: number, visible: boolean | null, }; /** * Boolean that may also be an expression. @@ -150,7 +150,13 @@ export type FontWeight = number | string; * Forward declaration of PenNode union — defined in `node/mod.rs`. * We accept `Vec` as children. */ -export type FrameNode = { children: Array | null, imageSearchQuery: string | null, reusable: boolean | null, slot: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, }; +export type FrameNode = { children: Array | null, imageSearchQuery: string | null, reusable: boolean | null, slot: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, +/** + * Screen marker: this top-level frame is one screen of the app, + * mounted at the given route path ("/" = entry). Consumed only by + * the screen-projection pass; ignored elsewhere. Additive 1.x. + */ +screen: string | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, width: SizingBehavior | null, height: SizingBehavior | null, layout: LayoutMode | null, gap: NumberOrExpression | null, padding: Padding | null, justifyContent: JustifyContent | null, alignItems: AlignItems | null, clipContent: boolean | null, cornerRadius: CornerRadius | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, }; export type GestureOverrides = { /** @@ -266,7 +272,9 @@ export type Padding = number | [number, number] | [number, number, number, numbe export type PageLifecycleHooks = { onEnter: Array | null, onLeave: Array | null, onForeground: Array | null, onBackground: Array | null, }; -export type PathNode = { iconId: string | null, d: string | null, anchors: Array | null, closed: boolean | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; +export type PathFillRule = "nonzero" | "evenodd"; + +export type PathNode = { iconId: string | null, d: string | null, anchors: Array | null, closed: boolean | null, fillRule: PathFillRule | null, width: SizingBehavior | null, height: SizingBehavior | null, fill: Array | null, stroke: PenStroke | null, effects: Array | null, state: { [key in string]?: StateEntry } | null, bindings: { [key in string]?: Expression } | null, events: EventHandlers | null, lifecycle: NodeLifecycleHooks | null, semantics: SemanticsMeta | null, gestures: GestureOverrides | null, route: NavigationRoute | null, id: string, name: string | null, role: string | null, explain: string | null, x: number | null, y: number | null, rotation: number | null, opacity: NumberOrExpression | null, enabled: BoolOrExpression | null, visible: boolean | null, locked: boolean | null, flipX: boolean | null, flipY: boolean | null, theme: { [key in string]?: string } | null, }; export type PenDocument = { /** @@ -449,7 +457,7 @@ uniforms: { [key in string]?: ShaderUniformValue } | null, explain: string | nul */ export type ShaderUniformValue = number | Array | string; -export type ShadowBody = { inner: boolean | null, offsetX: number, offsetY: number, blur: number, spread: number, color: string, }; +export type ShadowBody = { inner: boolean | null, visible: boolean | null, offsetX: number, offsetY: number, blur: number, spread: number, color: string, }; export type SidedThickness = { top: number | null, right: number | null, bottom: number | null, left: number | null, };