mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
Merge pull request #339 from imachus/pr/ja-font-menu
Type: a font menu with samples, live preview, script and kind filters, styles and favourites # Conflicts: # crates/ui-egui/src/i18n/ja.tsv
This commit is contained in:
commit
b33e964aec
13 files changed
+1045
-12
No files matched your search
+1
-1
@@ -160,7 +160,7 @@ grade by behaviour against `plan/illustrator/`, not by whether a menu item exist
|
||||
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | ✅ done (M4.14–M4.98 on 2026-10-05): Save As/Save a Copy/Revert/templates, Data Recovery and background save, SVG import and export fidelity (units, text, symbols, filters, SVGZ, Preserve Editing, SVG Options), PDF import (colours, layers, masks, editable text, security) and export (presets, marks, bleed, ICC and output intent, raster effects, subset fonts, PDF/X-1a, X-3 and X-4, PDF layers, overprint, page thumbnails, fast web view, PDF 1.3), Place and Links, Package, EPS and DXF in and out, EMF/WMF, TIFF/BMP/Targa/PSD/GIF/PNG-8 export, Print and print presets, slices, Save for Web, Asset Export, clipboard flavours, File Info. Left: DWG (use DXF), PSD placement as layers, overprint read back from PDF, polish | 3–6 |
|
||||
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 15–25 |
|
||||
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Boolean precision on almost-horizontal edges fixed and the property tests made deterministic; Shape Builder edge erase, large-offset bug open) | 3–6 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options (with Width/Height), type areas resized by their handles and reshaped with Direct Selection, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font, installed system fonts in every font menu (searchable; the Character and Properties menus show each family's sample of the selected text, preview it live under the pointer or ↑/↓, filter by script and kind, list a family's styles, and star favourites) and found by name on open/import, variable fonts' named instances as styles, CJK names in the UI, input methods (marked text in place, candidate window at the caret; Japanese IME), Text Wrap (offset, invert, both sides of an object; follows edits), Type on a Path effects, tab stops + Tabs panel done; tab leaders, spell check, vertical type pending | 45–60 |
|
||||
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | ✅ done (M8.1–M8.20 on 2026-10-06, fidelity pass): Liquify Tool Options with one shared brush, pen pressure, hold-to-apply, a safe scope and incremental strokes; tool options that last; Puppet Warp rotation, multi-select, Control bar and a rest-shape session that follows Undo; Width tool on compound paths; Envelope dialogs with preview, Reset with Warp/Mesh, full Envelope Options, type/images/symbols/appearance/gradients/patterns distorted on canvas and in every export, warps that keep their frame, Edit Contents, mesh handles and Control bar; Blend Options, anchor-targeted Blend tool, editable spine, keys picked on the canvas, faithful interpolation and knockout; Perspective Grid with Define Grid, presets, lock/snap/rulers, widgets, the 1–4 plane switch, movable planes, Perspective Selection scale/copy/perpendicular/Transform Again, and type and symbols truly projected with Edit Text. Left: Anti-Alias and Preserve Shape output, warp-envelope point editing, brush interpolation in blends, Free Transform/Scale/Reflect along a rotated box | 2–4 |
|
||||
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder and Color Adjustments; SVG Filters, Document Raster Effects Settings, 3D pending | 86–135 |
|
||||
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 23–37 |
|
||||
|
||||
+189
-5
@@ -45,6 +45,8 @@ enum FontBytes {
|
||||
pub struct FontFace {
|
||||
/// Every name the face answers to (normalized), in every language its name table has.
|
||||
keys: FaceKeys,
|
||||
/// Its kind, for font menus' filters.
|
||||
pub traits: FontTraits,
|
||||
id: u32,
|
||||
/// Typographic family name (e.g. "Source Sans 3").
|
||||
pub family: String,
|
||||
@@ -203,6 +205,8 @@ impl FontFace {
|
||||
struct CatalogFamily {
|
||||
name: String,
|
||||
faces: Vec<CatalogFace>,
|
||||
/// The family's kind (from its first face found).
|
||||
traits: Option<FontTraits>,
|
||||
}
|
||||
|
||||
/// One style of an installed family: a face, or a named instance of a variable font.
|
||||
@@ -355,6 +359,164 @@ impl FaceKeys {
|
||||
}
|
||||
}
|
||||
|
||||
/// A font's kind, for filtering font menus: the classification of its OS/2 table (IBM family
|
||||
/// class, PANOSE) and, for CJK fonts whose tables seldom say, its names (明朝, ゴシック, 丸…).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
||||
pub enum FontClass {
|
||||
/// Serif (Latin) or Mincho (CJK).
|
||||
Serif,
|
||||
/// Sans serif (Latin) or Gothic (CJK).
|
||||
Sans,
|
||||
/// Rounded sans / Maru Gothic.
|
||||
Rounded,
|
||||
/// Script, handwriting, brush (楷書・行書・教科書…).
|
||||
Script,
|
||||
Monospaced,
|
||||
/// Decorative, display, symbol and pictorial fonts.
|
||||
Decorative,
|
||||
#[default]
|
||||
Other,
|
||||
}
|
||||
|
||||
impl FontClass {
|
||||
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.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct FontTraits {
|
||||
pub japanese: bool,
|
||||
pub class: FontClass,
|
||||
}
|
||||
|
||||
impl FontTraits {
|
||||
/// From a face's OS/2 table (when it has one) and names.
|
||||
fn of(font: &skrifa::FontRef<'_>, keys: &FaceKeys) -> Self {
|
||||
use skrifa::raw::TableProvider;
|
||||
let os2 = font.os2().ok();
|
||||
let names: Vec<&str> = keys.families.iter().chain(&keys.postscript).map(String::as_str).collect();
|
||||
let named = |words: &[&str]| names.iter().any(|n| words.iter().any(|w| n.contains(w)));
|
||||
let has_japanese_name = keys.families.iter().any(|k| k.chars().any(|c| matches!(c as u32, 0x3040..=0x30FF | 0x4E00..=0x9FFF)));
|
||||
let (class_id, panose, ranges2, codepages) = match &os2 {
|
||||
Some(o) => (o.s_family_class(), o.panose_10().to_vec(), o.ul_unicode_range_2(), o.ul_code_page_range_1().unwrap_or(0)),
|
||||
None => (0, vec![], 0, 0),
|
||||
};
|
||||
// Hiragana (bit 49) or Katakana (bit 50) in the Unicode ranges, or the JIS code page.
|
||||
let japanese = ranges2 & (0b11 << 17) != 0 || codepages & (1 << 17) != 0 || has_japanese_name;
|
||||
let family_type = panose.first().copied().unwrap_or(0);
|
||||
let serif_style = panose.get(1).copied().unwrap_or(0);
|
||||
let proportion = panose.get(3).copied().unwrap_or(0);
|
||||
let ibm = (class_id >> 8) as u8;
|
||||
let class = if named(&["丸", "maru", "rounded", "round"]) || japanese && named(&["jun"]) {
|
||||
FontClass::Rounded
|
||||
} else if named(&[
|
||||
"楷書",
|
||||
"行書",
|
||||
"草書",
|
||||
"隷書",
|
||||
"教科書",
|
||||
"毛筆",
|
||||
"筆",
|
||||
"kaisho",
|
||||
"gyosho",
|
||||
"sosho",
|
||||
"reisho",
|
||||
"kyokasho",
|
||||
"brush",
|
||||
"script",
|
||||
"hand",
|
||||
"kaiti",
|
||||
"xingkai",
|
||||
"libian",
|
||||
]) {
|
||||
FontClass::Script
|
||||
} else if named(&["mono", "code", "courier", "menlo", "consol"]) {
|
||||
FontClass::Monospaced
|
||||
} else if named(&[
|
||||
"明朝",
|
||||
"mincho",
|
||||
"ryumin",
|
||||
"hiramin",
|
||||
"yumin",
|
||||
"kozmin",
|
||||
"serif",
|
||||
"songti",
|
||||
"simsun",
|
||||
"stsong",
|
||||
"fangsong",
|
||||
"myungjo",
|
||||
"times",
|
||||
"georgia",
|
||||
"garamond",
|
||||
"baskerville",
|
||||
"bodoni",
|
||||
"caslon",
|
||||
"palatino",
|
||||
"didot",
|
||||
"century",
|
||||
"cambria",
|
||||
"minion",
|
||||
"charter",
|
||||
"hoefler",
|
||||
]) && !named(&["sansserif", "sans"])
|
||||
{
|
||||
FontClass::Serif
|
||||
} else if named(&[
|
||||
"ゴシック",
|
||||
"gothic",
|
||||
"goth",
|
||||
"sans",
|
||||
"kakugo",
|
||||
"kaku",
|
||||
"heiti",
|
||||
"helvetica",
|
||||
"arial",
|
||||
"grotesk",
|
||||
"grotesque",
|
||||
"futura",
|
||||
"gill",
|
||||
"verdana",
|
||||
"tahoma",
|
||||
"frutiger",
|
||||
"avenir",
|
||||
"geneva",
|
||||
"lucidagrande",
|
||||
"inter",
|
||||
"roboto",
|
||||
"optima",
|
||||
"pingfang",
|
||||
]) {
|
||||
FontClass::Sans
|
||||
} else if family_type == 2 && proportion == 9 {
|
||||
FontClass::Monospaced
|
||||
} else if family_type == 3 || ibm == 10 {
|
||||
FontClass::Script
|
||||
} else if family_type == 4 || family_type == 5 || ibm == 9 || ibm == 12 {
|
||||
FontClass::Decorative
|
||||
} else if matches!(ibm, 1..=5 | 7) || family_type == 2 && (2..=10).contains(&serif_style) {
|
||||
FontClass::Serif
|
||||
} else if ibm == 8 || family_type == 2 && (11..=13).contains(&serif_style) {
|
||||
FontClass::Sans
|
||||
} else {
|
||||
FontClass::Other
|
||||
};
|
||||
FontTraits { japanese, class }
|
||||
}
|
||||
}
|
||||
|
||||
/// What a name other than a family's own one stands for ([`FontDb::canonical`]).
|
||||
#[derive(Clone, Debug)]
|
||||
struct Alias {
|
||||
@@ -423,14 +585,20 @@ pub(crate) struct FaceStyle {
|
||||
weight: Option<f32>,
|
||||
/// A named instance is italic (by its `ital` or `slnt` setting, or its name).
|
||||
italic: bool,
|
||||
/// Its kind, for font menus' filters (the face's, shared by its named instances).
|
||||
traits: FontTraits,
|
||||
}
|
||||
|
||||
/// The styles of a face: the face itself, then its named instances when it is a variable font.
|
||||
pub(crate) fn face_styles(f: &skrifa::FontRef<'_>) -> Vec<FaceStyle> {
|
||||
let Some((family, style)) = face_names(f) else { return vec![] };
|
||||
let mut keys = FaceKeys::of(f, &family, &style);
|
||||
let instances = named_instances(f, &family, &style, &mut keys);
|
||||
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false };
|
||||
let traits = FontTraits::of(f, &keys);
|
||||
let mut instances = named_instances(f, &family, &style, &mut keys);
|
||||
for i in &mut instances {
|
||||
i.traits = traits;
|
||||
}
|
||||
let face = FaceStyle { family, style, keys, variations: vec![], weight: None, italic: false, traits };
|
||||
std::iter::once(face).chain(instances).collect()
|
||||
}
|
||||
|
||||
@@ -482,7 +650,7 @@ fn named_instances(f: &skrifa::FontRef<'_>, family: &str, style: &str, keys: &mu
|
||||
}
|
||||
let keys = FaceKeys { families: keys.families.clone(), styles, legacy: vec![], postscript };
|
||||
let variations = tags.iter().zip(&coords).map(|(t, v)| (t.to_be_bytes(), *v)).collect();
|
||||
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic });
|
||||
out.push(FaceStyle { family: family.to_string(), style, keys, variations, weight, italic, traits: FontTraits::default() });
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -543,11 +711,16 @@ fn file_face_names(path: &Path) -> Vec<FaceStyle> {
|
||||
let name = table(b"name")?;
|
||||
// A variable font's named instances are styles too.
|
||||
let fvar = table(b"fvar");
|
||||
// Its OS/2 table (the font's classification, for the menus' filters), when it has one.
|
||||
let os2 = table(b"OS/2");
|
||||
// A font holding just these tables, to read them as the font itself would.
|
||||
let mut tables: Vec<(&[u8; 4], &[u8])> = vec![(b"name", &name)];
|
||||
if let Some(fvar) = &fvar {
|
||||
tables.push((b"fvar", fvar));
|
||||
}
|
||||
if let Some(os2) = &os2 {
|
||||
tables.push((b"OS/2", os2));
|
||||
}
|
||||
let font = sfnt_of(&tables)?;
|
||||
Some(face_styles(&skrifa::FontRef::new(&font).ok()?))
|
||||
})
|
||||
@@ -622,7 +795,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
FontBytes::Owned(v) => v.as_slice(),
|
||||
};
|
||||
let f = skrifa::FontRef::from_index(data, index).ok()?;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic } = spec;
|
||||
let FaceStyle { family, style, keys, variations, weight, italic, traits } = spec;
|
||||
// A named instance: outlines, metrics and advances at its axis settings.
|
||||
let location = if variations.is_empty() { Location::default() } else { f.axes().location(variations.iter().map(|(t, v)| (Tag::new(t), *v))) };
|
||||
let m = f.metrics(Size::unscaled(), &location);
|
||||
@@ -633,6 +806,7 @@ fn make_face(bytes: FontBytes, index: u32, spec: FaceStyle, path: Option<std::pa
|
||||
(!variations.is_empty()).then(|| harfrust::ShaperInstance::from_variations(&hb, variations.iter().map(|(t, v)| (harfrust::Tag::new(t), *v))));
|
||||
Some(FontFace {
|
||||
keys,
|
||||
traits,
|
||||
id: NEXT_ID.fetch_add(1, Ordering::Relaxed),
|
||||
family,
|
||||
style,
|
||||
@@ -898,7 +1072,7 @@ impl FontDb {
|
||||
if !matches!(ext.as_deref(), Some("ttf" | "otf" | "ttc" | "otc")) {
|
||||
continue;
|
||||
}
|
||||
for FaceStyle { family, style, keys, weight, italic, .. } in file_face_names(&p) {
|
||||
for FaceStyle { family, style, keys, weight, italic, traits, .. } in file_face_names(&p) {
|
||||
for (k, a) in Alias::of(&family, &style, &keys) {
|
||||
aliases.entry(k).or_default().push(a);
|
||||
}
|
||||
@@ -907,6 +1081,7 @@ impl FontDb {
|
||||
postscript.insert(ps.clone(), (family.clone(), style.clone()));
|
||||
}
|
||||
let entry = catalog.entry(family.to_ascii_lowercase()).or_default();
|
||||
entry.traits.get_or_insert(traits);
|
||||
if entry.name.is_empty() {
|
||||
entry.name = family;
|
||||
}
|
||||
@@ -954,6 +1129,15 @@ impl FontDb {
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of `family` (whether it sets Japanese, its class), for font menu filters: from a
|
||||
/// loaded face, else from the system font scan. Cheap: no font file is read.
|
||||
pub fn family_traits(&self, family: &str) -> FontTraits {
|
||||
if let Some(f) = self.read_faces().iter().find(|f| f.family.eq_ignore_ascii_case(family)) {
|
||||
return f.traits;
|
||||
}
|
||||
self.read_catalog().get(&family.to_ascii_lowercase()).and_then(|c| c.traits).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`face`](Self::face) by any of the family's names ([`canonical`](Self::canonical)), and how
|
||||
/// it matched.
|
||||
pub fn resolve(&self, family: &str, style: &str) -> Option<(Arc<FontFace>, FontMatch)> {
|
||||
|
||||
@@ -24,7 +24,7 @@ pub mod thread;
|
||||
|
||||
pub use craft_fonts::{CRAFT_FONTS, CraftFont};
|
||||
pub use features::OtFeatures;
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontDb, FontFace, FontMatch, style_weight, system_font_dirs};
|
||||
pub use fontdb::{FALLBACK_FAMILY, FontClass, FontDb, FontFace, FontMatch, FontTraits, style_weight, system_font_dirs};
|
||||
use kurbo::{Affine, BezPath, Point, Rect, Vec2};
|
||||
pub use layout::{layout, layout_with};
|
||||
pub use vectorcraft_doc::TextObject;
|
||||
|
||||
@@ -0,0 +1,629 @@
|
||||
//! The font family menu of the Character and Properties panels: a search field over every family
|
||||
//! available, each row the family's name and a sample of the selected text set in it. The row
|
||||
//! under the pointer or reached with ↑/↓ previews its font on the selected text (one live
|
||||
//! interaction, nothing in the history); a click or Enter applies it as one step, Escape or
|
||||
//! closing the menu puts the text back. A star marks a favourite family, and the ★ filter lists
|
||||
//! only those. Preferences › Type › Enable in-menu font previews and Font Preview Size turn the
|
||||
//! samples off or size the rows.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, Sense, Ui, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use vectorcraft_text::FontClass;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::theme::Tokens;
|
||||
use crate::widgets;
|
||||
|
||||
/// What happened in the menu this frame.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum FontPick {
|
||||
/// A family (and one of its styles, when picked from the family's expanded list) was chosen.
|
||||
Chosen(String, Option<String>),
|
||||
/// The highlighted row changed: preview its family (and style).
|
||||
Preview(String, Option<String>),
|
||||
/// The menu closed (or came back to the current family) without a choice: undo the preview.
|
||||
EndPreview,
|
||||
/// The star of a family was clicked: add it to the favourites, or take it out.
|
||||
Favorite(String),
|
||||
}
|
||||
|
||||
/// How the menu looks: from the preferences and the favourites.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct MenuLook<'a> {
|
||||
/// Show each family's sample (Preferences › Type › Enable in-menu font previews).
|
||||
pub samples: bool,
|
||||
/// Row height in points (Font Preview Size).
|
||||
pub row: f32,
|
||||
/// The starred families.
|
||||
pub favorites: &'a [String],
|
||||
}
|
||||
|
||||
impl Default for MenuLook<'static> {
|
||||
/// Samples on, medium rows, no favourites.
|
||||
fn default() -> Self {
|
||||
Self { samples: true, row: ROW, favorites: &[] }
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> MenuLook<'a> {
|
||||
pub fn of(app: &'a VectorcraftApp) -> Self {
|
||||
let row = match app.session.prefs.font_preview_size.as_str() {
|
||||
"small" => 20.0,
|
||||
"large" => 32.0,
|
||||
_ => ROW,
|
||||
};
|
||||
Self { samples: app.session.prefs.font_preview, row, favorites: &app.ui.favorite_fonts }
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of a row, in points (Font Preview Size: Medium).
|
||||
const ROW: f32 = 24.0;
|
||||
/// Width of the star at the end of a family's row.
|
||||
const STAR: f32 = 18.0;
|
||||
/// The share of a row the family's name takes; the sample fills the rest.
|
||||
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.
|
||||
const SAMPLES_PER_FRAME: usize = 4;
|
||||
/// 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.
|
||||
const SCRIPTS: usize = 3;
|
||||
|
||||
fn script_label(i: usize) -> &'static str {
|
||||
match i {
|
||||
1 => tl!("Japanese"),
|
||||
2 => tl!("Latin"),
|
||||
_ => tl!("All Scripts"),
|
||||
}
|
||||
}
|
||||
|
||||
fn class_label(c: FontClass) -> &'static str {
|
||||
match c {
|
||||
FontClass::Serif => tl!("Serif / Mincho"),
|
||||
FontClass::Sans => tl!("Sans Serif / Gothic"),
|
||||
FontClass::Rounded => tl!("Rounded"),
|
||||
FontClass::Script => tl!("Script / Brush"),
|
||||
FontClass::Monospaced => tl!("Monospaced"),
|
||||
FontClass::Decorative => tl!("Decorative"),
|
||||
FontClass::Other => tl!("Other"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row of the list: a family, or one of the styles of an expanded family.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
enum Row {
|
||||
Family(String),
|
||||
Style(String, String),
|
||||
}
|
||||
|
||||
impl Row {
|
||||
fn family(&self) -> &str {
|
||||
match self {
|
||||
Row::Family(f) | Row::Style(f, _) => f,
|
||||
}
|
||||
}
|
||||
fn style(&self) -> Option<&str> {
|
||||
match self {
|
||||
Row::Family(_) => None,
|
||||
Row::Style(_, s) => Some(s),
|
||||
}
|
||||
}
|
||||
fn pick(&self) -> (String, Option<String>) {
|
||||
(self.family().to_string(), self.style().map(str::to_string))
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu's state between frames.
|
||||
#[derive(Clone, Default)]
|
||||
struct MenuState {
|
||||
/// The pass the list was last drawn in, and the pass it opened in.
|
||||
last: u64,
|
||||
opened: u64,
|
||||
/// The highlighted row and the query and filters it belongs to.
|
||||
highlight: Option<usize>,
|
||||
query: String,
|
||||
/// The filters (kept from one opening to the next): script (0..[`SCRIPTS`]), kind, and
|
||||
/// favourites only.
|
||||
script: usize,
|
||||
class: Option<FontClass>,
|
||||
favorites_only: bool,
|
||||
/// Families whose styles are listed (lowercase).
|
||||
expanded: Vec<String>,
|
||||
/// What was previewed last (None: nothing is previewed).
|
||||
previewed: Option<(String, Option<String>)>,
|
||||
}
|
||||
|
||||
/// The font family menu showing `current`, `width` points wide, rows sampling `sample` (the
|
||||
/// selected text; `None`: a generic sample).
|
||||
pub fn font_menu(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
current: &str,
|
||||
width: f32,
|
||||
sample: Option<&str>,
|
||||
look: MenuLook,
|
||||
) -> Option<FontPick> {
|
||||
let state_id = ui.id().with(&id).with("font-menu");
|
||||
let mut st: MenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let mut drawn = false;
|
||||
let picked = widgets::combo(ui, &id, current, width, true, |ui| {
|
||||
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() {
|
||||
// Closed (Escape, a click outside) with a preview showing.
|
||||
out = Some(FontPick::EndPreview);
|
||||
}
|
||||
if matches!(out, Some(FontPick::Chosen(..))) {
|
||||
st.previewed = None;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(state_id, st));
|
||||
out
|
||||
}
|
||||
|
||||
/// The list inside the open menu.
|
||||
fn list(ui: &mut Ui, state_id: egui::Id, st: &mut MenuState, current: &str, sample: Option<&str>, look: MenuLook) -> Option<FontPick> {
|
||||
let row_h = look.row;
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Each time the menu opens: no query, the current family highlighted and in view (the
|
||||
// filters stay as they were).
|
||||
let pass = ui.ctx().cumulative_pass_nr();
|
||||
let opening_now = st.last + 1 < pass || st.last == 0;
|
||||
if opening_now {
|
||||
st.opened = pass;
|
||||
st.highlight = None;
|
||||
st.query.clear();
|
||||
}
|
||||
st.last = pass;
|
||||
let field = state_id.with("query");
|
||||
if opening_now {
|
||||
ui.data_mut(|d| d.insert_temp(field, String::new()));
|
||||
}
|
||||
// A popup sizes itself invisibly in its first pass.
|
||||
let opening = pass <= st.opened + 1;
|
||||
// Arrow keys move the highlight and open or close a family's styles (taken before the
|
||||
// search field sees them).
|
||||
let key = |i: &mut egui::InputState, k| i.consume_key(egui::Modifiers::NONE, k);
|
||||
let (up, down, left, right) =
|
||||
ui.input_mut(|i| (key(i, egui::Key::ArrowUp), key(i, egui::Key::ArrowDown), key(i, egui::Key::ArrowLeft), key(i, egui::Key::ArrowRight)));
|
||||
if ui.memory(|m| m.focused().is_none()) {
|
||||
ui.memory_mut(|m| m.request_focus(field.with("edit")));
|
||||
}
|
||||
let query = widgets::search_field(ui, field, tl!("Search")).to_lowercase();
|
||||
// The filters: script, then kind.
|
||||
let mut refilter = false;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
for i in 0..SCRIPTS {
|
||||
if ui.selectable_label(st.script == i, egui::RichText::new(script_label(i)).size(11.0)).clicked() {
|
||||
st.script = i;
|
||||
refilter = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 2.0);
|
||||
if ui.selectable_label(st.favorites_only, egui::RichText::new("★").size(11.0)).on_hover_text(tl!("Favorites")).clicked() {
|
||||
st.favorites_only = !st.favorites_only;
|
||||
refilter = true;
|
||||
}
|
||||
if ui.selectable_label(st.class.is_none(), egui::RichText::new(tl!("Any Kind")).size(11.0)).clicked() {
|
||||
st.class = None;
|
||||
refilter = true;
|
||||
}
|
||||
for c in FontClass::ALL {
|
||||
if ui.selectable_label(st.class == Some(c), egui::RichText::new(class_label(c)).size(11.0)).clicked() {
|
||||
st.class = if st.class == Some(c) { None } else { Some(c) };
|
||||
refilter = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
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)));
|
||||
}
|
||||
}
|
||||
if query != st.query || refilter {
|
||||
st.query = query.clone();
|
||||
st.highlight = None;
|
||||
}
|
||||
if st.highlight.is_none() && query.is_empty() {
|
||||
st.highlight = rows.iter().position(|r| matches!(r, Row::Family(f) if f.eq_ignore_ascii_case(current)));
|
||||
}
|
||||
let mut keyed = false;
|
||||
if (up || down) && !rows.is_empty() {
|
||||
let last = rows.len() - 1;
|
||||
st.highlight = Some(match (st.highlight, down) {
|
||||
(None, _) => 0,
|
||||
(Some(i), true) => (i + 1).min(last),
|
||||
(Some(i), false) => i.saturating_sub(1),
|
||||
});
|
||||
keyed = true;
|
||||
}
|
||||
// → opens the highlighted family's styles, ← closes them (from the family or one of them).
|
||||
if let Some(row) = st.highlight.and_then(|i| rows.get(i)).cloned().filter(|_| left || right) {
|
||||
let f = row.family().to_lowercase();
|
||||
let open = st.expanded.contains(&f);
|
||||
if right && !open && db.styles(row.family()).len() > 1 {
|
||||
st.expanded.push(f);
|
||||
} else if left && open {
|
||||
st.expanded.retain(|e| *e != f);
|
||||
st.highlight = rows.iter().position(|r| *r == Row::Family(row.family().to_string()));
|
||||
}
|
||||
keyed = true;
|
||||
}
|
||||
if rows.is_empty() {
|
||||
widgets::dim_label(ui, tl!("No matching fonts"));
|
||||
return None;
|
||||
}
|
||||
// The rows: only those in view are laid out (there are thousands of families).
|
||||
const BOTTOM_GAP: f32 = 12.0;
|
||||
let room = (ui.ctx().content_rect().bottom() - ui.next_widget_position().y - BOTTOM_GAP).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));
|
||||
}
|
||||
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| {
|
||||
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());
|
||||
if resp.hovered() && ui.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO || inp.smooth_scroll_delta != egui::Vec2::ZERO) {
|
||||
hovered = Some(i);
|
||||
}
|
||||
let p = ui.painter_at(rect);
|
||||
if st.highlight == Some(i) {
|
||||
p.rect_filled(rect, 2.0, t.selection);
|
||||
} else if matches!(row, Row::Family(f) if f.eq_ignore_ascii_case(current)) {
|
||||
p.rect_filled(rect, 2.0, t.hover);
|
||||
}
|
||||
// The disclosure triangle of a family with several styles.
|
||||
let tri = egui::Rect::from_min_size(rect.min, vec2(DISCLOSURE, row_h));
|
||||
let mut on_triangle = false;
|
||||
if let Row::Family(f) = row
|
||||
&& db.styles(f).len() > 1
|
||||
{
|
||||
let open = st.expanded.iter().any(|e| e.eq_ignore_ascii_case(f));
|
||||
p.text(tri.center(), egui::Align2::CENTER_CENTER, if open { "▾" } else { "▸" }, egui::FontId::proportional(11.0), t.text_dim);
|
||||
on_triangle = resp.interact_pointer_pos().is_some_and(|pos| tri.contains(pos));
|
||||
if resp.clicked() && on_triangle {
|
||||
toggled = Some(f.to_lowercase());
|
||||
}
|
||||
}
|
||||
// The star at the end of a family's row.
|
||||
let star = egui::Rect::from_min_max(egui::pos2(rect.max.x - STAR, rect.min.y), rect.max);
|
||||
let mut on_star = false;
|
||||
if let Row::Family(f) = row {
|
||||
let on = favorite(f);
|
||||
let hot = resp.hover_pos().is_some_and(|pos| star.contains(pos));
|
||||
if on || hot || st.highlight == Some(i) {
|
||||
let color = if on { t.accent } else { t.text_dim };
|
||||
p.text(star.center(), egui::Align2::CENTER_CENTER, if on { "★" } else { "☆" }, egui::FontId::proportional(12.0), color);
|
||||
}
|
||||
on_star = resp.interact_pointer_pos().is_some_and(|pos| star.contains(pos));
|
||||
if resp.clicked() && on_star {
|
||||
starred = Some(f.clone());
|
||||
}
|
||||
}
|
||||
let indent = if row.style().is_some() { DISCLOSURE + 12.0 } else { DISCLOSURE };
|
||||
let name_w = rect.width() * NAME_SHARE;
|
||||
let name_rect = egui::Rect::from_min_max(rect.min + vec2(indent, 0.0), egui::pos2(rect.min.x + name_w - 4.0, rect.max.y));
|
||||
let name = row.style().unwrap_or(row.family());
|
||||
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);
|
||||
}
|
||||
if resp.clicked() && !on_triangle && !on_star {
|
||||
chosen = Some(row.pick());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(f) = toggled {
|
||||
if st.expanded.contains(&f) {
|
||||
st.expanded.retain(|e| *e != f);
|
||||
} else {
|
||||
st.expanded.push(f);
|
||||
}
|
||||
// The rows change next frame: what the highlight points at is decided then.
|
||||
return None;
|
||||
}
|
||||
if let Some(i) = hovered {
|
||||
st.highlight = Some(i);
|
||||
}
|
||||
if let Some(f) = starred {
|
||||
return Some(FontPick::Favorite(f));
|
||||
}
|
||||
if enter && chosen.is_none() {
|
||||
chosen = st.highlight.and_then(|i| rows.get(i)).or(rows.first()).map(Row::pick);
|
||||
}
|
||||
if let Some((f, s)) = chosen {
|
||||
ui.close();
|
||||
return Some(FontPick::Chosen(f, s));
|
||||
}
|
||||
// The highlighted row is previewed; back on the current family, the preview goes.
|
||||
let lit = st.highlight.and_then(|i| rows.get(i)).map(Row::pick);
|
||||
match lit {
|
||||
Some((f, None)) if f == current => st.previewed.take().map(|_| FontPick::EndPreview),
|
||||
Some(pick) if st.previewed.as_ref() != Some(&pick) => {
|
||||
st.previewed = Some(pick.clone());
|
||||
Some(FontPick::Preview(pick.0, pick.1))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- samples ----------
|
||||
|
||||
/// Width of the sample drawn at the end of a Type → Font menu item.
|
||||
const MENU_SAMPLE_WIDTH: f32 = 90.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);
|
||||
}
|
||||
}
|
||||
|
||||
type Key = (String, String, String, u32);
|
||||
|
||||
#[derive(Default)]
|
||||
struct Samples {
|
||||
ready: HashMap<Key, Option<egui::TextureHandle>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pending: HashMap<Key, std::sync::mpsc::Receiver<Option<egui::ColorImage>>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static SAMPLES: RefCell<Samples> = RefCell::new(Samples::default());
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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);
|
||||
SAMPLES.with(|s| {
|
||||
let mut s = s.borrow_mut();
|
||||
if let Some(t) = s.ready.get(&key) {
|
||||
return t.clone();
|
||||
}
|
||||
if s.ready.len() > 3000 {
|
||||
s.ready.clear();
|
||||
}
|
||||
#[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
if *budget == 0 || s.pending.len() >= 8 {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(30));
|
||||
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 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));
|
||||
});
|
||||
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
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
if *budget == 0 {
|
||||
ctx.request_repaint();
|
||||
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));
|
||||
s.ready.insert(key, tex.clone());
|
||||
tex
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Longest sample, in pixels per pixel of height (wider samples are cut).
|
||||
const MAX_ASPECT: f32 = 10.0;
|
||||
|
||||
/// `text` (or [`FALLBACK_SAMPLE`] when the font lacks some of its characters) set in `family`,
|
||||
/// `px` pixels high: a coverage mask as white with alpha.
|
||||
fn render_sample(family: &str, style: &str, text: &str, px: u32) -> Option<egui::ColorImage> {
|
||||
let db = vectorcraft_text::FontDb::global();
|
||||
let face: Arc<vectorcraft_text::FontFace> = db.face(family, style)?;
|
||||
let covers = |s: &str| !s.trim().is_empty() && s.chars().filter(|c| !c.is_whitespace()).all(|c| face.covers(c));
|
||||
let text = if covers(text) { text } else { FALLBACK_SAMPLE };
|
||||
if !covers(text) && !face.family.eq_ignore_ascii_case(family) {
|
||||
return None;
|
||||
}
|
||||
let (asc, desc) = face.vertical_metrics();
|
||||
let em = (asc + desc).max(1.0) / face.units_per_em();
|
||||
// The em box fills the height with a little air.
|
||||
let size = px as f64 * 0.8 / em;
|
||||
let style = vectorcraft_doc::CharStyle { font_family: face.family.clone(), font_style: face.style.clone(), size, ..Default::default() };
|
||||
let t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, text, style);
|
||||
let layout = vectorcraft_text::layout(db, &t);
|
||||
let mut path = layout.to_bezpath();
|
||||
let b = layout.bounds;
|
||||
let w = (b.width().ceil() as u32 + 2).clamp(1, (px as f32 * MAX_ASPECT) as u32) as usize;
|
||||
let h = px as usize;
|
||||
// Baseline where the ascent fits the top (with the air above it).
|
||||
let baseline = px as f64 * 0.1 + asc / face.units_per_em() * size;
|
||||
path.apply_affine(vectorcraft_geom::Affine::translate((1.0 - b.x0.min(0.0), baseline)));
|
||||
let mask = crate::panels::glyphs::rasterize(&path, w, h);
|
||||
Some(egui::ColorImage::new([w, h], mask.iter().map(|&a| Color32::from_rgba_premultiplied(a, a, a, a)).collect()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_sample_for_test(family: &str, text: &str, px: u32) -> Option<egui::ColorImage> {
|
||||
render_sample(family, "Regular", text, px)
|
||||
}
|
||||
|
||||
// ---------- applying a pick ----------
|
||||
|
||||
/// 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");
|
||||
match pick {
|
||||
FontPick::Preview(f, style) => {
|
||||
if !previewing {
|
||||
crate::panels::character::end_typing(app);
|
||||
if app.session.begin_interaction("Font").is_err() {
|
||||
return;
|
||||
}
|
||||
crate::panels::set_pstate(ctx, "font-previewing", true);
|
||||
}
|
||||
let (cmd, params) = font_command(app, &f, style.as_deref());
|
||||
if let Err(e) = app.session.preview(cmd, ¶ms) {
|
||||
app.ui.status = e.to_string();
|
||||
}
|
||||
app.sync_views();
|
||||
}
|
||||
FontPick::EndPreview => {
|
||||
if previewing {
|
||||
app.session.cancel_interaction().ok();
|
||||
crate::panels::set_pstate(ctx, "font-previewing", false);
|
||||
app.sync_views();
|
||||
}
|
||||
}
|
||||
FontPick::Favorite(f) => {
|
||||
let favs = &mut app.ui.favorite_fonts;
|
||||
match favs.iter().position(|x| x.eq_ignore_ascii_case(&f)) {
|
||||
Some(i) => {
|
||||
favs.remove(i);
|
||||
}
|
||||
None => favs.push(f),
|
||||
}
|
||||
}
|
||||
FontPick::Chosen(f, style) => {
|
||||
if previewing {
|
||||
app.session.cancel_interaction().ok();
|
||||
crate::panels::set_pstate(ctx, "font-previewing", false);
|
||||
}
|
||||
crate::panels::character::end_typing(app);
|
||||
let (cmd, params) = font_command(app, &f, style.as_deref());
|
||||
app.run(cmd, params).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The command that sets font `family` (and `style`, else the closest the family has) on what the
|
||||
/// menus act on.
|
||||
fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'static str, serde_json::Value) {
|
||||
let mut p = json!({"font": family});
|
||||
if let Some(s) = style {
|
||||
p["style"] = json!(s);
|
||||
}
|
||||
match crate::panels::character::text_editing(app) {
|
||||
Some((id, a, b)) => {
|
||||
p["id"] = json!(id.0);
|
||||
if b > a {
|
||||
p["start"] = json!(a);
|
||||
p["end"] = json!(b);
|
||||
}
|
||||
("text.setRangeStyle", p)
|
||||
}
|
||||
None => ("text.setStyle", p),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text the menus sample: the Type tool's selected characters, else the first line of the
|
||||
/// first selected text object, at most a dozen characters.
|
||||
pub(crate) fn sample_text(app: &VectorcraftApp) -> Option<String> {
|
||||
let doc = &app.session.active()?.doc;
|
||||
let text = match crate::panels::character::text_editing(app) {
|
||||
Some((id, a, b)) if b > a => match &doc.node(id)?.kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => t.plain_text().get(a..b).map(str::to_string),
|
||||
_ => None,
|
||||
},
|
||||
_ => app.session.active()?.selection.objects.iter().find_map(|id| match &doc.node(*id)?.kind {
|
||||
vectorcraft_doc::NodeKind::Text(t) => Some(t.plain_text()),
|
||||
_ => None,
|
||||
}),
|
||||
}?;
|
||||
let line = text.lines().map(str::trim).find(|l| !l.is_empty())?;
|
||||
Some(line.chars().take(12).collect())
|
||||
}
|
||||
@@ -9,9 +9,20 @@
|
||||
# tested). Left in English on purpose (`MENU_KEEP_AS_IS` in the tests): the product name, language
|
||||
# names, OpenType, the built-in workspace names and the bracketed perspective grid preset names.
|
||||
About VectorCraft VectorCraft について
|
||||
All Scripts すべての文字
|
||||
Any Kind すべての種類
|
||||
Decorative 装飾
|
||||
Favorites お気に入り
|
||||
Japanese 日本語
|
||||
Join Our Discord Discord に参加
|
||||
Line-end Punctuation Half Width 行末約物半角
|
||||
Mojikumi 文字組み
|
||||
Latin 欧文
|
||||
Monospaced 等幅
|
||||
Rounded 丸ゴシック
|
||||
Sans Serif / Gothic ゴシック・サンセリフ
|
||||
Script / Brush 筆書体・スクリプト
|
||||
Serif / Mincho 明朝・セリフ
|
||||
Settings… 環境設定…
|
||||
Language 表示言語
|
||||
UI Brightness UI の明るさ
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -119,6 +119,7 @@
|
||||
All on Active Artboard 作用中工作區域上的全部
|
||||
All on Same Layers 相同圖層上的全部
|
||||
All printer's marks 所有印表機標記
|
||||
All Scripts 所有文字
|
||||
All Text Objects 所有文字物件
|
||||
All the art 所有圖稿
|
||||
All Tools 所有工具
|
||||
@@ -141,6 +142,7 @@
|
||||
Anti-alias Rasters 點陣影像消除鋸齒
|
||||
Anti-aliased Artwork 消除鋸齒的圖稿
|
||||
Anti-aliasing: 消除鋸齒:
|
||||
Any Kind 所有類型
|
||||
Appearance 外觀
|
||||
Appearance Attribute 外觀屬性
|
||||
Appearance Item 外觀項目
|
||||
@@ -535,6 +537,7 @@
|
||||
Dates 日期
|
||||
Decimal-Justified Tab 小數點對齊定位點
|
||||
Decimal: 小數:
|
||||
Decorative 裝飾
|
||||
Default 預設
|
||||
Default Actions 預設動作
|
||||
Default Figure 預設數字
|
||||
@@ -874,6 +877,7 @@
|
||||
Eyedropper Picks Up: 滴管擷取:
|
||||
Eyedropper Tool 滴管工具
|
||||
Eyedropper: click the art to sample the stop's colour 滴管:點擊圖稿以取樣色標的顏色
|
||||
Favorites 我的最愛
|
||||
Feather… 羽化…
|
||||
Feet 英呎
|
||||
Feet & Inches 英呎與英吋
|
||||
@@ -1181,6 +1185,7 @@
|
||||
Isolate Blending 隔離混合
|
||||
Isolate Group 隔離群組
|
||||
Italian 義大利文
|
||||
Japanese 日文
|
||||
Join 連接
|
||||
Join Our Discord 加入我們的 Discord
|
||||
Join our Discord 加入我們的 Discord
|
||||
@@ -1226,6 +1231,7 @@
|
||||
Last Effect: {} 上次效果:{}
|
||||
Last Effect… 最後一次效果…
|
||||
Last Page 最後一頁
|
||||
Latin 拉丁文
|
||||
Latin Extended 拉丁文擴充
|
||||
Latin-1 Supplement 拉丁文-1 補充
|
||||
Layer 圖層
|
||||
@@ -1417,6 +1423,7 @@
|
||||
Monitor RGB 顯示器 RGB
|
||||
Mono 單色
|
||||
Monochrome images 單色影像
|
||||
Monospaced 等寬
|
||||
More 更多
|
||||
More Pathfinder options 更多路徑管理員選項
|
||||
More Settings 更多設定
|
||||
@@ -2091,6 +2098,7 @@
|
||||
Round Cap 圓端點
|
||||
Round Corners… 圓角…
|
||||
Round Join 圓角
|
||||
Rounded 圓體
|
||||
Rounded Rectangle 圓角矩形
|
||||
Rounded Rectangle Tool 圓角矩形工具
|
||||
Rounded Rectangle… 圓角矩形…
|
||||
@@ -2103,6 +2111,7 @@
|
||||
Same 相同
|
||||
Same As Source 與來源相同
|
||||
Sample Color 取樣顏色
|
||||
Sans Serif / Gothic 無襯線 / 黑體
|
||||
Saturate 飽和
|
||||
Saturate… 增加飽和度…
|
||||
Saturation 飽和度
|
||||
@@ -2229,6 +2238,7 @@
|
||||
Screen Mode 螢幕模式
|
||||
Screens 螢幕
|
||||
Scribble… 塗抹…
|
||||
Script / Brush 手寫 / 書法
|
||||
Scripts 指令碼
|
||||
Search 搜尋
|
||||
Search All 搜尋全部
|
||||
@@ -2288,6 +2298,7 @@
|
||||
Separations list the document's inks here: one page per ink that prints. 分色會在此列出文件的油墨:每種會列印的油墨各一頁。
|
||||
Separations Preview 分色預視
|
||||
Serialize Document 序列化文件
|
||||
Serif / Mincho 襯線 / 明體
|
||||
Server-side (.map) 伺服器端 (.map)
|
||||
Set 1 組 1
|
||||
Set Anchors 設定錨點
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -23,6 +23,7 @@ pub mod cursors;
|
||||
pub mod dialogs;
|
||||
pub mod dock;
|
||||
pub mod find_font;
|
||||
pub mod font_menu;
|
||||
pub mod i18n;
|
||||
pub mod icon_data;
|
||||
pub mod icons;
|
||||
@@ -64,6 +65,8 @@ mod tests_distortkeys;
|
||||
#[cfg(test)]
|
||||
mod tests_docsetup;
|
||||
#[cfg(test)]
|
||||
mod tests_font_menu;
|
||||
#[cfg(test)]
|
||||
mod tests_fonts;
|
||||
#[cfg(test)]
|
||||
mod tests_home;
|
||||
|
||||
@@ -2593,6 +2593,13 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], checks:
|
||||
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);
|
||||
}
|
||||
if r.clicked() {
|
||||
*clicked = Some(click_target(label_of(it), id, p));
|
||||
ui.close();
|
||||
|
||||
@@ -241,8 +241,9 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
return;
|
||||
};
|
||||
let w = ui.available_width();
|
||||
if let Some(f) = widgets::font_dropdown(ui, "ch-font", &s.font_family, w - 4.0) {
|
||||
style(app, json!({ "font": f }));
|
||||
let sample = crate::font_menu::sample_text(app);
|
||||
if let Some(pick) = crate::font_menu::font_menu(ui, "ch-font", &s.font_family, w - 4.0, sample.as_deref(), crate::font_menu::MenuLook::of(app)) {
|
||||
crate::font_menu::apply(app, ui.ctx(), pick);
|
||||
}
|
||||
let styles = vectorcraft_text::FontDb::global().styles(&s.font_family);
|
||||
let snames: Vec<&str> = styles.iter().map(String::as_str).collect();
|
||||
|
||||
@@ -387,8 +387,11 @@ pub fn type_sections(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
};
|
||||
let s = tx.first_style();
|
||||
section_header(ui, tl!("Character"));
|
||||
if let Some(f) = widgets::font_dropdown(ui, "font", &s.font_family, ui.available_width() - 4.0) {
|
||||
app.run("text.setStyle", json!({ "font": f })).ok();
|
||||
let sample = crate::font_menu::sample_text(app);
|
||||
if let Some(pick) =
|
||||
crate::font_menu::font_menu(ui, "font", &s.font_family, ui.available_width() - 4.0, sample.as_deref(), crate::font_menu::MenuLook::of(app))
|
||||
{
|
||||
crate::font_menu::apply(app, ui.ctx(), pick);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
dim_label(ui, tl!("Size"));
|
||||
|
||||
@@ -298,6 +298,9 @@ pub struct UiState {
|
||||
/// Type → Recent Fonts, most recent first.
|
||||
#[serde(default)]
|
||||
pub recent_fonts: Vec<String>,
|
||||
/// Families starred in the font menus (the ★ filter shows only these).
|
||||
#[serde(default)]
|
||||
pub favorite_fonts: Vec<String>,
|
||||
/// Engine preferences (Edit → Preferences), persisted alongside the UI state.
|
||||
#[serde(default)]
|
||||
pub engine_prefs: Value,
|
||||
@@ -416,6 +419,7 @@ impl Default for UiState {
|
||||
custom_workspaces: vec![],
|
||||
recent_files: vec![],
|
||||
recent_fonts: vec![],
|
||||
favorite_fonts: vec![],
|
||||
engine_prefs: Value::Null,
|
||||
color_guide: Default::default(),
|
||||
library_panel: None,
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
//! The font menu's samples, ↑/↓ highlight with live preview, and applying or undoing the preview.
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_doc::NodeKind;
|
||||
use vectorcraft_engine::Session;
|
||||
use vectorcraft_text::FontDb;
|
||||
|
||||
use crate::VectorcraftApp;
|
||||
use crate::font_menu::{FontPick, MenuLook, font_menu};
|
||||
|
||||
fn key(k: egui::Key) -> Vec<egui::Event> {
|
||||
[true, false].map(|pressed| egui::Event::Key { key: k, physical_key: None, pressed, repeat: false, modifiers: Default::default() }).to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrows_preview_the_next_family_and_enter_applies_it() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let 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, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
at = ui.next_widget_position();
|
||||
picked = font_menu(ui, "test-menu", "Inter", 260.0, Some("Gagaku"), MenuLook::default());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(at, picked)
|
||||
};
|
||||
let (at, _) = frame(vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(vec![button(false)]);
|
||||
for _ in 0..5 {
|
||||
frame(vec![]);
|
||||
}
|
||||
let families = FontDb::global().families();
|
||||
let inter = families.iter().position(|f| f == "Inter").unwrap();
|
||||
let next = families[inter + 1].clone();
|
||||
let (_, picked) = frame(key(egui::Key::ArrowDown));
|
||||
assert_eq!(picked, Some(FontPick::Preview(next.clone(), None)), "↓ previews the family below the current one");
|
||||
let (_, picked) = frame(key(egui::Key::ArrowUp));
|
||||
assert_eq!(picked, Some(FontPick::EndPreview), "back on the current family, the preview goes");
|
||||
frame(key(egui::Key::ArrowDown));
|
||||
let (_, picked) = frame(key(egui::Key::Enter));
|
||||
assert_eq!(picked, Some(FontPick::Chosen(next, None)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_closes_the_menu_and_ends_the_preview() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let 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, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
at = ui.next_widget_position();
|
||||
picked = font_menu(ui, "test-esc", "Inter", 260.0, None, MenuLook::default());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(at, picked)
|
||||
};
|
||||
let (at, _) = frame(vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(vec![button(false)]);
|
||||
for _ in 0..5 {
|
||||
frame(vec![]);
|
||||
}
|
||||
assert!(matches!(frame(key(egui::Key::ArrowDown)).1, Some(FontPick::Preview(..))));
|
||||
let mut ended = false;
|
||||
for events in [key(egui::Key::Escape), vec![], vec![], vec![]] {
|
||||
ended |= frame(events).1 == Some(FontPick::EndPreview);
|
||||
}
|
||||
assert!(ended, "closing without a choice ends the preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn samples_show_the_text_in_the_family_or_a_generic_sample() {
|
||||
let latin = crate::font_menu::render_sample_for_test("Source Serif 4", "Gagaku", 24).unwrap();
|
||||
assert_eq!(latin.size[1], 24);
|
||||
assert!(latin.pixels.iter().any(|p| p.a() > 0), "ink");
|
||||
// Japanese text in a Latin font: the generic sample, not glyphs from another font.
|
||||
let ja = crate::font_menu::render_sample_for_test("Source Serif 4", "雅楽", 24).unwrap();
|
||||
let sample = crate::font_menu::render_sample_for_test("Source Serif 4", "Sample", 24).unwrap();
|
||||
assert_eq!(ja.size, sample.size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preview_changes_the_text_live_and_only_a_choice_is_a_step() {
|
||||
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 font = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind {
|
||||
NodeKind::Text(t) => t.first_style().font_family,
|
||||
_ => String::new(),
|
||||
};
|
||||
let steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len();
|
||||
assert_eq!(crate::font_menu::sample_text(&app).as_deref(), Some("Gagaku"));
|
||||
let before = steps(&app);
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Preview("Source Serif 4".into(), None));
|
||||
assert_eq!(font(&app), "Source Serif 4", "previewed on the text");
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Preview("Source Sans 3".into(), None));
|
||||
assert_eq!(font(&app), "Source Sans 3");
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::EndPreview);
|
||||
assert_eq!((font(&app), steps(&app)), ("Inter".to_string(), before), "the preview leaves nothing behind");
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Preview("Source Serif 4".into(), None));
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Chosen("Source Serif 4".into(), None));
|
||||
assert_eq!((font(&app), steps(&app)), ("Source Serif 4".to_string(), before + 1), "one step");
|
||||
app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(font(&app), "Inter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_families_are_classified() {
|
||||
use vectorcraft_text::FontClass;
|
||||
let db = FontDb::global();
|
||||
let class = |f: &str| db.family_traits(f).class;
|
||||
assert_eq!(class("JetBrains Mono"), FontClass::Monospaced);
|
||||
assert_eq!(class("Source Serif 4"), FontClass::Serif);
|
||||
assert_eq!(class("Source Sans 3"), FontClass::Sans);
|
||||
assert!(!db.family_traits("Inter").japanese);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_arrow_lists_a_familys_styles_and_they_preview() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
let 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, mut picked) = (egui::Pos2::ZERO, None);
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
at = ui.next_widget_position();
|
||||
picked = font_menu(ui, "test-styles", "Source Sans 3", 260.0, None, MenuLook::default());
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
(at, picked)
|
||||
};
|
||||
let (at, _) = frame(vec![]);
|
||||
let click = at + egui::vec2(40.0, 10.0);
|
||||
let button = |pressed| egui::Event::PointerButton { pos: click, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
|
||||
frame(vec![egui::Event::PointerMoved(click), button(true)]);
|
||||
frame(vec![button(false)]);
|
||||
for _ in 0..5 {
|
||||
frame(vec![]);
|
||||
}
|
||||
let styles = FontDb::global().styles("Source Sans 3");
|
||||
assert!(styles.len() > 1);
|
||||
frame(key(egui::Key::ArrowRight));
|
||||
let (_, picked) = frame(key(egui::Key::ArrowDown));
|
||||
assert_eq!(picked, Some(FontPick::Preview("Source Sans 3".into(), Some(styles[0].clone()))), "the first style under the family");
|
||||
let (_, picked) = frame(key(egui::Key::ArrowDown));
|
||||
assert_eq!(picked, Some(FontPick::Preview("Source Sans 3".into(), Some(styles[1].clone()))));
|
||||
let (_, picked) = frame(key(egui::Key::Enter));
|
||||
assert_eq!(picked, Some(FontPick::Chosen("Source Sans 3".into(), Some(styles[1].clone()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stars_mark_favourites_and_the_preferences_size_the_rows() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), crate::Services::default());
|
||||
let ctx = egui::Context::default();
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Favorite("Inter".into()));
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Favorite("Source Serif 4".into()));
|
||||
assert_eq!(app.ui.favorite_fonts, ["Inter", "Source Serif 4"]);
|
||||
crate::font_menu::apply(&mut app, &ctx, FontPick::Favorite("inter".into()));
|
||||
assert_eq!(app.ui.favorite_fonts, ["Source Serif 4"], "a second click takes the star off (any case)");
|
||||
let saved: crate::state::UiState = serde_json::from_str(&serde_json::to_string(&app.ui).unwrap()).unwrap();
|
||||
assert_eq!(saved.favorite_fonts, ["Source Serif 4"], "favourites are kept with the UI state");
|
||||
let look = MenuLook::of(&app);
|
||||
assert!(look.samples && look.row == 24.0);
|
||||
app.run("prefs.set", json!({"values": {"fontPreviewSize": "large", "fontPreview": false}})).unwrap();
|
||||
let look = MenuLook::of(&app);
|
||||
assert!(!look.samples && look.row > 24.0, "Font Preview Size and Enable in-menu font previews");
|
||||
}
|
||||
@@ -465,7 +465,7 @@ pub fn dropdown_with(
|
||||
/// The recessed combo box of [`dropdown`] showing `current`; `list` draws the options and returns
|
||||
/// the chosen one. A `searchable` list (a search field over a list it scrolls itself) stays open
|
||||
/// while it is clicked: it closes itself when an option is chosen.
|
||||
fn combo<R>(
|
||||
pub(crate) fn combo<R>(
|
||||
ui: &mut Ui,
|
||||
id: impl std::hash::Hash + std::fmt::Debug,
|
||||
current: &str,
|
||||
|
||||
Reference in new issue
Block a user