feat(editor): scale the prompt center with the viewport

The 720x520 box becomes an 88%-of-viewport gallery sharing the asset
center's breakpoint functions — columns follow width, cards keep
their aspect, and the shell clamps instead of overflowing on small
windows.

Claude-Session: https://claude.ai/code/session_01FqKQqNj8exYwopGDpYUU7x
This commit is contained in:
Fini 2026-08-09 01:21:57 +08:00
parent 9348f856d1
commit 597cbbeb7d
4 changed files with 391 additions and 116 deletions

View file

@ -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<Rect> {
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<Rect> {
let natural: Vec<f32> = 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::<f32>();
@ -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;

View file

@ -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),
);
}

View file

@ -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<String> {
@ -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<_>>(),
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);

View file

@ -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(