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:
parent
9348f856d1
commit
597cbbeb7d
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue