diff --git a/crates/op-editor-ui/src/widgets/prompt_center_panel.rs b/crates/op-editor-ui/src/widgets/prompt_center_panel.rs index 02e173c81..cf060bcde 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_panel.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_panel.rs @@ -3,20 +3,60 @@ //! Hosts provide the panel rect and route the returned hit through their //! shared press flow. The widget itself only reads [`EditorState`], so it //! remains usable by both native and wasm hosts. +//! +//! Like the Asset Center next door, this is a gallery rather than a dialog. +//! It used to be a 720x520 box, which on a 1800 px window showed less than +//! two rows of cards inside a frame occupying a fifth of the screen. The +//! panel now takes a fraction of the viewport ([`PROMPT_CENTER_VIEWPORT_W_RATIO`] +//! / [`PROMPT_CENTER_VIEWPORT_H_RATIO`]) and everything inside it — the +//! column count, the card width, the card height — falls out of the rect it +//! was given, so one layout serves a laptop and a 32" display. +//! +//! The chrome metrics (padding, header, rows, chips, card gap and preview +//! aspect) are *imported* from [`super::scene_template_panel`] rather than +//! re-declared, because the two panels are meant to read as one surface +//! family and a second copy of each number is how they would drift apart. use std::borrow::Cow; use op_editor_core::prompt_center_catalog::{prompt_catalogue, PromptCategory}; use op_editor_core::{ButtonPressTarget, EditorState, Locale, PromptFilter}; +use super::scene_template_panel::{ + card_width, grid_columns, preview_height, CARD_GAP, CARD_PREVIEW_ASPECT, CARD_PREVIEW_INSET, + CHIP_H, CHIP_LABEL_SIZE, CLOSE_BTN, FILTER_ROW_H, HEADER_H, PAD, SCENE_TEMPLATE_CONTENT_MAX_W, + SEARCH_PAD_X, SEARCH_ROW_H, SEARCH_TEXT_SIZE, TITLE_SIZE, +}; use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::{Point2D, Rect}; -/// Prompt Center width in logical pixels. -pub const PROMPT_CENTER_PANEL_W: f32 = 720.0; -/// Prompt Center height in logical pixels. -pub const PROMPT_CENTER_PANEL_H: f32 = 520.0; +/// Fraction of the viewport width the Prompt Center spans. +/// +/// A fraction rather than a constant width: the panel is a picture gallery, +/// and the only honest answer to "how big should it be" is "as big as the +/// window can spare". The 12% left over is the margin that keeps it reading +/// as a layer above the editor rather than as a window that replaced it. +pub const PROMPT_CENTER_VIEWPORT_W_RATIO: f32 = 0.88; + +/// Fraction of the space *below the top bar* the Prompt Center spans. +/// +/// Measured against that band rather than the whole viewport so the panel is +/// centred in the room it actually has; centring it in the full height would +/// push its top edge under the top bar on short windows. +pub const PROMPT_CENTER_VIEWPORT_H_RATIO: f32 = 0.88; + +/// Floor for the panel width on a small window. +/// +/// Below this the two-column grid stops being a grid, so the panel gives up +/// the margin before it gives up the second column. Both floors are clamped +/// to the viewport by the caller, so a window smaller than the floor yields a +/// full-bleed panel rather than one hanging off the edge. +pub const PROMPT_CENTER_MIN_W: f32 = 480.0; + +/// Floor for the panel height on a short window — header, search row, filter +/// row, and one card row. +pub const PROMPT_CENTER_MIN_H: f32 = 380.0; /// Reserved hover token for the close button. pub const PROMPT_CENTER_CLOSE_HOVER: usize = usize::MAX; @@ -31,21 +71,23 @@ const FILTER_HOVER_BASE: usize = usize::MAX - 32; const SAVE_CATEGORY_HOVER_BASE: usize = usize::MAX - 64; const DELETE_HOVER_BASE: usize = usize::MAX / 2; -const PAD: f32 = 16.0; -const HEADER_H: f32 = 46.0; -const SEARCH_ROW_H: f32 = 42.0; -const FILTER_ROW_H: f32 = 40.0; const SAVE_FORM_H: f32 = 76.0; -const CLOSE_BTN: f32 = 26.0; -const HEADER_ACTION_H: f32 = 26.0; -const SEARCH_H: f32 = 30.0; -const CHIP_H: f32 = 24.0; -const CHIP_GAP: f32 = 6.0; -const CARD_COLS: usize = 2; -const CARD_GAP: f32 = 12.0; -const CARD_H: f32 = 262.0; -const CARD_PREVIEW_INSET: f32 = 8.0; -const CARD_PREVIEW_ASPECT: f32 = 16.0 / 10.0; +/// Left inset of the save-form title text. Shared by paint and the caret +/// hit-test so a click lands where the glyph is drawn. +const SAVE_TITLE_PAD_X: f32 = 10.0; +const HEADER_ACTION_H: f32 = 30.0; +/// Height of the search field inside [`SEARCH_ROW_H`]. Mirrors the Asset +/// Center's own (private) field height so the two search rows are the same +/// control at the same size. +const SEARCH_H: f32 = 38.0; +const CHIP_GAP: f32 = 8.0; +/// Title, metadata line, and the breathing room around them under a card +/// preview. Fixed while the preview above it flows, so a wider column buys +/// picture rather than whitespace — the same split the Asset Center makes. +const CARD_TEXT_H: f32 = 54.0; +/// Floor the preview leaves for the footer when a caller hands +/// [`PromptCenterPanel::card_preview_rect`] a card shorter than +/// [`card_height`] would have made it. const CARD_FOOTER_MIN_H: f32 = 44.0; const DELETE_BTN: f32 = 24.0; @@ -60,6 +102,15 @@ const FILTERS: [PromptFilter; 8] = [ PromptFilter::Custom, ]; +/// Card height derived from its width. +/// +/// Derived rather than a constant for the same reason the Asset Center's is: +/// a height written for a 720 px dialog letterboxes every preview once the +/// panel is the size of the window. +fn card_height(card_w: f32) -> f32 { + CARD_PREVIEW_INSET + preview_height(card_w) + CARD_TEXT_H +} + /// One filtered card ready for geometry, paint, and an unambiguous click. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PromptCenterCard<'a> { @@ -223,8 +274,8 @@ impl<'a> PromptCenterPanel<'a> { input, Self::search_rect(panel), point, - 12.0, - 32.0, + SEARCH_TEXT_SIZE, + SEARCH_PAD_X, ) .unwrap_or(input.text().len()); return Some(PromptCenterHit::FocusSearch(offset)); @@ -241,8 +292,8 @@ impl<'a> PromptCenterPanel<'a> { input, Self::save_title_rect(panel), point, - 12.0, - 10.0, + SEARCH_TEXT_SIZE, + SAVE_TITLE_PAD_X, ) .unwrap_or(input.text().len()); return Some(PromptCenterHit::FocusSaveTitle(offset)); @@ -292,19 +343,34 @@ impl<'a> PromptCenterPanel<'a> { /// Maximum vertical grid scroll for the current filter and query. pub fn max_scroll(&self, panel: Rect) -> f32 { - let count = self.filtered().len(); - let rows = count.div_ceil(CARD_COLS); - let content_h = if rows == 0 { - 0.0 - } else { - rows as f32 * CARD_H + (rows - 1) as f32 * CARD_GAP - }; - (content_h - self.cards_viewport(panel).size.y).max(0.0) + self.max_scroll_for_count(panel, self.filtered().len()) + } + + /// The centred content column every row inside the panel measures from. + /// + /// The panel grows with the window; its contents do not grow without + /// limit. Everything from the title to the card grid lives in a column + /// capped at [`SCENE_TEMPLATE_CONTENT_MAX_W`] — the Asset Center's cap, + /// shared so the two galleries line up when both have been opened — while + /// only the backdrop, the header rule, and the save form's band run edge + /// to edge. + /// + /// Every rect below derives its x from here, so paint and hit-testing + /// move together by construction. + pub fn content_rect(panel: Rect) -> Rect { + let width = (panel.size.x - PAD * 2.0).clamp(0.0, SCENE_TEMPLATE_CONTENT_MAX_W); + Rect::xywh( + panel.origin.x + ((panel.size.x - width) / 2.0).max(0.0), + panel.origin.y, + width, + panel.size.y, + ) } pub fn close_rect(panel: Rect) -> Rect { + let content = Self::content_rect(panel); Rect::xywh( - panel.origin.x + panel.size.x - PAD - CLOSE_BTN, + content.origin.x + content.size.x - CLOSE_BTN, panel.origin.y + (HEADER_H - CLOSE_BTN) / 2.0, CLOSE_BTN, CLOSE_BTN, @@ -312,26 +378,29 @@ impl<'a> PromptCenterPanel<'a> { } pub fn search_rect(panel: Rect) -> Rect { + let content = Self::content_rect(panel); Rect::xywh( - panel.origin.x + PAD, + content.origin.x, panel.origin.y + HEADER_H + (SEARCH_ROW_H - SEARCH_H) / 2.0, - panel.size.x - PAD * 2.0, + content.size.x, SEARCH_H, ) } pub fn save_title_rect(panel: Rect) -> Rect { + let content = Self::content_rect(panel); Rect::xywh( - panel.origin.x + PAD, + content.origin.x, Self::save_form_top(panel) + 8.0, - 260.0, + (content.size.x * 0.4).clamp(180.0, 320.0), 28.0, ) } pub fn save_button_rect(panel: Rect) -> Rect { + let content = Self::content_rect(panel); Rect::xywh( - panel.origin.x + panel.size.x - PAD - 58.0, + content.origin.x + content.size.x - 58.0, Self::save_form_top(panel) + 8.0, 58.0, 28.0, @@ -380,16 +449,16 @@ impl<'a> PromptCenterPanel<'a> { crate::widgets::text_input::single_line_caret_rect( &prompt_center.search, Self::search_rect(panel), - 12.0, - 32.0, + SEARCH_TEXT_SIZE, + SEARCH_PAD_X, ) } op_editor_core::PromptCenterFocus::SaveTitle => { crate::widgets::text_input::single_line_caret_rect( &prompt_center.save_title, Self::save_title_rect(panel), - 12.0, - 10.0, + SEARCH_TEXT_SIZE, + SAVE_TITLE_PAD_X, ) } } @@ -397,10 +466,11 @@ impl<'a> PromptCenterPanel<'a> { pub fn filter_chip_rects(&self, panel: Rect) -> Vec<(Rect, PromptFilter)> { let labels = FILTERS.map(|filter| self.filter_label(filter)); + let content = Self::content_rect(panel); chip_rects( - panel.origin.x + PAD, - panel.origin.y + HEADER_H + SEARCH_ROW_H + 8.0, - panel.size.x - PAD * 2.0, + content.origin.x, + panel.origin.y + HEADER_H + SEARCH_ROW_H + (FILTER_ROW_H - CHIP_H) / 2.0, + content.size.x, &labels, ) .into_iter() @@ -410,10 +480,11 @@ impl<'a> PromptCenterPanel<'a> { pub fn save_category_rects(&self, panel: Rect) -> Vec<(Rect, PromptCategory)> { let labels = PromptCategory::ALL.map(|category| self.category_label(category)); + let content = Self::content_rect(panel); chip_rects( - panel.origin.x + PAD, - Self::save_form_top(panel) + 43.0, - panel.size.x - PAD * 2.0, + content.origin.x, + Self::save_form_top(panel) + 42.0, + content.size.x, &labels, ) .into_iter() @@ -422,15 +493,30 @@ impl<'a> PromptCenterPanel<'a> { } pub fn cards_viewport(&self, panel: Rect) -> Rect { + let content = Self::content_rect(panel); let top = self.cards_top(panel); Rect::xywh( - panel.origin.x + PAD, + content.origin.x, top, - panel.size.x - PAD * 2.0, + content.size.x, (panel.origin.y + panel.size.y - PAD - top).max(0.0), ) } + /// Column count, card width, and row height of the grid. + /// + /// All three flow from the panel: the columns from how much room the card + /// viewport has (through the Asset Center's [`grid_columns`] breakpoints, + /// shared so the two galleries never disagree about how wide a 16:10 card + /// should be), and the height from the width so the preview keeps its + /// aspect at every breakpoint. + pub(super) fn grid_metrics(&self, panel: Rect) -> (usize, f32, f32) { + let viewport_w = self.cards_viewport(panel).size.x; + let columns = grid_columns(viewport_w); + let card_w = card_width(viewport_w, columns); + (columns, card_w, card_height(card_w)) + } + pub(super) fn save_current_rect(&self, panel: Rect) -> Option { self.can_open_save().then(|| { let close = Self::close_rect(panel); @@ -522,7 +608,7 @@ impl<'a> PromptCenterPanel<'a> { 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 (columns, card_w, card_h) = self.grid_metrics(panel); let scroll = self .state .editor_ui @@ -532,15 +618,15 @@ impl<'a> PromptCenterPanel<'a> { .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 row = index / columns; + let column = index % columns; ( index, Rect::xywh( viewport.origin.x + column as f32 * (card_w + CARD_GAP), - viewport.origin.y + row as f32 * (CARD_H + CARD_GAP) - scroll, + viewport.origin.y + row as f32 * (card_h + CARD_GAP) - scroll, card_w, - CARD_H, + card_h, ), ) }) @@ -548,11 +634,12 @@ impl<'a> PromptCenterPanel<'a> { } fn max_scroll_for_count(&self, panel: Rect, count: usize) -> f32 { - let rows = count.div_ceil(CARD_COLS); + let (columns, _, card_h) = self.grid_metrics(panel); + let rows = count.div_ceil(columns); let content_h = if rows == 0 { 0.0 } else { - rows as f32 * CARD_H + (rows - 1) as f32 * CARD_GAP + rows as f32 * card_h + (rows - 1) as f32 * CARD_GAP }; (content_h - self.cards_viewport(panel).size.y).max(0.0) } @@ -565,10 +652,13 @@ fn custom_matches(title: &str, body: &str, query: &str) -> bool { || body.to_lowercase().contains(&query) } +/// Chip row layout. Widths come from the label, and the whole row scales down +/// only when it would otherwise run past `available_w` — which the gallery +/// sizing makes rare, but a 15-locale chrome can still hit on a narrow window. fn chip_rects(x: f32, y: f32, available_w: f32, labels: &[&str]) -> Vec { let natural: Vec = labels .iter() - .map(|label| (estimated_text_width(label, 11.0) + 20.0).max(48.0)) + .map(|label| (estimated_text_width(label, CHIP_LABEL_SIZE) + 26.0).max(48.0)) .collect(); let gaps = CHIP_GAP * labels.len().saturating_sub(1) as f32; let natural_total = natural.iter().sum::(); @@ -606,6 +696,44 @@ pub(super) fn delete_hover_token(index: usize) -> usize { DELETE_HOVER_BASE + index } +/// Panel rects the hosts would hand the widget, for tests. +/// +/// The panel has no intrinsic size any more, so a test cannot name one; it +/// names the viewport it is standing in instead, and asks the host geometry +/// what that viewport yields — a fixture that cannot drift away from what +/// ships. The three widths straddle both [`grid_columns`] breakpoints, which +/// is the whole reason more than one exists. +#[cfg(test)] +pub(super) mod test_rects { + use op_editor_core::EditorState; + + use crate::widgets::host_overlay_geometry::prompt_center_panel_rect; + use crate::Rect; + + /// The 1200x800 laptop viewport — the default fixture. Three columns. + pub(in crate::widgets) fn medium() -> Rect { + for_viewport(1200.0, 800.0) + } + + /// An 820x620 viewport, small enough that the grid falls back to two + /// columns. + pub(in crate::widgets) fn narrow() -> Rect { + for_viewport(820.0, 620.0) + } + + /// A 2400x1300 viewport — four columns, and wide enough that the content + /// column hits its cap while the shell keeps growing. + pub(in crate::widgets) fn wide() -> Rect { + for_viewport(2400.0, 1300.0) + } + + pub(in crate::widgets) fn for_viewport(viewport_w: f32, viewport_h: f32) -> Rect { + let mut state = EditorState::new(); + state.editor_ui.open_prompt_center(1); + prompt_center_panel_rect(&state, viewport_w, viewport_h).expect("the panel is open") + } +} + #[path = "prompt_center_panel_paint.rs"] mod paint; diff --git a/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs b/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs index 2ec443867..39469c9a6 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_panel_paint.rs @@ -5,9 +5,9 @@ use op_editor_core::PromptCenterFocus; use super::{ delete_hover_token, estimated_text_width, filter_hover_token, save_category_hover_token, - PromptCenterCard, PromptCenterPanel, CARD_H, CHIP_H, CLOSE_BTN, HEADER_H, PAD, + PromptCenterCard, PromptCenterPanel, CHIP_H, CHIP_LABEL_SIZE, CLOSE_BTN, HEADER_H, PROMPT_CENTER_CANCEL_HOVER, PROMPT_CENTER_CLOSE_HOVER, PROMPT_CENTER_OPEN_SAVE_HOVER, - PROMPT_CENTER_SAVE_HOVER, + PROMPT_CENTER_SAVE_HOVER, SEARCH_PAD_X, SEARCH_TEXT_SIZE, TITLE_SIZE, }; use crate::widgets::button::paint_button_feedback_wash; use crate::widgets::canvas_viewport_image::{ @@ -18,12 +18,21 @@ use crate::widgets::property_panel_text_input::paint_text_input_view; use crate::widgets::{draw_icon, Icon, PaintCx}; use crate::{Color, ImageDrawMode, Point2D, Rect, TextLayout}; +/// Corner radius of the gallery frame itself, matching the Asset Center's. +/// Larger than a dropdown's because the shape is read at canvas scale, not +/// at menu scale. +const PANEL_RADIUS: f32 = 16.0; +const CARD_RADIUS: f32 = 12.0; +const CARD_TITLE_SIZE: f32 = 14.0; +const META_SIZE: f32 = 11.0; + impl PromptCenterPanel<'_> { /// 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); + .fill_round_rect(panel, PANEL_RADIUS, self.theme.popover); + cx.backend + .stroke_round_rect(panel, PANEL_RADIUS, self.theme.border, 1.0); self.paint_header(cx, panel); self.paint_search(cx, panel); self.paint_filter_chips(cx, panel); @@ -34,11 +43,18 @@ impl PromptCenterPanel<'_> { } fn paint_header(&self, cx: &mut PaintCx<'_>, panel: Rect) { + let content = Self::content_rect(panel); self.paint_text( cx, self.t("promptCenter.title"), - Point2D::new(panel.origin.x + PAD, panel.origin.y + 29.0), - 15.0, + Point2D::new( + content.origin.x, + jian_widgets::centered_text_baseline_y( + Rect::xywh(content.origin.x, panel.origin.y, content.size.x, HEADER_H), + TITLE_SIZE, + ), + ), + TITLE_SIZE, self.theme.foreground, ); @@ -52,21 +68,31 @@ impl PromptCenterPanel<'_> { self.state.editor_ui.prompt_center.hover == Some(PROMPT_CENTER_OPEN_SAVE_HOVER), self.is_pressed(PROMPT_CENTER_OPEN_SAVE_HOVER), ); + let glyph = 15.0; draw_icon( cx.backend, Icon::Save, - Point2D::new(rect.origin.x + 8.0, rect.origin.y + 6.0), - 14.0, + Point2D::new( + rect.origin.x + 9.0, + rect.origin.y + (rect.size.y - glyph) / 2.0, + ), + glyph, self.theme.muted_foreground, 1.4, ); - let label = - truncate_to_width(self.t("promptCenter.saveCurrent"), rect.size.x - 34.0, 11.0); + let label = truncate_to_width( + self.t("promptCenter.saveCurrent"), + rect.size.x - 38.0, + CHIP_LABEL_SIZE, + ); self.paint_text( cx, &label, - Point2D::new(rect.origin.x + 28.0, rect.origin.y + 17.0), - 11.0, + Point2D::new( + rect.origin.x + 30.0, + jian_widgets::centered_text_baseline_y(rect, CHIP_LABEL_SIZE), + ), + CHIP_LABEL_SIZE, self.theme.foreground, ); } @@ -78,7 +104,7 @@ impl PromptCenterPanel<'_> { pressed: self.is_pressed(PROMPT_CENTER_CLOSE_HOVER), active: false, enabled: true, - icon_size: CLOSE_BTN - 11.0, + icon_size: CLOSE_BTN - 14.0, stroke_width: 1.5, } .paint( @@ -95,13 +121,13 @@ impl PromptCenterPanel<'_> { 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.fill_round_rect(rect, 9.0, self.theme.muted); cx.backend - .stroke_round_rect(rect, 7.0, self.theme.border, 1.0); + .stroke_round_rect(rect, 9.0, self.theme.border, 1.0); draw_icon( cx.backend, Icon::Search, - Point2D::new(rect.origin.x + 9.0, rect.origin.y + 7.0), + Point2D::new(rect.origin.x + 10.0, rect.origin.y + 11.0), 16.0, self.theme.muted_foreground, 1.4, @@ -111,9 +137,9 @@ impl PromptCenterPanel<'_> { &self.theme, &self.state.editor_ui.prompt_center.search, rect, - 12.0, - 32.0, - rect.origin.y + 19.0, + SEARCH_TEXT_SIZE, + SEARCH_PAD_X, + jian_widgets::centered_text_baseline_y(rect, SEARCH_TEXT_SIZE), self.now_ms, self.t("promptCenter.searchPlaceholder"), self.state.editor_ui.prompt_center.focus == PromptCenterFocus::Search, @@ -137,16 +163,20 @@ impl PromptCenterPanel<'_> { self.state.editor_ui.prompt_center.hover == Some(filter_hover_token(index)), self.is_pressed(filter_hover_token(index)), ); - let label = truncate_to_width(self.filter_label(filter), rect.size.x - 14.0, 11.0); - let label_w = estimated_text_width(&label, 11.0); + let label = truncate_to_width( + self.filter_label(filter), + rect.size.x - 14.0, + CHIP_LABEL_SIZE, + ); + 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, + jian_widgets::centered_text_baseline_y(rect, CHIP_LABEL_SIZE), ), - 11.0, + CHIP_LABEL_SIZE, foreground, ); } @@ -180,9 +210,9 @@ impl PromptCenterPanel<'_> { &self.theme, &self.state.editor_ui.prompt_center.save_title, title, - 12.0, + SEARCH_TEXT_SIZE, 10.0, - title.origin.y + 18.0, + jian_widgets::centered_text_baseline_y(title, SEARCH_TEXT_SIZE), self.now_ms, self.t("promptCenter.saveTitlePlaceholder"), self.state.editor_ui.prompt_center.focus == PromptCenterFocus::SaveTitle, @@ -223,16 +253,17 @@ impl PromptCenterPanel<'_> { self.state.editor_ui.prompt_center.hover == Some(save_category_hover_token(index)), self.is_pressed(save_category_hover_token(index)), ); - let label = truncate_to_width(self.category_label(category), rect.size.x - 14.0, 10.5); - let label_w = estimated_text_width(&label, 10.5); + let label = + truncate_to_width(self.category_label(category), rect.size.x - 14.0, META_SIZE); + let label_w = estimated_text_width(&label, META_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, + jian_widgets::centered_text_baseline_y(rect, META_SIZE), ), - 10.5, + META_SIZE, if active { self.theme.foreground } else { @@ -260,16 +291,16 @@ impl PromptCenterPanel<'_> { if enabled { paint_button_feedback_wash(cx.backend, &self.theme, rect, 6.0, hovered, pressed); } - let text = truncate_to_width(label, rect.size.x - 12.0, 11.0); - let width = estimated_text_width(&text, 11.0); + let text = truncate_to_width(label, rect.size.x - 12.0, CHIP_LABEL_SIZE); + let width = estimated_text_width(&text, CHIP_LABEL_SIZE); self.paint_text( cx, &text, Point2D::new( rect.origin.x + ((rect.size.x - width) / 2.0).max(5.0), - rect.origin.y + 18.0, + jian_widgets::centered_text_baseline_y(rect, CHIP_LABEL_SIZE), ), - 11.0, + CHIP_LABEL_SIZE, if enabled { self.theme.primary_foreground } else { @@ -296,7 +327,7 @@ impl PromptCenterPanel<'_> { cx.backend.save(); cx.backend.clip_rect(viewport); for (index, rect) in self.card_rects_for_count(panel, cards.len()) { - if rect.origin.y + CARD_H <= viewport.origin.y || rect.origin.y >= bottom { + if rect.origin.y + rect.size.y <= viewport.origin.y || rect.origin.y >= bottom { continue; } self.paint_card(cx, index, rect, &cards[index]); @@ -311,9 +342,10 @@ impl PromptCenterPanel<'_> { rect: Rect, card: &PromptCenterCard<'_>, ) { - cx.backend.fill_round_rect(rect, 9.0, self.theme.card); cx.backend - .stroke_round_rect(rect, 9.0, self.theme.border, 1.0); + .fill_round_rect(rect, CARD_RADIUS, self.theme.card); + cx.backend + .stroke_round_rect(rect, CARD_RADIUS, self.theme.border, 1.0); let preview = Self::card_preview_rect(rect); self.paint_card_preview(cx, preview, card); @@ -321,21 +353,25 @@ impl PromptCenterPanel<'_> { cx.backend, &self.theme, rect, - 9.0, + CARD_RADIUS, self.state.editor_ui.prompt_center.hover == Some(index), self.is_pressed(index), ); let title_right_pad = 14.0; - let title = truncate_to_width(&card.title, rect.size.x - 14.0 - title_right_pad, 12.5); + let title = truncate_to_width( + &card.title, + rect.size.x - 14.0 - title_right_pad, + CARD_TITLE_SIZE, + ); self.paint_text( cx, &title, Point2D::new( - rect.origin.x + 12.0, - preview.origin.y + preview.size.y + 22.0, + rect.origin.x + 14.0, + preview.origin.y + preview.size.y + 24.0, ), - 12.5, + CARD_TITLE_SIZE, self.theme.foreground, ); @@ -350,9 +386,9 @@ impl PromptCenterPanel<'_> { if !metadata.is_empty() { self.paint_text( cx, - &truncate_to_width(&metadata, rect.size.x - 24.0, 10.0), - Point2D::new(rect.origin.x + 12.0, rect.origin.y + rect.size.y - 12.0), - 10.0, + &truncate_to_width(&metadata, rect.size.x - 28.0, META_SIZE), + Point2D::new(rect.origin.x + 14.0, rect.origin.y + rect.size.y - 14.0), + META_SIZE, self.theme.muted_foreground.with_alpha(0.85), ); } diff --git a/crates/op-editor-ui/src/widgets/prompt_center_panel_tests.rs b/crates/op-editor-ui/src/widgets/prompt_center_panel_tests.rs index 6f1add718..c2060abf0 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_panel_tests.rs @@ -1,7 +1,8 @@ use op_editor_core::prompt_center_catalog::PromptCategory; use op_editor_core::{CustomPrompt, EditorState, Locale, PromptFilter}; -use super::{PromptCenterHit, PromptCenterPanel, PROMPT_CENTER_PANEL_H, PROMPT_CENTER_PANEL_W}; +use super::test_rects; +use super::{PromptCenterHit, PromptCenterPanel}; use crate::widgets::canvas_viewport_image::{ lock_decode_registry_for_tests, mark_decode_done, take_pending_decodes, }; @@ -17,7 +18,7 @@ fn open_state(locale: Locale) -> EditorState { } fn panel_rect() -> Rect { - Rect::xywh(30.0, 40.0, PROMPT_CENTER_PANEL_W, PROMPT_CENTER_PANEL_H) + test_rects::medium() } fn filtered_ids(state: &EditorState) -> Vec { @@ -54,20 +55,25 @@ fn travel_search_matches_both_chinese_and_english() { #[test] fn category_filter_only_returns_that_category() { let mut state = open_state(Locale::EnUs); - let expected = [ + // Slices rather than fixed-size arrays: the categories do not all hold + // the same number of prompts, and the tuple list has to stay one type. + let expected: [(PromptCategory, Option<&[&str]>); 5] = [ (PromptCategory::Starter, None), - (PromptCategory::WebPage, Some(["web-orbit", "web-atelier"])), + ( + PromptCategory::WebPage, + Some(&["web-orbit", "web-atelier", "web-kilnform", "web-reefwright"]), + ), ( PromptCategory::Dashboard, - Some(["dashboard-pulse", "dashboard-sentinel"]), + Some(&["dashboard-pulse", "dashboard-sentinel"]), ), ( PromptCategory::Component, - Some(["component-data-grid", "component-form-lab"]), + Some(&["component-data-grid", "component-form-lab"]), ), ( PromptCategory::Modify, - Some(["modify-polish-current", "modify-complete-states"]), + Some(&["modify-polish-current", "modify-complete-states"]), ), ]; @@ -111,17 +117,30 @@ fn unmatched_query_produces_empty_result() { .is_empty()); } +/// Cards on the first row, in x order — the row is the grid's column count. +fn first_row_columns(state: &EditorState, rect: Rect) -> usize { + let panel = PromptCenterPanel::for_editor(state).expect("open panel"); + let rects = panel.card_rects(rect); + let top = rects[0].1.origin.y; + rects + .iter() + .take_while(|(_, card)| card.origin.y == top) + .count() +} + #[test] -fn grid_places_cards_in_two_columns() { +fn grid_lays_cards_out_in_rows_and_columns() { let state = open_state(Locale::EnUs); let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); - let rects = panel.card_rects(panel_rect()); - assert!(rects.len() >= 3); + let rect = panel_rect(); + let rects = panel.card_rects(rect); + let columns = first_row_columns(&state, rect); + assert!(rects.len() > columns, "need more than one row to test rows"); + assert_eq!(rects[0].1.origin.y, rects[1].1.origin.y); assert!(rects[1].1.origin.x > rects[0].1.origin.x); - assert_eq!(rects[0].1.origin.x, rects[2].1.origin.x); - assert!(rects[2].1.origin.y > rects[0].1.origin.y); - assert_eq!(rects[0].1.size.y, 262.0); + assert_eq!(rects[0].1.origin.x, rects[columns].1.origin.x); + assert!(rects[columns].1.origin.y > rects[0].1.origin.y); let preview = PromptCenterPanel::card_preview_rect(rects[0].1); assert!(rects[0].1.contains(preview.origin)); @@ -132,6 +151,91 @@ fn grid_places_cards_in_two_columns() { assert!(preview.origin.y + preview.size.y < rects[0].1.origin.y + rects[0].1.size.y); } +/// The whole point of the resize: a wider panel buys more cards per row, and +/// the card height tracks the width so the preview keeps its aspect. +/// +/// This is what a fixed `CARD_COLS = 2` / `CARD_H = 262` pair cannot do — put +/// either back and the first assertion fails on the very first pair. +#[test] +fn grid_columns_and_card_height_follow_panel_width() { + let state = open_state(Locale::EnUs); + let mut seen: Vec<(usize, f32, f32)> = Vec::new(); + for rect in [ + test_rects::narrow(), + test_rects::medium(), + test_rects::wide(), + ] { + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let card = panel.card_rects(rect)[0].1; + seen.push((first_row_columns(&state, rect), card.size.x, card.size.y)); + } + assert_eq!( + seen.iter().map(|entry| entry.0).collect::>(), + vec![2, 3, 4], + "column count must step up with the viewport" + ); + for entry in &seen { + let expected_h = 10.0 + (entry.1 - 20.0) / (16.0 / 10.0) + 54.0; + assert!( + (entry.2 - expected_h).abs() < 0.01, + "card height must derive from its width, got {entry:?}" + ); + } +} + +/// A wider panel must not sprout an ever-wider content column: past the cap +/// the extra width becomes margin, exactly as in the Asset Center. +#[test] +fn content_column_stops_widening_at_the_shared_cap() { + let wide = test_rects::wide(); + let content = PromptCenterPanel::content_rect(wide); + assert!( + content.size.x < wide.size.x, + "the cap must leave margin on a 2400 px viewport" + ); + assert_eq!( + content.size.x, + crate::widgets::SCENE_TEMPLATE_CONTENT_MAX_W, + "the Prompt Center shares the Asset Center content cap" + ); + let centre = wide.origin.x + wide.size.x / 2.0; + assert!( + (content.origin.x + content.size.x / 2.0 - centre).abs() < 0.01, + "the content column must stay centred in the panel" + ); +} + +/// Chrome rows have to move with the shell — a search field or a close button +/// still pinned to a 720 px-wide box would sit in the panel's top-left corner. +#[test] +fn chrome_rows_span_the_content_column() { + let state = open_state(Locale::EnUs); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let rect = test_rects::wide(); + let content = PromptCenterPanel::content_rect(rect); + + let search = PromptCenterPanel::search_rect(rect); + assert_eq!(search.origin.x, content.origin.x); + assert_eq!(search.size.x, content.size.x); + + let close = PromptCenterPanel::close_rect(rect); + assert_eq!( + close.origin.x + close.size.x, + content.origin.x + content.size.x + ); + + let chips = panel.filter_chip_rects(rect); + assert_eq!(chips[0].0.origin.x, content.origin.x); + assert!( + chips.last().expect("filters").0.origin.x > content.origin.x, + "the filter row must run along the content column" + ); + assert!( + panel.cards_viewport(rect).origin.y > chips[0].0.origin.y + chips[0].0.size.y, + "cards start below the filter row" + ); +} + #[test] fn body_language_follows_cjk_locale_boundary() { let zh = open_state(Locale::ZhCn); @@ -290,7 +394,7 @@ fn read_only_custom_card_does_not_expose_delete() { fn clipped_custom_delete_does_not_steal_hover_from_panel_chrome() { let mut state = open_state(Locale::EnUs); state.editor_ui.prompt_center.install_custom_prompts( - (0..4) + (0..12) .map(|index| CustomPrompt { id: format!("custom-{index}"), title: format!("Reusable {index}"), @@ -302,7 +406,9 @@ fn clipped_custom_delete_does_not_steal_hover_from_panel_chrome() { true, ); state.editor_ui.prompt_center.filter = PromptFilter::Custom; - let rect = panel_rect(); + // The narrow fixture: the grid has to overflow its viewport for a card to + // be scrollable up under the chrome at all. + let rect = test_rects::narrow(); let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); let delete = PromptCenterPanel::delete_rect(panel.card_rects(rect)[0].1); let search = PromptCenterPanel::search_rect(rect); diff --git a/crates/op-editor-ui/src/widgets/prompt_center_shared_flow_tests.rs b/crates/op-editor-ui/src/widgets/prompt_center_shared_flow_tests.rs index 56365ac2c..aa8382edf 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_shared_flow_tests.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_shared_flow_tests.rs @@ -1,6 +1,7 @@ use op_editor_core::EditorState; use super::{cursor_hover_flow, host_overlay_geometry, scroll_flow, PromptCenterPanel}; +use crate::widgets::TOP_BAR_HEIGHT; use crate::{Point2D, Rect}; fn open_state() -> EditorState { @@ -98,7 +99,11 @@ fn small_viewport_keeps_the_entire_panel_and_close_action_visible() { assert!(rect.origin.y >= 0.0); assert!(rect.origin.x + rect.size.x <= viewport_w); assert!(rect.origin.y + rect.size.y <= viewport_h); - assert_eq!(rect.size, Point2D::new(viewport_w, viewport_h)); + // The panel scales with the window rather than filling it, but on a + // window this short the height floor is what it gets — clamped to the + // space below the top bar so nothing hangs off the bottom edge. + assert!(rect.size.x < viewport_w && rect.size.x > viewport_w / 2.0); + assert!(rect.size.y <= viewport_h - TOP_BAR_HEIGHT); let close = PromptCenterPanel::close_rect(rect); let close_center = Point2D::new(