feat(ai): add visual prompt previews

This commit is contained in:
Fini 2026-07-31 00:28:54 +08:00
parent 957316dcf2
commit 51017a1f7d
52 changed files with 445 additions and 61 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View file

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

View file

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

View file

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

View file

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

View 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());
}
}

View file

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

View file

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