diff --git a/crates/op-editor-ui/assets/prompt_center_previews/extreme-calendar.jpg b/crates/op-editor-ui/assets/prompt_center_previews/extreme-calendar.jpg new file mode 100644 index 000000000..be2afd581 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/extreme-calendar.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/extreme-calm.jpg b/crates/op-editor-ui/assets/prompt_center_previews/extreme-calm.jpg new file mode 100644 index 000000000..50c5cd0ba Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/extreme-calm.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/extreme-daily-app.jpg b/crates/op-editor-ui/assets/prompt_center_previews/extreme-daily-app.jpg new file mode 100644 index 000000000..fc48872b4 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/extreme-daily-app.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/extreme-now-playing.jpg b/crates/op-editor-ui/assets/prompt_center_previews/extreme-now-playing.jpg new file mode 100644 index 000000000..266c4ac41 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/extreme-now-playing.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/extreme-weather.jpg b/crates/op-editor-ui/assets/prompt_center_previews/extreme-weather.jpg new file mode 100644 index 000000000..14a686080 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/extreme-weather.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-aloft.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-aloft.jpg new file mode 100644 index 000000000..644010e3b Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-aloft.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-bloom.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-bloom.jpg new file mode 100644 index 000000000..127aceb77 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-bloom.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-companion.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-companion.jpg new file mode 100644 index 000000000..a5b2549e4 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-companion.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-daybreak.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-daybreak.jpg new file mode 100644 index 000000000..1cbdaabcc Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-daybreak.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-ember.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-ember.jpg new file mode 100644 index 000000000..1e5698e36 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-ember.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-forage.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-forage.jpg new file mode 100644 index 000000000..b48b7601a Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-forage.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-gallery.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-gallery.jpg new file mode 100644 index 000000000..bbe9e7666 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-gallery.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-hearth.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-hearth.jpg new file mode 100644 index 000000000..e3b1271b2 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-hearth.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-lingua.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-lingua.jpg new file mode 100644 index 000000000..815d210eb Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-lingua.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-marginalia.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-marginalia.jpg new file mode 100644 index 000000000..e26586411 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-marginalia.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-marquee.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-marquee.jpg new file mode 100644 index 000000000..591a88745 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-marquee.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-meteo.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-meteo.jpg new file mode 100644 index 000000000..0ffd61543 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-meteo.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-nightcap.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-nightcap.jpg new file mode 100644 index 000000000..c4c379719 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-nightcap.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-nocturne.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-nocturne.jpg new file mode 100644 index 000000000..96ae38c6a Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-nocturne.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-relic.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-relic.jpg new file mode 100644 index 000000000..2a219a612 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-relic.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-ritual.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-ritual.jpg new file mode 100644 index 000000000..a7c996738 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-ritual.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-still.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-still.jpg new file mode 100644 index 000000000..3a2e5e53d Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-still.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-verdant.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-verdant.jpg new file mode 100644 index 000000000..5422332ca Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-verdant.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-volt.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-volt.jpg new file mode 100644 index 000000000..31c79dcef Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-volt.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/freeform-wander.jpg b/crates/op-editor-ui/assets/prompt_center_previews/freeform-wander.jpg new file mode 100644 index 000000000..81fcc0001 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/freeform-wander.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-aloft.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-aloft.jpg new file mode 100644 index 000000000..ea26ac608 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-aloft.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-bloom.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-bloom.jpg new file mode 100644 index 000000000..b8367abf1 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-bloom.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-companion.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-companion.jpg new file mode 100644 index 000000000..00b05f5ba Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-companion.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-daybreak.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-daybreak.jpg new file mode 100644 index 000000000..28fbf4b1f Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-daybreak.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-ember.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-ember.jpg new file mode 100644 index 000000000..d4f83af46 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-ember.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-forage.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-forage.jpg new file mode 100644 index 000000000..95414f516 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-forage.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-gallery.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-gallery.jpg new file mode 100644 index 000000000..70a70b10d Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-gallery.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-hearth.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-hearth.jpg new file mode 100644 index 000000000..64270fbd2 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-hearth.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-lingua.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-lingua.jpg new file mode 100644 index 000000000..72727230e Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-lingua.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-marginalia.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-marginalia.jpg new file mode 100644 index 000000000..a196cb683 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-marginalia.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-marquee.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-marquee.jpg new file mode 100644 index 000000000..bd665b663 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-marquee.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-meteo.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-meteo.jpg new file mode 100644 index 000000000..78aa846b6 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-meteo.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-nightcap.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-nightcap.jpg new file mode 100644 index 000000000..5ad52ba10 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-nightcap.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-nocturne.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-nocturne.jpg new file mode 100644 index 000000000..ef196669c Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-nocturne.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-relic.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-relic.jpg new file mode 100644 index 000000000..f6f9398b2 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-relic.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-ritual.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-ritual.jpg new file mode 100644 index 000000000..34ac3d9ba Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-ritual.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-still.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-still.jpg new file mode 100644 index 000000000..d6801ee0d Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-still.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-verdant.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-verdant.jpg new file mode 100644 index 000000000..7c2ebf3f3 Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-verdant.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-volt.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-volt.jpg new file mode 100644 index 000000000..a48f5e63c Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-volt.jpg differ diff --git a/crates/op-editor-ui/assets/prompt_center_previews/gallery-wander.jpg b/crates/op-editor-ui/assets/prompt_center_previews/gallery-wander.jpg new file mode 100644 index 000000000..b4d4ada8d Binary files /dev/null and b/crates/op-editor-ui/assets/prompt_center_previews/gallery-wander.jpg differ diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 0e1e6b209..98c06c4c0 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -46,6 +46,7 @@ mod layer_panel_tests; mod layer_panel_walkers; pub mod path_anchor_context_menu; pub mod prompt_center_panel; +pub(crate) mod prompt_center_previews; #[cfg(test)] mod prompt_center_shared_flow_tests; pub mod property_panel; 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 200f8682c..02e173c81 100644 --- a/crates/op-editor-ui/src/widgets/prompt_center_panel.rs +++ b/crates/op-editor-ui/src/widgets/prompt_center_panel.rs @@ -43,7 +43,10 @@ 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 = 112.0; +const CARD_H: f32 = 262.0; +const CARD_PREVIEW_INSET: f32 = 8.0; +const CARD_PREVIEW_ASPECT: f32 = 16.0 / 10.0; +const CARD_FOOTER_MIN_H: f32 = 44.0; const DELETE_BTN: f32 = 24.0; const FILTERS: [PromptFilter; 8] = [ @@ -182,14 +185,18 @@ impl<'a> PromptCenterPanel<'a> { } } let cards = self.filtered(); + let viewport = self.cards_viewport(panel); for (index, rect) in self.card_rects_for_count(panel, cards.len()) { + if !viewport.contains(point) { + continue; + } if cards[index].custom && self.state.editor_ui.prompt_center.custom_store_writable && Self::delete_rect(rect).contains(point) { return Some(delete_hover_token(index)); } - if rect.contains(point) && self.cards_viewport(panel).contains(point) { + if rect.contains(point) { return Some(index); } } @@ -345,6 +352,19 @@ impl<'a> PromptCenterPanel<'a> { ) } + /// Preview region within a card, preserving a 16:10 thumbnail aspect ratio. + pub fn card_preview_rect(card: Rect) -> Rect { + let width = (card.size.x - CARD_PREVIEW_INSET * 2.0).max(0.0); + let available_height = + (card.size.y - CARD_PREVIEW_INSET * 2.0 - CARD_FOOTER_MIN_H).max(0.0); + Rect::xywh( + card.origin.x + CARD_PREVIEW_INSET, + card.origin.y + CARD_PREVIEW_INSET, + width, + (width / CARD_PREVIEW_ASPECT).min(available_height), + ) + } + /// Whether `point` is over one of the panel's visible text inputs. pub fn text_input_at(&self, panel: Rect, point: Point2D) -> bool { Self::search_rect(panel).contains(point) 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 ce54ad223..e20cbb67f 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 @@ -1,5 +1,6 @@ //! Prompt Center painting, split from geometry to keep both files compact. +use op_editor_core::prompt_center_catalog::PromptCategory; use op_editor_core::PromptCenterFocus; use super::{ @@ -9,9 +10,13 @@ use super::{ PROMPT_CENTER_SAVE_HOVER, }; use crate::widgets::button::paint_button_feedback_wash; +use crate::widgets::canvas_viewport_image::{ + has_cached_image_bytes, note_pending_decode, required_raster_edge, store_remote_image_bytes, +}; +use crate::widgets::prompt_center_previews::prompt_center_preview; use crate::widgets::property_panel_text_input::paint_text_input_view; use crate::widgets::{draw_icon, Icon, PaintCx}; -use crate::{Color, Point2D, Rect, TextLayout}; +use crate::{Color, ImageDrawMode, Point2D, Rect, TextLayout}; impl PromptCenterPanel<'_> { /// Paint the complete non-modal panel. @@ -309,6 +314,9 @@ impl PromptCenterPanel<'_> { cx.backend.fill_round_rect(rect, 9.0, self.theme.card); cx.backend .stroke_round_rect(rect, 9.0, self.theme.border, 1.0); + + let preview = Self::card_preview_rect(rect); + self.paint_card_preview(cx, preview, card); paint_button_feedback_wash( cx.backend, &self.theme, @@ -318,36 +326,19 @@ impl PromptCenterPanel<'_> { self.is_pressed(index), ); - let title_right_pad = - if card.custom && self.state.editor_ui.prompt_center.custom_store_writable { - 42.0 - } else { - 14.0 - }; + let title_right_pad = 14.0; let title = truncate_to_width(&card.title, rect.size.x - 14.0 - title_right_pad, 12.5); self.paint_text( cx, &title, - Point2D::new(rect.origin.x + 12.0, rect.origin.y + 23.0), + Point2D::new( + rect.origin.x + 12.0, + preview.origin.y + preview.size.y + 22.0, + ), 12.5, self.theme.foreground, ); - let summary_width = rect.size.x - 24.0; - let lines = summary_lines(card.body, summary_width, 11.0); - for (line_index, line) in lines.iter().enumerate() { - self.paint_text( - cx, - line, - Point2D::new( - rect.origin.x + 12.0, - rect.origin.y + 47.0 + line_index as f32 * 17.0, - ), - 11.0, - self.theme.muted_foreground, - ); - } - let metadata = { let built_in = self.metadata(card); if built_in.is_empty() && card.custom { @@ -368,6 +359,8 @@ impl PromptCenterPanel<'_> { if card.custom && self.state.editor_ui.prompt_center.custom_store_writable { let delete = Self::delete_rect(rect); + cx.backend + .fill_round_rect(delete, 6.0, self.theme.popover.with_alpha(0.88)); paint_button_feedback_wash( cx.backend, &self.theme, @@ -387,6 +380,81 @@ impl PromptCenterPanel<'_> { } } + fn paint_card_preview(&self, cx: &mut PaintCx<'_>, preview: Rect, card: &PromptCenterCard<'_>) { + let Some((image_id, encoded)) = prompt_center_preview(card.id.as_ref()) else { + self.paint_preview_fallback(cx, preview, card); + return; + }; + if !has_cached_image_bytes(image_id) { + store_remote_image_bytes(image_id, encoded.to_vec()); + } + let max_edge_px = required_raster_edge(preview, cx.backend.dpi_scale()); + let sharp = cx.backend.image_decoded(image_id, encoded, max_edge_px); + if !sharp { + note_pending_decode(image_id, max_edge_px); + } + if !sharp && !cx.backend.image_resident(image_id) { + self.paint_preview_fallback(cx, preview, card); + return; + } + + cx.backend.save(); + cx.backend.clip_round_rect(preview, 7.0); + cx.backend + .draw_image_with_mode(preview, image_id, encoded, ImageDrawMode::Fill); + cx.backend.restore(); + } + + fn paint_preview_fallback( + &self, + cx: &mut PaintCx<'_>, + preview: Rect, + card: &PromptCenterCard<'_>, + ) { + let accent = match card.category { + PromptCategory::Starter => self.theme.primary, + PromptCategory::MobileApp => Color::rgb_u8(124, 92, 255), + PromptCategory::WebPage => Color::rgb_u8(14, 165, 233), + PromptCategory::Dashboard => self.theme.status_success, + PromptCategory::Component => self.theme.status_warning, + PromptCategory::Modify => Color::rgb_u8(236, 72, 153), + }; + cx.backend.fill_round_rect_linear_gradient( + preview, + 7.0, + &[ + (0.0, accent.with_alpha(0.42)), + (1.0, self.theme.muted.with_alpha(0.92)), + ], + 135.0, + 1.0, + ); + let icon = if card.custom { + Icon::Pencil + } else { + match card.category { + PromptCategory::Starter => Icon::Library, + PromptCategory::MobileApp => Icon::Smartphone, + PromptCategory::WebPage => Icon::Chrome, + PromptCategory::Dashboard => Icon::LayoutGrid, + PromptCategory::Component => Icon::Component, + PromptCategory::Modify => Icon::Wand2, + } + }; + let icon_size = 34.0; + draw_icon( + cx.backend, + icon, + Point2D::new( + preview.origin.x + (preview.size.x - icon_size) / 2.0, + preview.origin.y + (preview.size.y - icon_size) / 2.0, + ), + icon_size, + self.theme.foreground.with_alpha(0.72), + 1.35, + ); + } + fn paint_text( &self, cx: &mut PaintCx<'_>, @@ -424,32 +492,3 @@ fn truncate_to_width(text: &str, max_width: f32, size: f32) -> String { output.push('…'); output } - -fn summary_lines(text: &str, max_width: f32, size: f32) -> [String; 2] { - let normalized = text.split_whitespace().collect::>().join(" "); - let mut lines = [String::new(), String::new()]; - let mut line = 0; - let mut width = 0.0; - let mut truncated = false; - for ch in normalized.chars() { - let advance = estimated_text_width(&ch.to_string(), size); - if width + advance > max_width { - if line == 1 { - truncated = true; - break; - } - line = 1; - width = 0.0; - } - lines[line].push(ch); - width += advance; - } - if truncated { - let second = lines[1].trim_end_matches('…').to_owned(); - lines[1] = truncate_to_width(&(second + "…"), max_width, size); - if !lines[1].ends_with('…') { - lines[1].push('…'); - } - } - lines -} 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 f39036890..64b339dbe 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 @@ -2,7 +2,12 @@ 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 crate::{Point2D, Rect}; +use crate::widgets::canvas_viewport_image::{ + lock_decode_registry_for_tests, mark_decode_done, take_pending_decodes, +}; +use crate::widgets::property_panel_test_support::CountingBackend; +use crate::widgets::PaintCx; +use crate::{ImageDrawMode, Point2D, Rect}; fn open_state(locale: Locale) -> EditorState { let mut state = EditorState::new(); @@ -24,6 +29,18 @@ fn filtered_ids(state: &EditorState) -> Vec { .collect() } +fn visible_card_count(panel: &PromptCenterPanel<'_>, rect: Rect) -> usize { + let viewport = panel.cards_viewport(rect); + let bottom = viewport.origin.y + viewport.size.y; + panel + .card_rects(rect) + .into_iter() + .filter(|(_, card)| { + card.origin.y + card.size.y > viewport.origin.y && card.origin.y < bottom + }) + .count() +} + #[test] fn travel_search_matches_both_chinese_and_english() { let mut state = open_state(Locale::ZhCn); @@ -70,14 +87,22 @@ fn unmatched_query_produces_empty_result() { #[test] fn grid_places_cards_in_two_columns() { let state = open_state(Locale::EnUs); - let rects = PromptCenterPanel::for_editor(&state) - .expect("open panel") - .card_rects(panel_rect()); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let rects = panel.card_rects(panel_rect()); assert!(rects.len() >= 3); 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); + + let preview = PromptCenterPanel::card_preview_rect(rects[0].1); + assert!(rects[0].1.contains(preview.origin)); + assert!( + (preview.size.x / preview.size.y - 16.0 / 10.0).abs() < 0.001, + "preview must remain 16:10" + ); + assert!(preview.origin.y + preview.size.y < rects[0].1.origin.y + rects[0].1.size.y); } #[test] @@ -149,6 +174,31 @@ fn outside_inside_and_card_hits_are_distinct() { } } +#[test] +fn scrolled_grid_hits_the_card_visible_at_the_viewport_top() { + let mut state = open_state(Locale::EnUs); + let rect = panel_rect(); + let row_step = { + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let cards = panel.card_rects(rect); + cards[2].1.origin.y - cards[0].1.origin.y + }; + state.editor_ui.prompt_center.scroll.offset = row_step; + + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let cards = panel.card_rects(rect); + let viewport = panel.cards_viewport(rect); + assert_eq!(cards[2].1.origin.y, viewport.origin.y); + let expected_id = panel.filtered()[2].id.to_string(); + assert!(matches!( + panel.hit_test( + rect, + Point2D::new(cards[2].1.origin.x + 12.0, cards[2].1.origin.y + 12.0), + ), + Some(PromptCenterHit::SelectPrompt { id, .. }) if id == expected_id + )); +} + #[test] fn custom_delete_has_its_own_hit_target() { let mut state = open_state(Locale::EnUs); @@ -209,22 +259,142 @@ fn read_only_custom_card_does_not_expose_delete() { )); } +#[test] +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) + .map(|index| CustomPrompt { + id: format!("custom-{index}"), + title: format!("Reusable {index}"), + body: "Reusable prompt body".to_owned(), + category: PromptCategory::Modify, + created_at: index, + }) + .collect(), + true, + ); + state.editor_ui.prompt_center.filter = PromptFilter::Custom; + let rect = panel_rect(); + 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); + state.editor_ui.prompt_center.scroll.offset = + delete.origin.y + delete.size.y / 2.0 - (search.origin.y + search.size.y / 2.0); + + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let hidden_delete = PromptCenterPanel::delete_rect(panel.card_rects(rect)[0].1); + let point = Point2D::new( + hidden_delete.origin.x + hidden_delete.size.x / 2.0, + hidden_delete.origin.y + hidden_delete.size.y / 2.0, + ); + assert!(search.contains(point)); + assert_eq!(panel.hover_at(rect, point), None); +} + #[test] fn max_scroll_tracks_filtered_grid_height() { let mut state = open_state(Locale::EnUs); let rect = panel_rect(); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let viewport = panel.cards_viewport(rect); + let last = panel.card_rects(rect).last().expect("catalogue card").1; + assert_eq!( + panel.max_scroll(rect), + last.origin.y + last.size.y - (viewport.origin.y + viewport.size.y) + ); + + state.editor_ui.prompt_center.filter = PromptFilter::Category(PromptCategory::Starter); assert!( PromptCenterPanel::for_editor(&state) .expect("open panel") .max_scroll(rect) - > 0.0 + > 0.0, + "two thumbnail rows must overflow the card viewport" ); - state.editor_ui.prompt_center.filter = PromptFilter::Category(PromptCategory::Starter); + state.editor_ui.prompt_center.install_custom_prompts( + vec![CustomPrompt { + id: "custom-only".to_owned(), + title: "Reusable".to_owned(), + body: "Reusable prompt body".to_owned(), + category: PromptCategory::Modify, + created_at: 1, + }], + true, + ); + state.editor_ui.prompt_center.filter = PromptFilter::Custom; assert_eq!( PromptCenterPanel::for_editor(&state) .expect("open panel") .max_scroll(rect), - 0.0 + 0.0, + "a single thumbnail card must not scroll" ); } + +#[test] +fn paint_decodes_only_generated_previews_visible_in_the_viewport() { + let state = open_state(Locale::EnUs); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let expected = visible_card_count(&panel, panel_rect()); + let mut backend = CountingBackend::default(); + panel.paint( + &mut PaintCx { + backend: &mut backend, + }, + panel_rect(), + ); + + assert_eq!(backend.images.len(), expected); + assert_eq!(backend.image_modes.len(), backend.images.len()); + assert!(backend + .image_modes + .iter() + .all(|mode| *mode == ImageDrawMode::Fill)); +} + +#[test] +fn first_paint_queues_only_visible_previews_and_uses_fallbacks() { + let _guard = lock_decode_registry_for_tests(); + let state = open_state(Locale::EnUs); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let expected = visible_card_count(&panel, panel_rect()); + let mut backend = CountingBackend { + image_decode_ready: Some(false), + image_resident_ready: Some(false), + ..Default::default() + }; + panel.paint( + &mut PaintCx { + backend: &mut backend, + }, + panel_rect(), + ); + + assert!(backend.images.is_empty()); + assert!(backend.linear_gradients >= 2); + let pending = take_pending_decodes(usize::MAX); + assert_eq!(pending.len(), expected); + for entry in pending { + mark_decode_done(entry.id); + } +} + +#[test] +fn starter_quick_actions_use_the_local_fallback_without_image_decode() { + let mut state = open_state(Locale::EnUs); + state.editor_ui.prompt_center.filter = PromptFilter::Category(PromptCategory::Starter); + let panel = PromptCenterPanel::for_editor(&state).expect("open panel"); + let mut backend = CountingBackend::default(); + panel.paint( + &mut PaintCx { + backend: &mut backend, + }, + panel_rect(), + ); + + assert!(backend.images.is_empty()); + assert!(backend.image_decode_edges.is_empty()); + assert_eq!(backend.linear_gradients, 4); +} diff --git a/crates/op-editor-ui/src/widgets/prompt_center_previews.rs b/crates/op-editor-ui/src/widgets/prompt_center_previews.rs new file mode 100644 index 000000000..1cfa3afab --- /dev/null +++ b/crates/op-editor-ui/src/widgets/prompt_center_previews.rs @@ -0,0 +1,127 @@ +//! Compile-time Prompt Center preview registry. +//! +//! Preview bytes stay inside the wasm bundle so card painting never performs +//! runtime file or network I/O. + +const PREVIEW_IMAGE_ID_BASE: u64 = 0x5052_4d50_0000_0000; + +macro_rules! preview { + ($index:literal, $file:literal) => { + Some(( + PREVIEW_IMAGE_ID_BASE | $index, + include_bytes!(concat!( + "../../assets/prompt_center_previews/", + $file, + ".jpg" + )) + .as_slice(), + )) + }; +} + +/// Return the stable cache id and embedded JPEG bytes for a built-in prompt. +/// +/// Starter quick actions, user-defined prompts, and unknown ids intentionally +/// have no generated preview and therefore return `None`. +pub(crate) fn prompt_center_preview(prompt_id: &str) -> Option<(u64, &'static [u8])> { + match prompt_id { + "gallery-wander" => preview!(1, "gallery-wander"), + "gallery-forage" => preview!(2, "gallery-forage"), + "gallery-still" => preview!(3, "gallery-still"), + "gallery-hearth" => preview!(4, "gallery-hearth"), + "gallery-meteo" => preview!(5, "gallery-meteo"), + "gallery-marginalia" => preview!(6, "gallery-marginalia"), + "gallery-lingua" => preview!(7, "gallery-lingua"), + "gallery-daybreak" => preview!(8, "gallery-daybreak"), + "gallery-verdant" => preview!(9, "gallery-verdant"), + "gallery-companion" => preview!(10, "gallery-companion"), + "gallery-relic" => preview!(11, "gallery-relic"), + "gallery-nocturne" => preview!(12, "gallery-nocturne"), + "gallery-marquee" => preview!(13, "gallery-marquee"), + "gallery-ritual" => preview!(14, "gallery-ritual"), + "gallery-ember" => preview!(15, "gallery-ember"), + "gallery-volt" => preview!(16, "gallery-volt"), + "gallery-aloft" => preview!(17, "gallery-aloft"), + "gallery-gallery" => preview!(18, "gallery-gallery"), + "gallery-nightcap" => preview!(19, "gallery-nightcap"), + "gallery-bloom" => preview!(20, "gallery-bloom"), + "freeform-wander" => preview!(21, "freeform-wander"), + "freeform-forage" => preview!(22, "freeform-forage"), + "freeform-still" => preview!(23, "freeform-still"), + "freeform-hearth" => preview!(24, "freeform-hearth"), + "freeform-meteo" => preview!(25, "freeform-meteo"), + "freeform-marginalia" => preview!(26, "freeform-marginalia"), + "freeform-lingua" => preview!(27, "freeform-lingua"), + "freeform-daybreak" => preview!(28, "freeform-daybreak"), + "freeform-verdant" => preview!(29, "freeform-verdant"), + "freeform-companion" => preview!(30, "freeform-companion"), + "freeform-relic" => preview!(31, "freeform-relic"), + "freeform-nocturne" => preview!(32, "freeform-nocturne"), + "freeform-marquee" => preview!(33, "freeform-marquee"), + "freeform-ritual" => preview!(34, "freeform-ritual"), + "freeform-ember" => preview!(35, "freeform-ember"), + "freeform-volt" => preview!(36, "freeform-volt"), + "freeform-aloft" => preview!(37, "freeform-aloft"), + "freeform-gallery" => preview!(38, "freeform-gallery"), + "freeform-nightcap" => preview!(39, "freeform-nightcap"), + "freeform-bloom" => preview!(40, "freeform-bloom"), + "extreme-weather" => preview!(41, "extreme-weather"), + "extreme-now-playing" => preview!(42, "extreme-now-playing"), + "extreme-daily-app" => preview!(43, "extreme-daily-app"), + "extreme-calendar" => preview!(44, "extreme-calendar"), + "extreme-calm" => preview!(45, "extreme-calm"), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use op_editor_core::prompt_center_catalog::{prompt_catalogue, PromptCategory}; + + use super::prompt_center_preview; + + #[test] + fn every_generated_catalogue_entry_has_one_unique_preview() { + let generated: Vec<_> = prompt_catalogue() + .iter() + .filter(|prompt| prompt.category != PromptCategory::Starter) + .collect(); + assert_eq!(generated.len(), 45); + + let mut image_ids = HashSet::new(); + for prompt in generated { + let (image_id, bytes) = prompt_center_preview(&prompt.id) + .unwrap_or_else(|| panic!("missing preview for `{}`", prompt.id)); + assert!( + image_ids.insert(image_id), + "duplicate image id {image_id:#x}" + ); + assert_eq!( + crate::image_runtime::encoded_image_dimensions(bytes), + Some((640, 400)), + "wrong preview dimensions for `{}`", + prompt.id + ); + } + assert_eq!(image_ids.len(), 45); + } + + #[test] + fn starter_custom_and_unknown_ids_have_no_generated_preview() { + for prompt in prompt_catalogue() + .iter() + .filter(|prompt| prompt.category == PromptCategory::Starter) + { + assert!( + prompt_center_preview(&prompt.id).is_none(), + "starter `{}` unexpectedly has a preview", + prompt.id + ); + } + + assert!(prompt_center_preview("custom-1").is_none()); + assert!(prompt_center_preview("unknown-prompt").is_none()); + } +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_test_support.rs b/crates/op-editor-ui/src/widgets/property_panel_test_support.rs index fde1901ea..e32e22151 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_test_support.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_test_support.rs @@ -87,6 +87,7 @@ pub(super) struct CountingBackend { pub(super) text: usize, pub(super) texts: Vec, pub(super) round_rects: usize, + pub(super) linear_gradients: usize, pub(super) images: Vec<(Rect, u64, usize)>, pub(super) image_modes: Vec, pub(super) image_transforms: Vec>, @@ -115,6 +116,19 @@ impl crate::RenderBackend for CountingBackend { fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) { self.round_rects += 1; } + fn fill_round_rect_linear_gradient( + &mut self, + rect: Rect, + radius: f32, + stops: &[(f32, Color)], + _: f32, + opacity: f32, + ) { + self.linear_gradients += 1; + if let Some((_, color)) = stops.first() { + self.fill_round_rect(rect, radius, color.with_alpha(color.a * opacity)); + } + } fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} fn image_decoded(&mut self, _: u64, _: &[u8], max_edge_px: u32) -> bool { diff --git a/crates/op-host-native/src/backend/skia/tests/image_cache.rs b/crates/op-host-native/src/backend/skia/tests/image_cache.rs index 08ffc36e5..0d43295eb 100644 --- a/crates/op-host-native/src/backend/skia/tests/image_cache.rs +++ b/crates/op-host-native/src/backend/skia/tests/image_cache.rs @@ -117,6 +117,19 @@ fn image_decoded_is_false_before_install_and_true_after() { ); } +#[test] +fn prompt_center_jpeg_decodes_into_a_capped_raster() { + let encoded = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../op-editor-ui/assets/prompt_center_previews/gallery-wander.jpg" + )); + let (image, covers_edge_px) = + decode_raster_capped(encoded, 320).expect("prompt preview JPEG rasterizes"); + + assert_eq!(image.dimensions(), (320, 200).into()); + assert_eq!(covers_edge_px, 320); +} + #[test] fn image_cache_evicts_least_recently_used_over_byte_budget() { let mut be = NativeBackend::with_dpi(1.0);