Type: font menu previews end safely, rows and samples cost less per frame (review of #339)
- A preview whose menu has gone (the panel isn't drawn: text deselected, panel closed) or is closing (Escape, a press outside the menu) is undone at the start of the frame, before the canvas sees the input: a canvas drag no longer runs inside the preview's interaction and gets rolled back with it. The menu also ends the preview in the frame it closes. - The list fits the popup (it was taller than the popup, which then scrolled too), so the current family is centred in view on opening; ↑/↓ scroll only as far as needed. - Type › Font (in-window menu bar): each item keeps room for its sample after the name, so long family names no longer run under it. - Rows are listed again only when the query, filters, expanded families, favourites or font list change, not every frame. - Samples: one budget per frame across every menu (Type › Font gave each item its own), at most 8 rendering at once, the worker asks for a repaint when it is done instead of polling, the cache holds 512, and one function draws a sample for both menus. - Font menus use menu_family_list (the hidden-family rule lives there); FontClass::label is gone (the menu's translated labels are the only ones). - Tests: a preview left by a menu that is gone is undone; a press outside the open menu ends the preview in that frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8f7949c30b
commit
cea2ee6a54
|
|
@ -379,20 +379,9 @@ pub enum FontClass {
|
|||
}
|
||||
|
||||
impl FontClass {
|
||||
/// The kinds a font menu filters by (all but `Other`).
|
||||
pub const ALL: [FontClass; 6] =
|
||||
[FontClass::Serif, FontClass::Sans, FontClass::Rounded, FontClass::Script, FontClass::Monospaced, FontClass::Decorative];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
FontClass::Serif => "Serif / Mincho",
|
||||
FontClass::Sans => "Sans Serif / Gothic",
|
||||
FontClass::Rounded => "Rounded",
|
||||
FontClass::Script => "Script / Brush",
|
||||
FontClass::Monospaced => "Monospaced",
|
||||
FontClass::Decorative => "Decorative",
|
||||
FontClass::Other => "Other",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a font menu filters on: whether a family sets Japanese, and its kind.
|
||||
|
|
|
|||
|
|
@ -69,8 +69,11 @@ const STAR: f32 = 18.0;
|
|||
const NAME_SHARE: f32 = 0.5;
|
||||
/// Width of the disclosure triangle of a family with several styles.
|
||||
const DISCLOSURE: f32 = 14.0;
|
||||
/// Samples rendered (or asked for) per frame, so a long list doesn't stall the UI.
|
||||
/// Samples rendered (or asked for) per frame, across every menu, so a long list doesn't stall the UI.
|
||||
const SAMPLES_PER_FRAME: usize = 4;
|
||||
/// Samples rendering at once (native).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_PENDING: usize = 8;
|
||||
/// What a sample shows when the font lacks the selected text's characters (or there is none).
|
||||
const FALLBACK_SAMPLE: &str = "Sample";
|
||||
/// The script filter's choices: all, Japanese, Latin.
|
||||
|
|
@ -120,6 +123,10 @@ impl Row {
|
|||
}
|
||||
}
|
||||
|
||||
/// What the rows were listed for: the query, the script, kind and favourites filters, the
|
||||
/// expanded families, the favourites and the font list's generation.
|
||||
type RowsKey = (String, usize, Option<FontClass>, bool, Vec<String>, Vec<String>, u64);
|
||||
|
||||
/// The menu's state between frames.
|
||||
#[derive(Clone, Default)]
|
||||
struct MenuState {
|
||||
|
|
@ -138,6 +145,11 @@ struct MenuState {
|
|||
expanded: Vec<String>,
|
||||
/// What was previewed last (None: nothing is previewed).
|
||||
previewed: Option<(String, Option<String>)>,
|
||||
/// The rows listed, and what for: built again only when that changes, not every frame.
|
||||
rows: Arc<[Row]>,
|
||||
rows_key: Option<RowsKey>,
|
||||
/// The list's scroll offset last frame.
|
||||
scrolled: f32,
|
||||
}
|
||||
|
||||
/// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the
|
||||
|
|
@ -157,9 +169,8 @@ pub fn font_menu(
|
|||
drawn = true;
|
||||
list(ui, state_id, &mut st, current, sample, look)
|
||||
});
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
let mut out = picked;
|
||||
if !drawn && st.last + 1 < pass && st.previewed.take().is_some() && out.is_none() {
|
||||
if !drawn && out.is_none() && st.previewed.take().is_some() {
|
||||
// Closed (Escape, a click outside) with a preview showing.
|
||||
out = Some(FontPick::EndPreview);
|
||||
}
|
||||
|
|
@ -184,6 +195,8 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
|||
st.query.clear();
|
||||
}
|
||||
st.last = pass;
|
||||
// For [`end_stale_preview`]: the menu is open, in this layer.
|
||||
ui.ctx().data_mut(|d| d.insert_temp(open_id(), (pass, ui.layer_id())));
|
||||
let field = state_id.with("query");
|
||||
if opening_now {
|
||||
ui.data_mut(|d| d.insert_temp(field, String::new()));
|
||||
|
|
@ -229,32 +242,37 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
|||
});
|
||||
let enter = ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let families = db.family_list();
|
||||
// The current family by its own name (a document may name it in Japanese: ヒラギノ角ゴシック).
|
||||
let current = db.canonical(current, "").0;
|
||||
let current = current.as_str();
|
||||
// Families named with a leading dot are the system's own (hidden from font menus).
|
||||
let favorite = |f: &str| look.favorites.iter().any(|x| x.eq_ignore_ascii_case(f));
|
||||
let wanted = |f: &str| {
|
||||
if f.starts_with('.') || !(query.is_empty() || f.to_lowercase().contains(&query)) {
|
||||
return false;
|
||||
}
|
||||
if st.favorites_only && !favorite(f) {
|
||||
return false;
|
||||
}
|
||||
if st.script == 0 && st.class.is_none() {
|
||||
return true;
|
||||
}
|
||||
let traits = db.family_traits(f);
|
||||
(st.script == 0 || (st.script == 1) == traits.japanese) && st.class.is_none_or(|c| c == traits.class)
|
||||
};
|
||||
let mut rows: Vec<Row> = vec![];
|
||||
for f in families.iter().filter(|f| wanted(f)) {
|
||||
rows.push(Row::Family(f.clone()));
|
||||
if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) {
|
||||
rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
|
||||
// The generation is read first: fonts that load meanwhile make the next frame list them.
|
||||
let key: RowsKey = (query.clone(), st.script, st.class, st.favorites_only, st.expanded.clone(), look.favorites.to_vec(), db.generation());
|
||||
if st.rows_key.as_ref() != Some(&key) {
|
||||
let wanted = |f: &str| {
|
||||
if !(query.is_empty() || f.to_lowercase().contains(&query)) {
|
||||
return false;
|
||||
}
|
||||
if st.favorites_only && !favorite(f) {
|
||||
return false;
|
||||
}
|
||||
if st.script == 0 && st.class.is_none() {
|
||||
return true;
|
||||
}
|
||||
let traits = db.family_traits(f);
|
||||
(st.script == 0 || (st.script == 1) == traits.japanese) && st.class.is_none_or(|c| c == traits.class)
|
||||
};
|
||||
let mut rows: Vec<Row> = vec![];
|
||||
for f in db.menu_family_list().iter().filter(|f| wanted(f)) {
|
||||
rows.push(Row::Family(f.clone()));
|
||||
if st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f)) {
|
||||
rows.extend(db.styles(f).into_iter().map(|s| Row::Style(f.clone(), s)));
|
||||
}
|
||||
}
|
||||
st.rows = rows.into();
|
||||
st.rows_key = Some(key);
|
||||
}
|
||||
let rows = st.rows.clone();
|
||||
if query != st.query || refilter {
|
||||
st.query = query.clone();
|
||||
st.highlight = None;
|
||||
|
|
@ -288,20 +306,34 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
|||
widgets::dim_label(ui, tl!("No matching fonts"));
|
||||
return None;
|
||||
}
|
||||
// The rows: only those in view are laid out (there are thousands of families).
|
||||
// The rows: only those in view are laid out (there are thousands of families). The list fills
|
||||
// what the popup shows below the filters (its clip: the popup doesn't grow past its own size,
|
||||
// and a taller list would scroll inside a scrolling popup), down to the window's bottom.
|
||||
const BOTTOM_GAP: f32 = 12.0;
|
||||
let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).max(120.0);
|
||||
let bottom = ui.clip_rect().bottom().min(ui.ctx().content_rect().bottom() - BOTTOM_GAP);
|
||||
let room = (bottom - ui.next_widget_position().y).max(120.0);
|
||||
let mut area = egui::ScrollArea::vertical().max_height(room).min_scrolled_height(room.min(row_h * rows.len() as f32));
|
||||
if let Some(i) = st.highlight.filter(|_| keyed || opening) {
|
||||
area = area.vertical_scroll_offset((i as f32 * row_h - room * 0.5 + row_h * 0.5).max(0.0));
|
||||
// The highlight in view: centred as the menu opens, scrolled just enough as the keys move it.
|
||||
if let Some(top) = st.highlight.map(|i| i as f32 * row_h) {
|
||||
let target = if opening {
|
||||
Some(top - (room - row_h) * 0.5)
|
||||
} else if keyed && top < st.scrolled {
|
||||
Some(top)
|
||||
} else if keyed && top + row_h > st.scrolled + room {
|
||||
Some(top + row_h - room)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(y) = target {
|
||||
area = area.vertical_scroll_offset(y.max(0.0));
|
||||
}
|
||||
}
|
||||
let mut chosen: Option<(String, Option<String>)> = None;
|
||||
let mut hovered: Option<usize> = None;
|
||||
let mut toggled: Option<String> = None;
|
||||
let mut starred: Option<String> = None;
|
||||
let mut budget = SAMPLES_PER_FRAME;
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
area.show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
let shown = area.show_rows(ui, row_h, rows.len(), |ui, range| {
|
||||
for i in range {
|
||||
let Some(row) = rows.get(i) else { continue };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
|
||||
|
|
@ -349,19 +381,15 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
|||
ui.painter_at(name_rect).text(name_rect.left_center(), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.0), t.text);
|
||||
let sample_rect = egui::Rect::from_min_max(rect.min + vec2(name_w, 2.0), rect.max - vec2(STAR + 2.0, 2.0));
|
||||
if !look.samples {
|
||||
} else if let Some(tex) = sample_texture(ui.ctx(), row.family(), row.style(), sample, sample_rect.height(), &mut budget) {
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let size = tex.size_vec2() / ppp;
|
||||
let at = egui::Rect::from_min_size(sample_rect.left_center() - vec2(0.0, size.y * 0.5), size);
|
||||
let shown_at = at.intersect(sample_rect);
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown_at.width() / size.x).clamp(0.0, 1.0), 1.0));
|
||||
ui.painter_at(sample_rect).image(tex.id(), shown_at, uv, t.text);
|
||||
} else if let Some(tex) = sample_texture(ui.ctx(), row.family(), row.style(), sample, sample_rect.height()) {
|
||||
paint_sample(ui, sample_rect, &tex, t.text);
|
||||
}
|
||||
if resp.clicked() && !on_triangle && !on_star {
|
||||
chosen = Some(row.pick());
|
||||
}
|
||||
}
|
||||
});
|
||||
st.scrolled = shown.state.offset.y;
|
||||
if let Some(f) = toggled {
|
||||
if st.expanded.contains(&f) {
|
||||
st.expanded.retain(|e| *e != f);
|
||||
|
|
@ -399,23 +427,26 @@ fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, samp
|
|||
// ---------- samples ----------
|
||||
|
||||
/// Width of the sample drawn at the end of a Type → Font menu item.
|
||||
const MENU_SAMPLE_WIDTH: f32 = 90.0;
|
||||
/// The room a Type → Font menu item keeps for its sample, after the family's name.
|
||||
pub(crate) const MENU_SAMPLE_SIZE: egui::Vec2 = vec2(90.0, 16.0);
|
||||
|
||||
/// A generic sample of `family` drawn at the right end of the menu item `item` (Type → Font in
|
||||
/// the in-window menu bar; the native macOS menu shows names only).
|
||||
pub(crate) fn menu_item_sample(ui: &Ui, item: egui::Rect, family: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mut budget = SAMPLES_PER_FRAME;
|
||||
let area = egui::Rect::from_min_max(egui::pos2(item.max.x - MENU_SAMPLE_WIDTH, item.min.y + 2.0), item.max - vec2(4.0, 2.0));
|
||||
if let Some(tex) = sample_texture(ui.ctx(), family, None, None, area.height(), &mut budget) {
|
||||
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
|
||||
let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size);
|
||||
let shown = at.intersect(area);
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0));
|
||||
ui.painter_at(area).image(tex.id(), shown, uv, t.text_dim);
|
||||
/// A generic sample of `family` drawn in `slot` (the room a Type → Font item of the in-window
|
||||
/// menu bar keeps for it; the native macOS menu shows names only).
|
||||
pub(crate) fn menu_item_sample(ui: &Ui, slot: egui::Rect, family: &str) {
|
||||
if let Some(tex) = sample_texture(ui.ctx(), family, None, None, slot.height()) {
|
||||
paint_sample(ui, slot, &tex, Tokens::get(ui.ctx()).text_dim);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw sample `tex` (tinted `tint`) at the left of `area`, centred vertically, cut at its right.
|
||||
fn paint_sample(ui: &Ui, area: egui::Rect, tex: &egui::TextureHandle, tint: Color32) {
|
||||
let size = tex.size_vec2() / ui.ctx().pixels_per_point();
|
||||
let at = egui::Rect::from_min_size(area.left_center() - vec2(0.0, size.y * 0.5), size);
|
||||
let shown = at.intersect(area);
|
||||
let uv = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2((shown.width() / size.x).clamp(0.0, 1.0), 1.0));
|
||||
ui.painter_at(area).image(tex.id(), shown, uv, tint);
|
||||
}
|
||||
|
||||
type Key = (String, String, String, u32);
|
||||
|
||||
/// Samples kept before the cache starts over.
|
||||
|
|
@ -426,6 +457,26 @@ struct Samples {
|
|||
ready: HashMap<Key, Option<egui::TextureHandle>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending: HashMap<Key, std::sync::mpsc::Receiver<Option<egui::ColorImage>>>,
|
||||
/// The pass the budget is for, and the samples started in it.
|
||||
pass: u64,
|
||||
started: usize,
|
||||
}
|
||||
|
||||
impl Samples {
|
||||
/// Take one sample from this frame's budget ([`SAMPLES_PER_FRAME`]); `false` when it is spent
|
||||
/// (another frame is asked for, to go on).
|
||||
fn take_budget(&mut self, ctx: &egui::Context) -> bool {
|
||||
let pass = ctx.cumulative_pass_nr();
|
||||
if self.pass != pass {
|
||||
(self.pass, self.started) = (pass, 0);
|
||||
}
|
||||
if self.started >= SAMPLES_PER_FRAME {
|
||||
ctx.request_repaint();
|
||||
return false;
|
||||
}
|
||||
self.started += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
|
|
@ -434,17 +485,12 @@ thread_local! {
|
|||
|
||||
/// The sample of `family` for a row `height` points high: the selected text set in it (else a
|
||||
/// generic sample), white with coverage as alpha (tinted when drawn). Rendered off the UI thread
|
||||
/// on native, a few per frame; `None` until it is ready.
|
||||
fn sample_texture(
|
||||
ctx: &egui::Context,
|
||||
family: &str,
|
||||
style: Option<&str>,
|
||||
sample: Option<&str>,
|
||||
height: f32,
|
||||
budget: &mut usize,
|
||||
) -> Option<egui::TextureHandle> {
|
||||
/// on native (the worker asks for a frame when it is done), a few per frame; `None` until it is
|
||||
/// ready.
|
||||
fn sample_texture(ctx: &egui::Context, family: &str, style: Option<&str>, sample: Option<&str>, height: f32) -> Option<egui::TextureHandle> {
|
||||
let px = (height * ctx.pixels_per_point()).round().clamp(8.0, 96.0) as u32;
|
||||
let key: Key = (family.to_string(), style.unwrap_or("Regular").to_string(), sample.unwrap_or_default().to_string(), px);
|
||||
let texture = |img: egui::ColorImage| ctx.load_texture(format!("font-sample-{family}-{px}"), img, egui::TextureOptions::LINEAR);
|
||||
SAMPLES.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if let Some(t) = s.ready.get(&key) {
|
||||
|
|
@ -457,52 +503,41 @@ fn sample_texture(
|
|||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(rx) = s.pending.get(&key) {
|
||||
match rx.try_recv() {
|
||||
Ok(img) => {
|
||||
s.pending.remove(&key);
|
||||
let tex = img.map(|img| ctx.load_texture(format!("font-sample-{}-{px}", key.0), img, egui::TextureOptions::LINEAR));
|
||||
s.ready.insert(key, tex.clone());
|
||||
return tex;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
return None;
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
s.pending.remove(&key);
|
||||
s.ready.insert(key, None);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let img = match rx.try_recv() {
|
||||
Ok(img) => img,
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return None,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => None,
|
||||
};
|
||||
s.pending.remove(&key);
|
||||
let tex = img.map(texture);
|
||||
s.ready.insert(key, tex.clone());
|
||||
return tex;
|
||||
}
|
||||
if *budget == 0 || s.pending.len() >= 8 {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
if s.pending.len() >= MAX_PENDING || !s.take_budget(ctx) {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (f, style, text) = (key.0.clone(), key.1.clone(), key.2.clone());
|
||||
let repaint = ctx.clone();
|
||||
let spawned = std::thread::Builder::new().name("font-sample".into()).spawn(move || {
|
||||
// The receiver may be gone (the cache was cleared): nothing to do then.
|
||||
let _ = tx.send(render_sample(&f, &style, &text, px));
|
||||
repaint.request_repaint();
|
||||
});
|
||||
if spawned.is_ok() {
|
||||
s.pending.insert(key, rx);
|
||||
} else {
|
||||
s.ready.insert(key, None);
|
||||
}
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
None
|
||||
}
|
||||
// The web has no threads: a few samples per frame, on this one.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if *budget == 0 {
|
||||
ctx.request_repaint();
|
||||
if !s.take_budget(ctx) {
|
||||
return None;
|
||||
}
|
||||
*budget -= 1;
|
||||
let tex = render_sample(&key.0, &key.1, &key.2, px)
|
||||
.map(|img| ctx.load_texture(format!("font-sample-{}-{px}", key.0), img, egui::TextureOptions::LINEAR));
|
||||
let tex = render_sample(&key.0, &key.1, &key.2, px).map(texture);
|
||||
s.ready.insert(key, tex.clone());
|
||||
tex
|
||||
}
|
||||
|
|
@ -547,10 +582,40 @@ pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Optio
|
|||
|
||||
// ---------- applying a pick ----------
|
||||
|
||||
/// Is a font previewed on the text (an interaction is open for it)?
|
||||
const PREVIEWING: &str = "font-previewing";
|
||||
|
||||
/// Where the open font menu was drawn last: (pass, its popup's layer).
|
||||
fn open_id() -> egui::Id {
|
||||
egui::Id::new("font-menu-open")
|
||||
}
|
||||
|
||||
/// Undo a preview whose menu is gone (its panel wasn't drawn: the text was deselected, the panel
|
||||
/// closed) or is closing (Escape, a press outside the menu), before anything else sees this
|
||||
/// frame's input: the canvas must not act inside the preview's interaction (a drag would be
|
||||
/// rolled back with the preview). The shell calls it at the start of each frame.
|
||||
pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) {
|
||||
if !crate::panels::pstate::<bool>(ctx, PREVIEWING) {
|
||||
return;
|
||||
}
|
||||
let drawn = ctx.data(|d| d.get_temp::<(u64, egui::LayerId)>(open_id()));
|
||||
// Drawn in the last pass: still open.
|
||||
let Some((_, layer)) = drawn.filter(|(pass, _)| pass + 1 >= ctx.cumulative_pass_nr()) else {
|
||||
apply(app, ctx, FontPick::EndPreview);
|
||||
return;
|
||||
};
|
||||
// (`layer_id_at` outside `input`: egui's context lock isn't re-entrant.)
|
||||
let (escape, press) = ctx.input(|i| (i.key_pressed(egui::Key::Escape), i.pointer.any_pressed().then(|| i.pointer.interact_pos())));
|
||||
let leaving = escape || press.is_some_and(|at| at.is_none_or(|p| ctx.layer_id_at(p) != Some(layer)));
|
||||
if leaving {
|
||||
apply(app, ctx, FontPick::EndPreview);
|
||||
}
|
||||
}
|
||||
|
||||
/// What the font menus preview on and apply to: the Type tool's selected range while it edits
|
||||
/// text (the whole text when nothing is selected), else the selected text objects.
|
||||
pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) {
|
||||
let previewing = crate::panels::pstate::<bool>(ctx, "font-previewing");
|
||||
let previewing = crate::panels::pstate::<bool>(ctx, PREVIEWING);
|
||||
match pick {
|
||||
FontPick::Preview(f, style) => {
|
||||
if !previewing {
|
||||
|
|
@ -558,7 +623,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic
|
|||
if app.session.begin_interaction("Font").is_err() {
|
||||
return;
|
||||
}
|
||||
crate::panels::set_pstate(ctx, "font-previewing", true);
|
||||
crate::panels::set_pstate(ctx, PREVIEWING, true);
|
||||
}
|
||||
let (cmd, params) = font_command(app, &f, style.as_deref());
|
||||
if let Err(e) = app.session.preview(cmd, ¶ms) {
|
||||
|
|
@ -569,7 +634,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic
|
|||
FontPick::EndPreview => {
|
||||
if previewing {
|
||||
app.session.cancel_interaction().ok();
|
||||
crate::panels::set_pstate(ctx, "font-previewing", false);
|
||||
crate::panels::set_pstate(ctx, PREVIEWING, false);
|
||||
app.sync_views();
|
||||
}
|
||||
}
|
||||
|
|
@ -585,7 +650,7 @@ pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPic
|
|||
FontPick::Chosen(f, style) => {
|
||||
if previewing {
|
||||
app.session.cancel_interaction().ok();
|
||||
crate::panels::set_pstate(ctx, "font-previewing", false);
|
||||
crate::panels::set_pstate(ctx, PREVIEWING, false);
|
||||
}
|
||||
crate::panels::character::end_typing(app);
|
||||
let (cmd, params) = font_command(app, &f, style.as_deref());
|
||||
|
|
|
|||
|
|
@ -842,6 +842,7 @@ impl VectorcraftApp {
|
|||
return;
|
||||
}
|
||||
let t0 = now_ms();
|
||||
font_menu::end_stale_preview(self, &ctx);
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
if self.ui.screen_mode < 2 {
|
||||
chrome::app_bar(self, ui);
|
||||
|
|
|
|||
|
|
@ -2592,14 +2592,20 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks:
|
|||
Some(false) => format!(" {label}"),
|
||||
None => label,
|
||||
};
|
||||
let r = ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc));
|
||||
// Type → Font: each family's sample beside its name (Enable in-menu font previews).
|
||||
if *id == "text.setStyle"
|
||||
&& app.session.prefs.font_preview
|
||||
&& let Some(family) = p.get("font").and_then(Value::as_str)
|
||||
{
|
||||
crate::font_menu::menu_item_sample(ui, r.rect, family);
|
||||
}
|
||||
// Type → Font: each family's sample after its name (Enable in-menu font previews).
|
||||
let sampled = p.get("font").and_then(Value::as_str).filter(|_| *id == "text.setStyle" && app.session.prefs.font_preview);
|
||||
let r = match sampled {
|
||||
Some(family) => {
|
||||
let slot = ui.id().with(("font-sample", family));
|
||||
let button = egui::Button::new(text).right_text(egui::Atom::custom(slot, crate::font_menu::MENU_SAMPLE_SIZE));
|
||||
let out = ui.add_enabled_ui(en, |ui| button.atom_ui(ui)).inner;
|
||||
if let Some(rect) = out.rect(slot) {
|
||||
crate::font_menu::menu_item_sample(ui, rect, family);
|
||||
}
|
||||
out.response
|
||||
}
|
||||
None => ui.add_enabled(en, egui::Button::new(text).shortcut_text(sc)),
|
||||
};
|
||||
if r.clicked() {
|
||||
*clicked = Some(click_target(label_of(it), id, p));
|
||||
ui.close();
|
||||
|
|
|
|||
|
|
@ -116,6 +116,69 @@ fn preview_changes_the_text_live_and_only_a_choice_is_a_step() {
|
|||
assert_eq!(font(&app), "Inter");
|
||||
}
|
||||
|
||||
/// A preview whose menu isn't drawn any more (the text was deselected, the panel closed) is undone
|
||||
/// at the start of the next frame, before the canvas sees its input.
|
||||
#[test]
|
||||
fn a_preview_left_open_by_a_menu_that_is_gone_is_undone() {
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let steps = app.session.active().unwrap().history.undo.len();
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Preview("Source Serif 4".into(), None));
|
||||
assert!(app.session.in_interaction());
|
||||
crate::font_menu::end_stale_preview(&mut app, &ctx);
|
||||
assert!(!app.session.in_interaction(), "no menu drawn: the preview is undone");
|
||||
let font = match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.first_style().font_family,
|
||||
_ => String::new(),
|
||||
};
|
||||
assert_eq!((font.as_str(), app.session.active().unwrap().history.undo.len()), ("Inter", steps));
|
||||
// With nothing previewed it does nothing.
|
||||
crate::font_menu::end_stale_preview(&mut app, &ctx);
|
||||
assert_eq!(app.session.active().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// A press outside the open menu ends the preview in that frame, before the canvas could act on
|
||||
/// it inside the preview's interaction (the menu itself closes on the release).
|
||||
#[test]
|
||||
fn a_press_outside_the_menu_ends_the_preview_at_once() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
|
||||
let id = app.session.execute("text.create", &json!({"x": 20, "y": 50, "text": "Gagaku", "font": "Inter"})).unwrap()["id"].as_u64().unwrap();
|
||||
app.session.execute("select.set", &json!({"ids": [id]})).unwrap();
|
||||
let mut frame = |events: Vec<egui::Event>| {
|
||||
let input =
|
||||
egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 800.0))), ..Default::default() };
|
||||
let mut at = egui::Pos2::ZERO;
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
crate::font_menu::end_stale_preview(&mut app, ui.ctx());
|
||||
at = ui.next_widget_position();
|
||||
if let Some(pick) = font_menu(ui, "test-press", "Inter", 260.0, None, MenuLook::default()) {
|
||||
crate::font_menu::apply(&mut app, ui.ctx(), pick);
|
||||
}
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(at, app.session.in_interaction())
|
||||
};
|
||||
let (at, _) = frame(vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(click, true)]);
|
||||
frame(vec![button(click, false)]);
|
||||
for _ in 0..5 {
|
||||
frame(vec![]);
|
||||
}
|
||||
assert!(frame(key(egui::Key::ArrowDown)).1, "↓ previews");
|
||||
assert!(frame(vec![]).1, "the preview lasts while the menu is open");
|
||||
let outside = egui::pos2(580.0, 780.0);
|
||||
assert!(!frame(vec![egui::Event::PointerMoved(outside), button(outside, true)]).1, "the press ends the preview");
|
||||
assert!(!frame(vec![button(outside, false)]).1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_families_are_classified() {
|
||||
use vectorcraft_text::FontClass;
|
||||
|
|
|
|||
Loading…
Reference in New Issue