feat(panels): add the scene template center panel
The panel layer for "pick a scene, get a document": header, search, a scene chip row, and a two-column card grid painted from the embedded previews. Geometry, hit-testing, painting, and the press/scroll/hover transitions are all in op-editor-ui, so both hosts can mount it without either growing its own copy. It deliberately mirrors the Prompt Center's shape — same panel size, same chip row, same card grid — because a user who has met one should not have to learn the other. What differs is what a card does: a prompt lands in the chat input, a template becomes a document. So there is no save form, and choosing a card does not open anything here. It raises `pending_open` for the host to drain, because loading a document is a host capability (unsaved-work prompts, recent files) that a widget reaching into would have to reimplement per host. Opening either centre closes the other: both are full-size centred panels and two at once would stack card grids the user cannot see past. Two gates learned from earlier bugs are covered by tests rather than left to review: hover is rejected outside the card viewport, so a pointer under the panel cannot light up a row scrolled out of sight; and `hover_scene_template_center` returns whether the pointer is over the panel at all, which is what a host needs to suppress hover underneath — the exact gate the colour-variable popover was missing on 2026-07-29 when hover fell through to the layer below. Text width comes from the Prompt Center's existing estimate rather than a second model, so both chip rows size the same label identically. Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
parent
048d3cf36f
commit
8a2afc7b51
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
self.pending_open = Some(template_id.into());
|
||||
self.close();
|
||||
}
|
||||
|
||||
/// Drain a pending open request.
|
||||
pub fn take_pending_open(&mut self) -> Option<String> {
|
||||
self.pending_open.take()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<Rect> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
326
crates/op-editor-ui/src/widgets/scene_template_panel.rs
Normal file
326
crates/op-editor-ui/src/widgets/scene_template_panel.rs
Normal file
|
|
@ -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> {
|
||||
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<Self> {
|
||||
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<SceneFilter> {
|
||||
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<usize> {
|
||||
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<SceneTemplateHit> {
|
||||
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;
|
||||
354
crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs
Normal file
354
crates/op-editor-ui/src/widgets/scene_template_panel_paint.rs
Normal file
|
|
@ -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<String> {
|
||||
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
|
||||
}
|
||||
167
crates/op-editor-ui/src/widgets/scene_template_panel_tests.rs
Normal file
167
crates/op-editor-ui/src/widgets/scene_template_panel_tests.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
109
crates/op-editor-ui/src/widgets/scene_template_press_flow.rs
Normal file
109
crates/op-editor-ui/src/widgets/scene_template_press_flow.rs
Normal file
|
|
@ -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<bool> {
|
||||
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<bool> {
|
||||
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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
72
crates/op-editor-ui/src/widgets/scene_template_previews.rs
Normal file
72
crates/op-editor-ui/src/widgets/scene_template_previews.rs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue