diff --git a/crates/op-editor-core/src/button_press_state.rs b/crates/op-editor-core/src/button_press_state.rs index 1ffc6e63a..e45198b5a 100644 --- a/crates/op-editor-core/src/button_press_state.rs +++ b/crates/op-editor-core/src/button_press_state.rs @@ -14,6 +14,7 @@ pub enum ButtonPressTarget { ChatFooter(crate::chat_button_state::ChatFooterButton), ChatExample(usize), PromptCenter(usize), + SceneTemplate(usize), Codegen(crate::codegen::CodegenHover), Git(crate::git_button_state::GitButton), PropertyPanel(usize), diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index 9744b4ee6..8a30a399d 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -63,7 +63,7 @@ pub use git_panel::{ }; pub use groups::{ CustomPrompt, DesignMdPanelState, PreviewState, PromptCenterFocus, PromptCenterState, - PromptFilter, SizeToggleState, + PromptFilter, SceneFilter, SceneTemplateCenterState, SizeToggleState, }; pub use pickers::{ CanvasDropIndicator, CanvasOverlayLine, CanvasOverlayRect, CompositingPickerTarget, @@ -646,6 +646,8 @@ pub struct EditorUiState { // --- Prompt Center ---------------------------------------------- /// Floating prompt catalogue, search, save form, and custom entries. pub prompt_center: PromptCenterState, + /// Floating Scene Template Center panel. + pub scene_template_center: SceneTemplateCenterState, // --- Component browser ------------------------------------------ /// Whether the floating Component-Browser panel is shown. diff --git a/crates/op-editor-core/src/editor_ui_state/defaults.rs b/crates/op-editor-core/src/editor_ui_state/defaults.rs index 95e69b5f1..bae12cf02 100644 --- a/crates/op-editor-core/src/editor_ui_state/defaults.rs +++ b/crates/op-editor-core/src/editor_ui_state/defaults.rs @@ -5,8 +5,8 @@ use super::{ DesignMdPanelState, EditorUiState, EmbedHost, ExportFormat, FlexLayout, GitPanelState, Locale, - PencilCursorStyle, PreviewState, PromptCenterState, PropertyTab, SizeToggleState, ThemeMode, - UpdateStatus, + PencilCursorStyle, PreviewState, PromptCenterState, PropertyTab, SceneTemplateCenterState, + SizeToggleState, ThemeMode, UpdateStatus, }; use crate::tool::Tool; use std::collections::HashSet; @@ -184,6 +184,7 @@ impl Default for EditorUiState { git_panel: GitPanelState::default(), design_md_panel: DesignMdPanelState::default(), prompt_center: PromptCenterState::default(), + scene_template_center: SceneTemplateCenterState::default(), component_browser_open: false, canvas_hover_node: None, entered_container: None, diff --git a/crates/op-editor-core/src/editor_ui_state/groups.rs b/crates/op-editor-core/src/editor_ui_state/groups.rs index 963cafdc8..e535ca51d 100644 --- a/crates/op-editor-core/src/editor_ui_state/groups.rs +++ b/crates/op-editor-core/src/editor_ui_state/groups.rs @@ -15,6 +15,7 @@ use super::{DesignMdRequest, PreviewDeviceKind}; use crate::design_md_button_state::DesignMdButton; use crate::prompt_center_catalog::PromptCategory; +use crate::scene_template_catalog::TemplateScene; use serde::{Deserialize, Serialize}; /// Canvas **Preview** (Play) mode state. @@ -283,3 +284,79 @@ impl PromptCenterState { } } } + +/// The catalogue subset selected by the Scene Template Center chip row. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum SceneFilter { + /// Every shipped template. + #[default] + All, + /// One scene. + Scene(TemplateScene), +} + +/// Grouped state for the non-modal Scene Template Center panel. +/// +/// Deliberately smaller than [`PromptCenterState`]: a template is opened, +/// never authored, so there is no save form and no user-owned entries to +/// persist. Adding those later means adding fields here, not reshaping the +/// panel. +#[derive(Debug, Clone, PartialEq)] +pub struct SceneTemplateCenterState { + /// Whether the floating panel is visible. + pub open: bool, + /// Search text, caret, selection, and IME state. + pub search: jian_core::text_input::TextInputState, + /// Active catalogue filter. + pub filter: SceneFilter, + /// Vertical card-grid scroll. + pub scroll: jian_core::scroll::ScrollState, + /// Widget-defined hover token. Card rows use their filtered index; + /// chrome controls use reserved high values. + pub hover: Option, + /// Raised when a card is chosen; the host drains it to load the + /// document. Kept as a request rather than applied inline because + /// opening a document is a host capability (file dialogs, unsaved-work + /// prompts), not a widget one. + pub pending_open: Option, +} + +impl Default for SceneTemplateCenterState { + fn default() -> Self { + Self { + open: false, + search: Default::default(), + filter: SceneFilter::All, + scroll: Default::default(), + hover: None, + pending_open: None, + } + } +} + +impl SceneTemplateCenterState { + /// Open the panel with search focused and no stale hover/scroll. + pub fn open(&mut self, now_ms: u64) { + self.open = true; + self.hover = None; + self.scroll.offset = 0.0; + self.search.touch(now_ms); + } + + /// Close only this panel layer. + pub fn close(&mut self) { + self.open = false; + self.hover = None; + } + + /// Request that the host open `template_id` and close the panel. + pub fn request_open(&mut self, template_id: impl Into) { + self.pending_open = Some(template_id.into()); + self.close(); + } + + /// Drain a pending open request. + pub fn take_pending_open(&mut self) -> Option { + self.pending_open.take() + } +} diff --git a/crates/op-editor-core/src/editor_ui_state/methods.rs b/crates/op-editor-core/src/editor_ui_state/methods.rs index 849a0f18f..0cdc660d2 100644 --- a/crates/op-editor-core/src/editor_ui_state/methods.rs +++ b/crates/op-editor-core/src/editor_ui_state/methods.rs @@ -67,6 +67,7 @@ impl EditorUiState { /// Open the Prompt Center with its search field focused. pub fn open_prompt_center(&mut self, now_ms: u64) { + self.close_scene_template_center(); self.close_icon_picker(); self.close_chat_model_picker(); self.close_parallel_agents_picker(); @@ -90,6 +91,36 @@ impl EditorUiState { true } + /// Open the Scene Template Center with its search field focused. + /// + /// Closes the Prompt Center: both are full-size centred panels, so + /// leaving one open behind the other would stack two card grids the user + /// cannot see past. + pub fn open_scene_template_center(&mut self, now_ms: u64) { + self.close_prompt_center(); + self.close_icon_picker(); + self.close_chat_model_picker(); + self.close_parallel_agents_picker(); + self.scene_template_center.open(now_ms); + } + + /// Close the Scene Template Center and clear its transient state. + pub fn close_scene_template_center(&mut self) -> bool { + if !self.scene_template_center.open { + return false; + } + self.scene_template_center.close(); + if matches!( + self.pressed_button, + Some(crate::button_press_state::ButtonPressTarget::SceneTemplate( + _ + )) + ) { + self.pressed_button = None; + } + true + } + /// Toggle the Effects "+" add-menu. The corner-radius editor and /// effect menu are mutually exclusive inspector overlays. pub fn toggle_effect_add_picker(&mut self) { diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 10639e9fe..7230afea4 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -315,7 +315,8 @@ pub use editor_ui_state::{ ImageFillMode, LayerContextMenuState, Locale, MergeConflictRow, MergeResolveFile, MergeResolveState, MissingFontSurface, PaddingEditMode, PageRenameState, PencilCursorStyle, PreviewDeviceKind, PreviewState, PromptCenterFocus, PromptCenterState, PromptFilter, - PropertyTab, RecentFile, SizeToggleState, ThemeMode, UpdateStatus, VariableRowFocus, + PropertyTab, RecentFile, SceneFilter, SceneTemplateCenterState, SizeToggleState, ThemeMode, + UpdateStatus, VariableRowFocus, }; pub use export_dialog_state::ExportDialogButton; pub use figma_import_state::{FigmaImportButton, FigmaImportPage, FigmaImportSelection}; diff --git a/crates/op-editor-ui/src/widgets/host_overlay_geometry.rs b/crates/op-editor-ui/src/widgets/host_overlay_geometry.rs index f8e331df9..2a8c0ea44 100644 --- a/crates/op-editor-ui/src/widgets/host_overlay_geometry.rs +++ b/crates/op-editor-ui/src/widgets/host_overlay_geometry.rs @@ -24,8 +24,8 @@ use crate::widgets::{ ImportMenu, LayoutCx, LocalePicker, ShapePicker, Toolbar, TopBar, Widget, COMPONENT_BROWSER_PANEL_H, COMPONENT_BROWSER_PANEL_W, DESIGN_MD_PANEL_H, DESIGN_MD_PANEL_W, ICON_PICKER_PANEL_H, ICON_PICKER_PANEL_W, IMPORT_MENU_WIDTH, LOCALE_PICKER_WIDTH, - PROMPT_CENTER_PANEL_H, PROMPT_CENTER_PANEL_W, SHAPE_PICKER_WIDTH, TOOLBAR_WIDTH, - TOP_BAR_HEIGHT, + PROMPT_CENTER_PANEL_H, PROMPT_CENTER_PANEL_W, SCENE_TEMPLATE_PANEL_H, SCENE_TEMPLATE_PANEL_W, + SHAPE_PICKER_WIDTH, TOOLBAR_WIDTH, TOP_BAR_HEIGHT, }; use crate::{Point2D, Rect}; @@ -233,6 +233,32 @@ pub fn prompt_center_panel_rect( }) } +/// Scene Template Center rect — centred in the canvas region, same as the +/// Prompt Center so the two panels occupy identical space. +pub fn scene_template_panel_rect( + state: &EditorState, + viewport_w: f32, + viewport_h: f32, +) -> Option { + if !state.editor_ui.scene_template_center.open { + return None; + } + let canvas = canvas_rect(state, viewport_w, viewport_h); + let viewport_w = viewport_w.max(0.0); + let viewport_h = viewport_h.max(0.0); + let panel_w = SCENE_TEMPLATE_PANEL_W.min(viewport_w); + let panel_h = SCENE_TEMPLATE_PANEL_H.min(viewport_h); + let x = canvas.origin.x + (canvas.size.x - panel_w) / 2.0; + let y = canvas.origin.y + (canvas.size.y - panel_h) / 2.0; + Some(Rect { + origin: Point2D::new( + x.clamp(0.0, (viewport_w - panel_w).max(0.0)), + y.clamp(0.0, (viewport_h - panel_h).max(0.0)), + ), + size: Point2D::new(panel_w, panel_h), + }) +} + /// Floating Icon-picker panel rect — `None` when closed. pub fn icon_picker_panel_rect( state: &EditorState, diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 98c06c4c0..d56a7f21a 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -47,6 +47,11 @@ mod layer_panel_walkers; pub mod path_anchor_context_menu; pub mod prompt_center_panel; pub(crate) mod prompt_center_previews; +pub mod scene_template_panel; +pub use scene_template_panel::{ + SceneTemplateHit, SceneTemplatePanel, SCENE_TEMPLATE_CLOSE_HOVER, SCENE_TEMPLATE_PANEL_H, + SCENE_TEMPLATE_PANEL_W, +}; #[cfg(test)] mod prompt_center_shared_flow_tests; pub mod property_panel; @@ -124,6 +129,8 @@ mod property_panel_wash_tests; pub mod property_panel_widget; #[cfg(test)] mod property_panel_widget_tests; +mod scene_template_panel_paint; +pub(crate) mod scene_template_previews; pub mod text_input; pub(crate) mod text_input_backend; mod text_selection; @@ -185,6 +192,7 @@ pub mod image_crop_flow; pub mod image_popover_input_flow; pub mod press_flow; mod prompt_center_press_flow; +mod scene_template_press_flow; pub mod scroll_flow; // Step 4 — icon glyph drawer for editor chrome (lucide-flavored line art). diff --git a/crates/op-editor-ui/src/widgets/press_flow.rs b/crates/op-editor-ui/src/widgets/press_flow.rs index 6e921cbb3..eb91bc052 100644 --- a/crates/op-editor-ui/src/widgets/press_flow.rs +++ b/crates/op-editor-ui/src/widgets/press_flow.rs @@ -20,6 +20,9 @@ use crate::widgets::{ use crate::{Point2D, Rect}; pub use super::prompt_center_press_flow::press_prompt_center; +pub use super::scene_template_press_flow::{ + hover_scene_template_center, press_scene_template_center, scroll_scene_template_center, +}; /// Screen rect of the right-hand property rail. Both hosts derive it /// from the same two inputs, so the walk lives here instead of being diff --git a/crates/op-editor-ui/src/widgets/scene_template_panel.rs b/crates/op-editor-ui/src/widgets/scene_template_panel.rs new file mode 100644 index 000000000..9c1106b65 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_panel.rs @@ -0,0 +1,326 @@ +//! Platform-neutral Scene Template Center geometry, filtering, and hit testing. +//! +//! Same contract as the Prompt Center: hosts supply the panel rect and route +//! the returned hit through their shared press flow, and the widget reads only +//! [`EditorState`] so both the native and wasm hosts can use it. +//! +//! The two panels look alike on purpose — a user who has met one should not +//! have to learn the other — but they answer different questions. A prompt +//! ends up in the chat input; a template opens as a document. That is why the +//! only card action here is "open", and why the panel carries no save form. + +use op_editor_core::scene_template_catalog::{ + scene_template_catalogue, SceneTemplateDefinition, TemplateScene, +}; +use op_editor_core::{ButtonPressTarget, EditorState, Locale, SceneFilter}; + +use super::prompt_center_panel::estimated_text_width; +use crate::theme::Theme; +use crate::widgets::editor_state_ext::theme_for; +use crate::{Point2D, Rect}; + +/// Scene Template Center width in logical pixels. +pub const SCENE_TEMPLATE_PANEL_W: f32 = 720.0; +/// Scene Template Center height in logical pixels. +pub const SCENE_TEMPLATE_PANEL_H: f32 = 520.0; + +/// Hover token for the close button. +pub const SCENE_TEMPLATE_CLOSE_HOVER: usize = usize::MAX; + +const FILTER_HOVER_BASE: usize = usize::MAX - 32; + +pub(super) const PAD: f32 = 16.0; +pub(super) const HEADER_H: f32 = 46.0; +pub(super) const SEARCH_ROW_H: f32 = 42.0; +pub(super) const FILTER_ROW_H: f32 = 40.0; +pub(super) const CLOSE_BTN: f32 = 26.0; +const SEARCH_H: f32 = 30.0; +pub(super) const SEARCH_TEXT_SIZE: f32 = 12.0; +pub(super) const CHIP_H: f32 = 24.0; +const CHIP_GAP: f32 = 6.0; +const CARD_COLS: usize = 2; +const CARD_GAP: f32 = 12.0; +pub(super) const CARD_H: f32 = 262.0; +pub(super) const CARD_PREVIEW_INSET: f32 = 8.0; +pub(super) const CARD_PREVIEW_ASPECT: f32 = 16.0 / 10.0; + +/// A hover token for the filter chip at `index`. +pub(super) fn filter_hover_token(index: usize) -> usize { + FILTER_HOVER_BASE + index +} + +/// What a press inside the panel resolved to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SceneTemplateHit { + Close, + FocusSearch(usize), + SelectFilter(SceneFilter), + /// Open this template as a new document. + SelectTemplate(String), + /// Inside the panel but not on a control — swallows the press so it + /// cannot fall through to the canvas underneath. + Inside, +} + +/// Floating Scene Template Center view model. +pub struct SceneTemplatePanel<'a> { + pub(super) state: &'a EditorState, + pub(super) theme: Theme, + pub(super) locale: Locale, + pub(super) now_ms: u64, +} + +impl<'a> SceneTemplatePanel<'a> { + /// Build the panel when it is open. + pub fn for_editor(state: &'a EditorState) -> Option { + Self::for_editor_at(state, 0) + } + + /// Build the panel with a frame clock for caret blinking. + pub fn for_editor_at(state: &'a EditorState, now_ms: u64) -> Option { + state.editor_ui.scene_template_center.open.then(|| Self { + state, + theme: theme_for(&state.editor_ui), + locale: state.editor_ui.locale, + now_ms, + }) + } + + pub(super) fn now_ms(&self) -> u64 { + self.now_ms + } + + pub(super) fn is_pressed(&self, token: usize) -> bool { + matches!( + self.state.editor_ui.pressed_button, + Some(ButtonPressTarget::SceneTemplate(pressed)) if pressed == token + ) + } + + /// Templates surviving the scene filter and the search query. + pub fn filtered(&self) -> Vec<&'static SceneTemplateDefinition> { + let center = &self.state.editor_ui.scene_template_center; + let query = center.search.text().trim(); + scene_template_catalogue() + .iter() + .filter(|template| match center.filter { + SceneFilter::All => true, + SceneFilter::Scene(scene) => template.scene == scene, + }) + .filter(|template| template.matches_query(self.locale, query)) + .collect() + } + + /// The chip row: "All" plus every scene, in catalogue order. + pub(super) fn filters(&self) -> Vec { + let mut filters = vec![SceneFilter::All]; + filters.extend(TemplateScene::ALL.map(SceneFilter::Scene)); + filters + } + + /// Label for one chip. + pub(super) fn filter_label(&self, filter: SceneFilter) -> &'static str { + match filter { + SceneFilter::All => { + let translated = op_i18n::translate(self.locale, "sceneTemplate.filter.all"); + if translated == "sceneTemplate.filter.all" { + "全部" + } else { + translated + } + } + SceneFilter::Scene(scene) => { + let translated = op_i18n::translate(self.locale, scene.title_key()); + if translated == scene.title_key() { + scene.title_fallback() + } else { + translated + } + } + } + } + + pub fn close_rect(panel: Rect) -> Rect { + Rect::xywh( + panel.origin.x + panel.size.x - PAD - CLOSE_BTN, + panel.origin.y + (HEADER_H - CLOSE_BTN) / 2.0, + CLOSE_BTN, + CLOSE_BTN, + ) + } + + pub fn search_rect(panel: Rect) -> Rect { + Rect::xywh( + panel.origin.x + PAD, + panel.origin.y + HEADER_H + (SEARCH_ROW_H - SEARCH_H) / 2.0, + panel.size.x - PAD * 2.0, + SEARCH_H, + ) + } + + pub(super) fn filter_chip_rects(&self, panel: Rect) -> Vec<(Rect, SceneFilter)> { + let top = panel.origin.y + HEADER_H + SEARCH_ROW_H + (FILTER_ROW_H - CHIP_H) / 2.0; + let mut x = panel.origin.x + PAD; + self.filters() + .into_iter() + .map(|filter| { + let width = chip_width(self.filter_label(filter)); + let rect = Rect::xywh(x, top, width, CHIP_H); + x += width + CHIP_GAP; + (rect, filter) + }) + .collect() + } + + pub(super) fn cards_top(&self, panel: Rect) -> f32 { + panel.origin.y + HEADER_H + SEARCH_ROW_H + FILTER_ROW_H + } + + pub fn cards_viewport(&self, panel: Rect) -> Rect { + let top = self.cards_top(panel); + Rect::xywh( + panel.origin.x + PAD, + top, + panel.size.x - PAD * 2.0, + (panel.origin.y + panel.size.y - PAD - top).max(0.0), + ) + } + + fn rows_for_count(count: usize) -> usize { + count.div_ceil(CARD_COLS) + } + + pub(super) fn content_height_for_count(count: usize) -> f32 { + let rows = Self::rows_for_count(count); + if rows == 0 { + 0.0 + } else { + rows as f32 * CARD_H + (rows - 1) as f32 * CARD_GAP + } + } + + /// Largest legal scroll offset for the current result set. + pub fn max_scroll(&self, panel: Rect) -> f32 { + self.max_scroll_for_count(panel, self.filtered().len()) + } + + pub(super) fn max_scroll_for_count(&self, panel: Rect, count: usize) -> f32 { + let viewport = self.cards_viewport(panel); + (Self::content_height_for_count(count) - viewport.size.y).max(0.0) + } + + pub(super) fn card_rects_for_count(&self, panel: Rect, count: usize) -> Vec<(usize, Rect)> { + let viewport = self.cards_viewport(panel); + let card_w = (viewport.size.x - CARD_GAP) / CARD_COLS as f32; + let scroll = self + .state + .editor_ui + .scene_template_center + .scroll + .offset + .clamp(0.0, self.max_scroll_for_count(panel, count)); + (0..count) + .map(|index| { + let row = index / CARD_COLS; + let column = index % CARD_COLS; + let rect = Rect::xywh( + viewport.origin.x + column as f32 * (card_w + CARD_GAP), + viewport.origin.y + row as f32 * (CARD_H + CARD_GAP) - scroll, + card_w, + CARD_H, + ); + (index, rect) + }) + .collect() + } + + pub(super) fn card_rects(&self, panel: Rect) -> Vec<(usize, Rect)> { + self.card_rects_for_count(panel, self.filtered().len()) + } + + /// Resolve a pointer to a hover token shared with paint. + pub fn hover_at(&self, panel: Rect, point: Point2D) -> Option { + if !panel.contains(point) { + return None; + } + if Self::close_rect(panel).contains(point) { + return Some(SCENE_TEMPLATE_CLOSE_HOVER); + } + for (index, (rect, _)) in self.filter_chip_rects(panel).into_iter().enumerate() { + if rect.contains(point) { + return Some(filter_hover_token(index)); + } + } + // A card scrolled out of the viewport must not hover: its rect is + // still computed (paint clips it), so the viewport check is what + // keeps a pointer below the panel from lighting up a hidden row. + let viewport = self.cards_viewport(panel); + if !viewport.contains(point) { + return None; + } + self.card_rects(panel) + .into_iter() + .find(|(_, rect)| rect.contains(point)) + .map(|(index, _)| index) + } + + /// Hit-test panel chrome and cards. Outside presses return `None` so the + /// caller can treat them as dismiss. + pub fn hit_test(&self, panel: Rect, point: Point2D) -> Option { + if !panel.contains(point) { + return None; + } + if Self::close_rect(panel).contains(point) { + return Some(SceneTemplateHit::Close); + } + let search = Self::search_rect(panel); + if search.contains(point) { + let caret = self.search_caret_at(search, point); + return Some(SceneTemplateHit::FocusSearch(caret)); + } + for (rect, filter) in self.filter_chip_rects(panel) { + if rect.contains(point) { + return Some(SceneTemplateHit::SelectFilter(filter)); + } + } + let viewport = self.cards_viewport(panel); + if viewport.contains(point) { + let cards = self.filtered(); + for (index, rect) in self.card_rects_for_count(panel, cards.len()) { + if rect.contains(point) { + return Some(SceneTemplateHit::SelectTemplate(cards[index].id.clone())); + } + } + } + Some(SceneTemplateHit::Inside) + } + + /// Caret index for a press inside the search field. + fn search_caret_at(&self, search: Rect, point: Point2D) -> usize { + let text = self.state.editor_ui.scene_template_center.search.text(); + let relative = (point.x - (search.origin.x + 32.0)).max(0.0); + let mut width = 0.0; + for (index, character) in text.char_indices() { + let advance = estimated_text_width(&character.to_string(), SEARCH_TEXT_SIZE); + if relative < width + advance / 2.0 { + return index; + } + width += advance; + } + text.len() + } +} + +/// Chip label size, shared by the rect math here and the paint pass. +pub(super) const CHIP_LABEL_SIZE: f32 = 11.0; + +fn chip_width(label: &str) -> f32 { + // Reuses the Prompt Center's estimate on purpose: the two chip rows sit + // in identically sized panels, and a second width model would drift them + // apart for the same label. + estimated_text_width(label, CHIP_LABEL_SIZE) + 20.0 +} + +#[cfg(test)] +#[path = "scene_template_panel_tests.rs"] +mod scene_template_panel_tests; diff --git a/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs b/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs new file mode 100644 index 000000000..a2beb1878 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs @@ -0,0 +1,354 @@ +//! Scene Template Center painting, split from geometry to keep both compact. + +use op_editor_core::scene_template_catalog::SceneTemplateDefinition; + +use super::scene_template_panel::{ + filter_hover_token, SceneTemplatePanel, CARD_H, CARD_PREVIEW_ASPECT, CARD_PREVIEW_INSET, + CHIP_H, CHIP_LABEL_SIZE, CLOSE_BTN, HEADER_H, PAD, SCENE_TEMPLATE_CLOSE_HOVER, + SEARCH_TEXT_SIZE, +}; +use crate::widgets::button::paint_button_feedback_wash; +use crate::widgets::canvas_viewport_image::{ + has_cached_image_bytes, note_pending_decode, required_raster_edge, store_remote_image_bytes, +}; +use crate::widgets::prompt_center_panel::estimated_text_width; +use crate::widgets::property_panel_text_input::paint_text_input_view; +use crate::widgets::scene_template_previews::scene_template_preview; +use crate::widgets::{draw_icon, Icon, PaintCx}; +use crate::{Color, ImageDrawMode, Point2D, Rect, TextLayout}; + +const CARD_RADIUS: f32 = 9.0; +const TITLE_SIZE: f32 = 12.5; +const SUMMARY_SIZE: f32 = 11.0; +const META_SIZE: f32 = 10.5; + +impl SceneTemplatePanel<'_> { + /// Paint the complete non-modal panel. + pub fn paint(&self, cx: &mut PaintCx<'_>, panel: Rect) { + cx.backend.fill_round_rect(panel, 12.0, self.theme.popover); + cx.backend + .stroke_round_rect(panel, 12.0, self.theme.border, 1.0); + self.paint_header(cx, panel); + self.paint_search(cx, panel); + self.paint_filter_chips(cx, panel); + self.paint_cards(cx, panel); + } + + fn paint_header(&self, cx: &mut PaintCx<'_>, panel: Rect) { + self.paint_text( + cx, + self.t("sceneTemplate.title", "场景模板"), + Point2D::new(panel.origin.x + PAD, panel.origin.y + 29.0), + 15.0, + self.theme.foreground, + ); + + let close = Self::close_rect(panel); + jian_widgets::components::icon_button::IconButton { + icon_paths: Icon::Close.paths(), + hovered: self.state.editor_ui.scene_template_center.hover + == Some(SCENE_TEMPLATE_CLOSE_HOVER), + pressed: self.is_pressed(SCENE_TEMPLATE_CLOSE_HOVER), + active: false, + enabled: true, + icon_size: CLOSE_BTN - 11.0, + stroke_width: 1.5, + } + .paint( + cx.backend, + close, + &crate::widgets::button::tokens_from_theme(&self.theme), + ); + + cx.backend.fill_rect( + Rect::xywh(panel.origin.x, panel.origin.y + HEADER_H, panel.size.x, 1.0), + self.theme.border, + ); + } + + fn paint_search(&self, cx: &mut PaintCx<'_>, panel: Rect) { + let rect = Self::search_rect(panel); + cx.backend.fill_round_rect(rect, 7.0, self.theme.muted); + cx.backend + .stroke_round_rect(rect, 7.0, self.theme.border, 1.0); + draw_icon( + cx.backend, + Icon::Search, + Point2D::new(rect.origin.x + 9.0, rect.origin.y + 7.0), + 16.0, + self.theme.muted_foreground, + 1.4, + ); + paint_text_input_view( + cx, + &self.theme, + &self.state.editor_ui.scene_template_center.search, + rect, + SEARCH_TEXT_SIZE, + 32.0, + rect.origin.y + 19.0, + self.now_ms(), + self.t("sceneTemplate.searchPlaceholder", "搜索场景或模板"), + // The panel has one text field, so it owns the caret whenever the + // panel is open — no focus enum needed. + true, + ); + } + + fn paint_filter_chips(&self, cx: &mut PaintCx<'_>, panel: Rect) { + for (index, (rect, filter)) in self.filter_chip_rects(panel).into_iter().enumerate() { + let active = self.state.editor_ui.scene_template_center.filter == filter; + let (fill, foreground) = if active { + (self.theme.primary, self.theme.primary_foreground) + } else { + (self.theme.muted, self.theme.muted_foreground) + }; + cx.backend.fill_round_rect(rect, CHIP_H / 2.0, fill); + paint_button_feedback_wash( + cx.backend, + &self.theme, + rect, + CHIP_H / 2.0, + self.state.editor_ui.scene_template_center.hover == Some(filter_hover_token(index)), + self.is_pressed(filter_hover_token(index)), + ); + let label = self.filter_label(filter); + let label_w = estimated_text_width(label, CHIP_LABEL_SIZE); + self.paint_text( + cx, + label, + Point2D::new( + rect.origin.x + ((rect.size.x - label_w) / 2.0).max(5.0), + rect.origin.y + 16.0, + ), + CHIP_LABEL_SIZE, + foreground, + ); + } + } + + fn paint_cards(&self, cx: &mut PaintCx<'_>, panel: Rect) { + let viewport = self.cards_viewport(panel); + let templates = self.filtered(); + if templates.is_empty() { + self.paint_text( + cx, + self.t("sceneTemplate.empty", "没有匹配的模板"), + Point2D::new(viewport.origin.x + 4.0, viewport.origin.y + 28.0), + 12.0, + self.theme.muted_foreground, + ); + return; + } + + cx.backend.save(); + cx.backend.clip_rect(viewport); + for (index, rect) in self.card_rects_for_count(panel, templates.len()) { + // Cheap reject for rows scrolled out of view: their rects are + // still computed so hover and paint agree on indices. + if rect.origin.y > viewport.origin.y + viewport.size.y + || rect.origin.y + rect.size.y < viewport.origin.y + { + continue; + } + self.paint_card(cx, rect, templates[index], index); + } + cx.backend.restore(); + } + + fn paint_card( + &self, + cx: &mut PaintCx<'_>, + rect: Rect, + template: &'static SceneTemplateDefinition, + index: usize, + ) { + cx.backend + .fill_round_rect(rect, CARD_RADIUS, self.theme.card); + cx.backend + .stroke_round_rect(rect, CARD_RADIUS, self.theme.border, 1.0); + paint_button_feedback_wash( + cx.backend, + &self.theme, + rect, + CARD_RADIUS, + self.state.editor_ui.scene_template_center.hover == Some(index), + self.is_pressed(index), + ); + + let preview = Self::card_preview_rect(rect); + self.paint_card_preview(cx, preview, template); + + let text_x = rect.origin.x + CARD_PREVIEW_INSET + 4.0; + let text_w = rect.size.x - (CARD_PREVIEW_INSET + 4.0) * 2.0; + self.paint_text( + cx, + &truncate_to_width(template.title_for_locale(self.locale), text_w, TITLE_SIZE), + Point2D::new(text_x, preview.origin.y + preview.size.y + 22.0), + TITLE_SIZE, + self.theme.foreground, + ); + + // Two summary lines, wrapped on the same width estimate the rest of + // the panel uses. + let summary = template.summary_for_locale(self.locale); + let mut y = preview.origin.y + preview.size.y + 40.0; + for line in wrap_to_width(summary, text_w, SUMMARY_SIZE, 2) { + self.paint_text( + cx, + &line, + Point2D::new(text_x, y), + SUMMARY_SIZE, + self.theme.muted_foreground, + ); + y += 15.0; + } + + self.paint_text( + cx, + &self.metadata(template), + Point2D::new(text_x, rect.origin.y + rect.size.y - 12.0), + META_SIZE, + self.theme.muted_foreground, + ); + } + + fn paint_card_preview( + &self, + cx: &mut PaintCx<'_>, + preview: Rect, + template: &'static SceneTemplateDefinition, + ) { + let Some((image_id, encoded)) = scene_template_preview(&template.id) else { + cx.backend.fill_round_rect(preview, 7.0, self.theme.muted); + return; + }; + // Same decode handshake the Prompt Center uses: register the bytes, + // request the raster at the size actually painted, and fall back to a + // plain block until the decode lands so the first frame never blocks. + if !has_cached_image_bytes(image_id) { + store_remote_image_bytes(image_id, encoded.to_vec()); + } + let max_edge_px = required_raster_edge(preview, cx.backend.dpi_scale()); + let sharp = cx.backend.image_decoded(image_id, encoded, max_edge_px); + if !sharp { + note_pending_decode(image_id, max_edge_px); + } + if !sharp && !cx.backend.image_resident(image_id) { + cx.backend.fill_round_rect(preview, 7.0, self.theme.muted); + return; + } + cx.backend.save(); + cx.backend.clip_round_rect(preview, 7.0); + cx.backend + .draw_image_with_mode(preview, image_id, encoded, ImageDrawMode::Fill); + cx.backend.restore(); + } + + fn card_preview_rect(card: Rect) -> Rect { + let width = card.size.x - CARD_PREVIEW_INSET * 2.0; + let height = (width / CARD_PREVIEW_ASPECT).min(CARD_H * 0.62); + Rect::xywh( + card.origin.x + CARD_PREVIEW_INSET, + card.origin.y + CARD_PREVIEW_INSET, + width, + height, + ) + } + + /// "6 页 · 1920×1080" — what distinguishes a deck from a poster at a + /// glance, which is the decision the card exists to support. + fn metadata(&self, template: &SceneTemplateDefinition) -> String { + let count = template.frames.to_string(); + let pages = + op_i18n::translate_with(self.locale, "sceneTemplate.frames", &[("count", &count)]); + let pages = if pages == "sceneTemplate.frames" { + format!("{count} 页") + } else { + pages + }; + format!( + "{pages} · {}×{}", + template.frame_width, template.frame_height + ) + } + + fn t(&self, key: &'static str, fallback: &'static str) -> &'static str { + let translated = op_i18n::translate(self.locale, key); + if translated == key { + fallback + } else { + translated + } + } + + fn paint_text( + &self, + cx: &mut PaintCx<'_>, + text: &str, + position: Point2D, + size: f32, + color: Color, + ) { + let layout = TextLayout::single_run( + text, + "system-ui", + size, + color.to_jian(), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text(&layout, position); + } +} + +fn truncate_to_width(text: &str, max_width: f32, size: f32) -> String { + if estimated_text_width(text, size) <= max_width { + return text.to_string(); + } + let ellipsis_w = estimated_text_width("…", size); + let mut out = String::new(); + let mut width = 0.0; + for character in text.chars() { + let advance = estimated_text_width(&character.to_string(), size); + if width + advance + ellipsis_w > max_width { + break; + } + out.push(character); + width += advance; + } + out.push('…'); + out +} + +/// Greedy wrap to at most `max_lines`, ellipsising the last line when the +/// text does not fit. Breaks per character, which is right for the CJK the +/// summaries are written in and acceptable for the Latin ones at this size. +fn wrap_to_width(text: &str, max_width: f32, size: f32, max_lines: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + let mut width = 0.0; + for character in text.chars() { + let advance = estimated_text_width(&character.to_string(), size); + if width + advance > max_width && !current.is_empty() { + lines.push(std::mem::take(&mut current)); + width = 0.0; + if lines.len() == max_lines { + break; + } + } + current.push(character); + width += advance; + } + if lines.len() < max_lines && !current.is_empty() { + lines.push(current); + } + // Anything past the cap is dropped, so the final line must show that. + if lines.len() == max_lines { + let consumed: usize = lines.iter().map(|line| line.chars().count()).sum(); + if text.chars().count() > consumed { + let last = lines.pop().unwrap_or_default(); + lines.push(truncate_to_width(&last, max_width, size)); + } + } + lines +} diff --git a/crates/op-editor-ui/src/widgets/scene_template_panel_tests.rs b/crates/op-editor-ui/src/widgets/scene_template_panel_tests.rs new file mode 100644 index 000000000..8aa2e7628 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_panel_tests.rs @@ -0,0 +1,167 @@ +use super::*; +use op_editor_core::scene_template_catalog::TemplateScene; +use op_editor_core::EditorState; + +const PANEL: Rect = Rect { + origin: Point2D { x: 40.0, y: 60.0 }, + size: Point2D { + x: SCENE_TEMPLATE_PANEL_W, + y: SCENE_TEMPLATE_PANEL_H, + }, +}; + +fn open_state() -> EditorState { + let mut state = EditorState::default(); + state.editor_ui.open_scene_template_center(0); + state +} + +#[test] +fn the_panel_only_builds_while_open() { + let mut state = EditorState::default(); + assert!(SceneTemplatePanel::for_editor(&state).is_none()); + state.editor_ui.open_scene_template_center(0); + assert!(SceneTemplatePanel::for_editor(&state).is_some()); +} + +#[test] +fn every_scene_chip_is_reachable_and_none_overlap() { + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let chips = panel.filter_chip_rects(PANEL); + assert_eq!(chips.len(), TemplateScene::ALL.len() + 1, "scenes plus All"); + + // Chips are laid out by accumulating widths; an off-by-one there would + // silently make the last chip unclickable or overlap its neighbour. + for pair in chips.windows(2) { + let (left, _) = pair[0]; + let (right, _) = pair[1]; + assert!( + left.origin.x + left.size.x <= right.origin.x, + "chips overlap: {left:?} then {right:?}" + ); + } + let (last, _) = chips.last().copied().expect("chips"); + assert!( + last.origin.x + last.size.x <= PANEL.origin.x + PANEL.size.x, + "the last chip runs past the panel edge" + ); + for (rect, filter) in chips { + assert_eq!( + panel.hit_test( + PANEL, + Point2D::new(rect.origin.x + 2.0, rect.origin.y + 2.0) + ), + Some(SceneTemplateHit::SelectFilter(filter)) + ); + } +} + +#[test] +fn cards_are_laid_out_two_per_row_inside_the_viewport() { + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let viewport = panel.cards_viewport(PANEL); + let rects = panel.card_rects(PANEL); + assert!( + rects.len() >= 4, + "the catalogue ships at least four templates" + ); + + let (_, first) = rects[0]; + let (_, second) = rects[1]; + assert_eq!(first.origin.y, second.origin.y, "first two share a row"); + assert!(second.origin.x > first.origin.x); + let (_, third) = rects[2]; + assert!( + third.origin.y > first.origin.y, + "third wraps to the next row" + ); + + for (_, rect) in &rects { + assert!(rect.origin.x >= viewport.origin.x - 0.5); + assert!( + rect.origin.x + rect.size.x <= viewport.origin.x + viewport.size.x + 0.5, + "card {rect:?} runs past the viewport" + ); + } +} + +#[test] +fn a_press_on_a_card_resolves_to_that_template() { + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let templates = panel.filtered(); + let (index, rect) = panel.card_rects(PANEL).into_iter().next().expect("a card"); + let hit = panel.hit_test( + PANEL, + Point2D::new(rect.origin.x + 10.0, rect.origin.y + 10.0), + ); + assert_eq!( + hit, + Some(SceneTemplateHit::SelectTemplate( + templates[index].id.clone() + )) + ); +} + +#[test] +fn search_narrows_the_grid_and_an_empty_result_is_representable() { + let mut state = open_state(); + state.editor_ui.scene_template_center.search.set_text("PPT"); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let hits = panel.filtered(); + assert!(!hits.is_empty()); + assert!(hits.iter().all(|t| t.scene == TemplateScene::Slides)); + + state + .editor_ui + .scene_template_center + .search + .set_text("no-such-template-anywhere"); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + assert!(panel.filtered().is_empty()); + assert_eq!(panel.max_scroll(PANEL), 0.0, "an empty grid cannot scroll"); +} + +#[test] +fn a_pointer_below_the_viewport_does_not_hover_a_scrolled_card() { + // Card rects are computed for every entry, including ones scrolled out of + // view — paint clips them. Without the viewport gate, a pointer under the + // panel would light up a row nobody can see. + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let viewport = panel.cards_viewport(PANEL); + let below = Point2D::new( + viewport.origin.x + 10.0, + viewport.origin.y + viewport.size.y + 4.0, + ); + assert_eq!(panel.hover_at(PANEL, below), None); +} + +#[test] +fn the_close_button_sits_inside_the_header_and_hit_tests_first() { + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let close = SceneTemplatePanel::close_rect(PANEL); + assert!(close.origin.y >= PANEL.origin.y); + assert!(close.origin.x + close.size.x <= PANEL.origin.x + PANEL.size.x); + let centre = Point2D::new( + close.origin.x + close.size.x / 2.0, + close.origin.y + close.size.y / 2.0, + ); + assert_eq!(panel.hit_test(PANEL, centre), Some(SceneTemplateHit::Close)); + assert_eq!( + panel.hover_at(PANEL, centre), + Some(SCENE_TEMPLATE_CLOSE_HOVER) + ); +} + +#[test] +fn presses_outside_the_panel_are_not_claimed() { + let state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let outside = Point2D::new(PANEL.origin.x - 1.0, PANEL.origin.y + 10.0); + assert_eq!(panel.hit_test(PANEL, outside), None); + assert_eq!(panel.hover_at(PANEL, outside), None); +} diff --git a/crates/op-editor-ui/src/widgets/scene_template_press_flow.rs b/crates/op-editor-ui/src/widgets/scene_template_press_flow.rs new file mode 100644 index 000000000..cbd184697 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_press_flow.rs @@ -0,0 +1,109 @@ +//! Shared Scene Template Center press transitions for native and web hosts. + +use op_editor_core::{ButtonPressTarget, EditorState}; + +use crate::widgets::{SceneTemplateHit, SceneTemplatePanel}; +use crate::{Point2D, Rect}; + +/// Route one pointer press to the non-modal Scene Template Center. +/// +/// `None` means the press was outside the panel and must fall through. +/// `Some(changed)` means the panel swallowed it; hosts repaint when `changed` +/// is true. +/// +/// Choosing a card does not open the document here. It raises +/// `scene_template_center.pending_open`, which the host drains: loading a +/// document is a host capability (it may have to prompt about unsaved work, +/// and on native it touches the recent-files list), and a widget that reached +/// into that would have to be reimplemented per host — the exact fork this +/// shared layer exists to prevent. +pub fn press_scene_template_center( + state: &mut EditorState, + panel_rect: Rect, + point: Point2D, + now_ms: u64, +) -> Option { + let panel = SceneTemplatePanel::for_editor(state)?; + let hover = panel.hover_at(panel_rect, point); + let hit = panel.hit_test(panel_rect, point)?; + let pressed = hover.map(ButtonPressTarget::SceneTemplate); + let pressed_changed = state.editor_ui.pressed_button != pressed; + state.editor_ui.pressed_button = pressed; + + let changed = match hit { + SceneTemplateHit::Close => state.editor_ui.close_scene_template_center(), + SceneTemplateHit::FocusSearch(offset) => { + let center = &mut state.editor_ui.scene_template_center; + let changed = center.search.caret() != offset; + center.search.set_caret(offset, now_ms); + changed + } + SceneTemplateHit::SelectFilter(filter) => { + let center = &mut state.editor_ui.scene_template_center; + let changed = + center.filter != filter || center.scroll.offset != 0.0 || center.hover.is_some(); + center.filter = filter; + // A filter change reorders the grid, so a retained scroll offset + // or hover index would point at a different card than the one the + // pointer is over. + center.scroll.offset = 0.0; + center.hover = None; + changed + } + SceneTemplateHit::SelectTemplate(id) => { + state.editor_ui.scene_template_center.request_open(id); + true + } + SceneTemplateHit::Inside => false, + }; + Some(changed || pressed_changed) +} + +/// Route a wheel/trackpad scroll to the card grid. +/// +/// `None` when the pointer is outside the panel, so the host falls through to +/// canvas zoom/pan. +pub fn scroll_scene_template_center( + state: &mut EditorState, + panel_rect: Rect, + point: Point2D, + delta_y: f32, +) -> Option { + let panel = SceneTemplatePanel::for_editor(state)?; + if !panel_rect.contains(point) { + return None; + } + let max_scroll = panel.max_scroll(panel_rect); + let center = &mut state.editor_ui.scene_template_center; + let previous = center.scroll.offset; + center.scroll.offset = (previous - delta_y).clamp(0.0, max_scroll); + // Swallow the event either way: a grid that has nothing left to scroll + // must not hand the wheel to the canvas underneath it. + Some(center.scroll.offset != previous) +} + +/// Resolve hover for a pointer move. +/// +/// Returns `(over_panel, changed)` — hosts need the first to suppress +/// canvas-level hover while the pointer is over the panel, which is the gate +/// a floating overlay is easiest to forget (measured 2026-07-29 on the colour +/// variable popover: hover fell straight through to the layer underneath). +pub fn hover_scene_template_center( + state: &mut EditorState, + panel_rect: Rect, + point: Point2D, +) -> (bool, bool) { + let Some(panel) = SceneTemplatePanel::for_editor(state) else { + return (false, false); + }; + let hover = panel.hover_at(panel_rect, point); + let over_panel = panel_rect.contains(point); + let center = &mut state.editor_ui.scene_template_center; + let changed = center.hover != hover; + center.hover = hover; + (over_panel, changed) +} + +#[cfg(test)] +#[path = "scene_template_press_flow_tests.rs"] +mod scene_template_press_flow_tests; diff --git a/crates/op-editor-ui/src/widgets/scene_template_press_flow_tests.rs b/crates/op-editor-ui/src/widgets/scene_template_press_flow_tests.rs new file mode 100644 index 000000000..1f79cdf90 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_press_flow_tests.rs @@ -0,0 +1,172 @@ +use super::*; +use crate::widgets::SCENE_TEMPLATE_CLOSE_HOVER; +use op_editor_core::scene_template_catalog::TemplateScene; +use op_editor_core::{EditorState, SceneFilter}; + +const PANEL: Rect = Rect { + origin: Point2D { x: 100.0, y: 80.0 }, + size: Point2D { x: 720.0, y: 520.0 }, +}; + +fn open_state() -> EditorState { + let mut state = EditorState::default(); + state.editor_ui.open_scene_template_center(0); + state +} + +fn point_in(rect: Rect) -> Point2D { + Point2D::new( + rect.origin.x + rect.size.x / 2.0, + rect.origin.y + rect.size.y / 2.0, + ) +} + +#[test] +fn a_press_outside_the_panel_falls_through() { + let mut state = open_state(); + let outside = Point2D::new(PANEL.origin.x - 10.0, PANEL.origin.y - 10.0); + assert_eq!( + press_scene_template_center(&mut state, PANEL, outside, 0), + None + ); + assert!( + state.editor_ui.scene_template_center.open, + "falling through must not close the panel; the host decides that" + ); +} + +#[test] +fn a_press_on_empty_panel_space_is_swallowed() { + let mut state = open_state(); + // Between the header text and the close button: inside, but not a control. + let inside = Point2D::new(PANEL.origin.x + 300.0, PANEL.origin.y + 20.0); + assert_eq!( + press_scene_template_center(&mut state, PANEL, inside, 0), + Some(false), + "swallowed without a state change" + ); +} + +#[test] +fn the_close_button_closes_the_panel() { + let mut state = open_state(); + let close = point_in(SceneTemplatePanel::close_rect(PANEL)); + assert_eq!( + press_scene_template_center(&mut state, PANEL, close, 0), + Some(true) + ); + assert!(!state.editor_ui.scene_template_center.open); +} + +#[test] +fn selecting_a_scene_filters_and_resets_scroll_and_hover() { + let mut state = open_state(); + state.editor_ui.scene_template_center.scroll.offset = 120.0; + state.editor_ui.scene_template_center.hover = Some(3); + + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let (rect, filter) = panel + .filter_chip_rects(PANEL) + .into_iter() + .find(|(_, filter)| *filter == SceneFilter::Scene(TemplateScene::Slides)) + .expect("slides chip"); + + assert_eq!( + press_scene_template_center(&mut state, PANEL, point_in(rect), 0), + Some(true) + ); + let center = &state.editor_ui.scene_template_center; + assert_eq!(center.filter, filter); + assert_eq!( + center.scroll.offset, 0.0, + "stale offset points at other cards" + ); + assert_eq!(center.hover, None); + + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let visible = panel.filtered(); + assert!(!visible.is_empty(), "the slides scene ships a template"); + assert!(visible + .iter() + .all(|template| template.scene == TemplateScene::Slides)); +} + +#[test] +fn choosing_a_card_requests_an_open_and_closes_the_panel() { + let mut state = open_state(); + let panel = SceneTemplatePanel::for_editor(&state).expect("open"); + let first = panel.filtered().first().copied().expect("catalogue"); + let expected = first.id.clone(); + let (_, rect) = panel + .card_rects_for_count(PANEL, panel.filtered().len()) + .into_iter() + .next() + .expect("a card rect"); + + assert_eq!( + press_scene_template_center(&mut state, PANEL, point_in(rect), 0), + Some(true) + ); + let center = &mut state.editor_ui.scene_template_center; + assert!(!center.open, "the panel closes once a choice is made"); + assert_eq!( + center.take_pending_open().as_deref(), + Some(expected.as_str()) + ); + assert_eq!( + center.take_pending_open(), + None, + "the request drains exactly once" + ); +} + +#[test] +fn scrolling_is_swallowed_and_clamped_even_when_the_grid_does_not_move() { + let mut state = open_state(); + // Upward past the top: clamps at zero but must not reach the canvas. + assert_eq!( + scroll_scene_template_center(&mut state, PANEL, point_in(PANEL), 50.0), + Some(false) + ); + assert_eq!(state.editor_ui.scene_template_center.scroll.offset, 0.0); + + let outside = Point2D::new(PANEL.origin.x - 5.0, PANEL.origin.y); + assert_eq!( + scroll_scene_template_center(&mut state, PANEL, outside, 50.0), + None, + "outside the panel the canvas keeps the wheel" + ); +} + +#[test] +fn hover_reports_being_over_the_panel_so_hosts_can_gate_canvas_hover() { + let mut state = open_state(); + let close = point_in(SceneTemplatePanel::close_rect(PANEL)); + let (over, changed) = hover_scene_template_center(&mut state, PANEL, close); + assert!(over, "the host needs this to suppress hover underneath"); + assert!(changed); + assert_eq!( + state.editor_ui.scene_template_center.hover, + Some(SCENE_TEMPLATE_CLOSE_HOVER) + ); + + let outside = Point2D::new(PANEL.origin.x - 20.0, PANEL.origin.y); + let (over, changed) = hover_scene_template_center(&mut state, PANEL, outside); + assert!(!over); + assert!(changed, "leaving the panel clears the hover"); + assert_eq!(state.editor_ui.scene_template_center.hover, None); +} + +#[test] +fn opening_one_centre_closes_the_other() { + // Both are full-size centred panels; two open at once would stack. + let mut state = EditorState::default(); + state.editor_ui.open_prompt_center(0); + state.editor_ui.open_scene_template_center(0); + assert!(state.editor_ui.scene_template_center.open); + assert!(!state.editor_ui.prompt_center.open); + + state.editor_ui.open_prompt_center(0); + assert!(state.editor_ui.prompt_center.open); + assert!(!state.editor_ui.scene_template_center.open); +} diff --git a/crates/op-editor-ui/src/widgets/scene_template_previews.rs b/crates/op-editor-ui/src/widgets/scene_template_previews.rs new file mode 100644 index 000000000..62b34bf80 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/scene_template_previews.rs @@ -0,0 +1,72 @@ +//! Compile-time embedded Scene Template card previews. +//! +//! Baked from the full-resolution renders by +//! `templates/step0/_generators/scene_preview_cards.py`; see that script for +//! why a deck is tiled into a grid rather than fitted as a strip. + +/// Cache ids are hand-assigned and must stay stable: the renderer keys its +/// decoded-raster cache on them, so reusing an id for different bytes would +/// serve the wrong image. They start above the Prompt Center's range so the +/// two catalogues can never collide in that shared cache. +const CACHE_ID_BASE: u64 = 10_000; + +macro_rules! preview { + ($offset:expr, $name:literal) => { + Some(( + CACHE_ID_BASE + $offset, + include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/assets/scene_template_previews/", + $name, + ".jpg" + )) + .as_slice(), + )) + }; +} + +/// Return the stable cache id and embedded JPEG bytes for a template. +/// +/// Every shipped template has one — `scene_template_catalog` rejects a +/// catalogue entry without a document, and the preview baker is driven by the +/// same id list — so `None` means an unknown id, not a missing asset. +pub(crate) fn scene_template_preview(template_id: &str) -> Option<(u64, &'static [u8])> { + match template_id { + "screenshot-tutorial" => preview!(1, "screenshot-tutorial"), + "knowledge-carousel" => preview!(2, "knowledge-carousel"), + "before-after" => preview!(3, "before-after"), + "slide-deck" => preview!(4, "slide-deck"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use op_editor_core::scene_template_catalog::scene_template_catalogue; + use std::collections::HashSet; + + #[test] + fn every_shipped_template_has_a_preview_with_a_unique_cache_id() { + let mut ids = HashSet::new(); + for template in scene_template_catalogue() { + let (cache_id, bytes) = scene_template_preview(&template.id) + .unwrap_or_else(|| panic!("{} has no card preview", template.id)); + assert!(!bytes.is_empty(), "{} preview is empty", template.id); + assert!( + ids.insert(cache_id), + "{} reuses cache id {cache_id}", + template.id + ); + } + assert!(scene_template_preview("no-such-template").is_none()); + } + + #[test] + fn previews_are_jpeg_so_the_raster_decoder_accepts_them() { + for template in scene_template_catalogue() { + let (_, bytes) = scene_template_preview(&template.id).expect("preview"); + assert_eq!(&bytes[..2], &[0xFF, 0xD8], "{} is not a JPEG", template.id); + } + } +}