feat(ai): add visual prompt previews
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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::<Vec<_>>().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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
|||
.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);
|
||||
}
|
||||
|
|
|
|||
127
crates/op-editor-ui/src/widgets/prompt_center_previews.rs
Normal file
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,7 @@ pub(super) struct CountingBackend {
|
|||
pub(super) text: usize,
|
||||
pub(super) texts: Vec<String>,
|
||||
pub(super) round_rects: usize,
|
||||
pub(super) linear_gradients: usize,
|
||||
pub(super) images: Vec<(Rect, u64, usize)>,
|
||||
pub(super) image_modes: Vec<ImageDrawMode>,
|
||||
pub(super) image_transforms: Vec<Option<[f32; 6]>>,
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||