Files
vectorcraft/crates/ui-egui/src/panels/character.rs
T

1206 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Character panel: font family / style, size, leading, kerning, tracking, vertical and horizontal
//! scale, baseline shift, character rotation and the All Caps, Small Caps, Superscript, Subscript,
//! Underline and Strikethrough toggles and the Language menu.
use std::sync::{Mutex, OnceLock};
use egui::{Key, Ui, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::setup::{LANGUAGES, char_language_name};
use vectorcraft_doc::{CharStyle, NodeId, NodeKind};
use vectorcraft_tools::{Mods, ToolKey};
use super::{pstate, set_pstate};
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::widgets::{self, SpinPick, menu_item};
/// The Font Size and Leading dropdowns' presets in points (Leading's after its Auto entry), shown
/// in the type unit.
pub const SIZE_PRESETS: [f64; 15] = [6.0, 7.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 21.0, 24.0, 36.0, 48.0, 60.0, 72.0];
/// The Kerning and Tracking dropdowns' presets in 1/1000 em.
pub const TRACKING_PRESETS: [f64; 14] = [-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
pub const SCALE_PRESETS: [f64; 8] = [25.0, 50.0, 75.0, 90.0, 100.0, 110.0, 125.0, 150.0];
/// The stored character style: the selected range's (or the caret's) while the Type
/// tool edits text, else the first selected text object's first run (including groups). With it, the paragraph
/// attributes of the paragraph the selection starts in (else of the first paragraph).
/// Font/paragraph controls and named-style capture use this; dimensional fields use `shared_effective`.
pub(crate) fn text_style(app: &VectorcraftApp) -> Option<(CharStyle, vectorcraft_doc::ParaStyle)> {
if let Some((id, a, b)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return Some((vectorcraft_text::edit::insertion_style(&t.runs, a, b), t.para_at(t.paragraphs_in(a, a).start).clone()));
}
let t = first_selected_text(app)?;
Some((t.first_style(), t.para_at(0).clone()))
}
/// The first selected text descendant, shared by Character and Paragraph controls.
pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_doc::TextObject> {
let st = app.session.active()?;
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
let mut text = None;
node.walk(&mut |n| {
if text.is_none()
&& let NodeKind::Text(t) = &n.kind
{
text = Some(t.as_ref());
}
});
if text.is_some() {
return text;
}
}
None
}
/// A boolean on every selected text object (`Some(true)` / `Some(false)`), or `None` when they
/// differ. When the Type tool edits text, only that object counts.
pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject) -> bool) -> Option<bool> {
if let Some((id, _, _)) = text_editing(app) {
return app.session.active().and_then(|st| st.doc.node(id)).and_then(|n| match &n.kind {
NodeKind::Text(t) => Some(f(t)),
_ => None,
});
}
let st = app.session.active()?;
let mut value: Option<bool> = None;
let mut mixed = false;
let mut saw = false;
for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
node.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
saw = true;
if mixed {
return;
}
let v = f(t);
match value {
None => value = Some(v),
Some(a) if a == v => {}
Some(_) => mixed = true,
}
}
});
}
if !saw || mixed { None } else { value }
}
/// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the
/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a
/// blank field) where they differ, or with no type.
pub(crate) fn shared<T: PartialEq>(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option<T> {
shared_styles(app, |_, _, style| Some(f(style)))
}
/// Dimensional controls use document units and tolerate arithmetic roundoff when comparing
/// compensated runs. Keep raw styles available to font/paragraph controls and style definitions,
/// even if a collapsed affine prevents editing dimensions.
fn shared_effective(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> f64) -> Option<f64> {
shared_styles(app, |t, _, style| t.effective_char_style(style).map(|s| Metric(f(&s)))).map(|v| v.0)
}
/// The Leading shown: each character's own, or for Auto leading the share of its size its
/// paragraph's Justification › Auto Leading sets, as the layout uses.
fn shared_leading(app: &VectorcraftApp) -> Option<f64> {
shared_styles(app, |t, para, style| t.effective_char_style(style).map(|s| Metric(para.effective_leading(&s)))).map(|v| v.0)
}
pub(crate) struct Metric(pub(crate) f64);
impl PartialEq for Metric {
fn eq(&self, other: &Self) -> bool {
(self.0 - other.0).abs() <= 1e-9 * self.0.abs().max(other.0.abs()).max(1.0)
}
}
/// `f` of each character style in the selection, with its text object and the paragraph the
/// characters are in (a run that spans paragraphs counts once for each).
fn shared_styles<T: PartialEq>(
app: &VectorcraftApp,
f: impl Fn(&vectorcraft_doc::TextObject, &vectorcraft_doc::ParaStyle, &CharStyle) -> Option<T>,
) -> Option<T> {
let st = app.session.active()?;
let text = |id: NodeId| match st.doc.node(id).map(|n| &n.kind) {
Some(NodeKind::Text(t)) => Some(t),
_ => None,
};
let mut acc = Shared::None;
if let Some((id, a, b)) = text_editing(app) {
let t = text(id)?;
if b <= a {
let para = t.plain_text().get(..a).map_or(0, |s| s.bytes().filter(|&c| c == b'\n').count());
return f(t, t.para_at(para), &vectorcraft_text::edit::insertion_style(&t.runs, a, b));
}
each_paragraph_run(t, Some((a, b)), |para, style| acc.add(f(t, para, style)));
} else {
for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) {
n.walk(&mut |c| {
if let NodeKind::Text(t) = &c.kind {
each_paragraph_run(t, None, |para, style| acc.add(f(t, para, style)));
}
});
}
}
acc.value().flatten()
}
/// `f` of the paragraph and style of each piece of `t`'s runs that lies in one paragraph (a
/// paragraph's closing line break belongs to it): the pieces within bytes `a..b`, or with no
/// range every run's, empty ones included. With no range, a final empty paragraph (the text ends
/// in a line break) counts too, with the style of the run before it, as the caret there types.
fn each_paragraph_run<'a>(
t: &'a vectorcraft_doc::TextObject,
range: Option<(usize, usize)>,
mut f: impl FnMut(&'a vectorcraft_doc::ParaStyle, &'a CharStyle),
) {
let (mut end, mut para) = (0, 0);
// The style of the last character and whether it is a line break: the paragraph after it has
// no characters of its own.
let mut last: Option<(&'a CharStyle, bool)> = None;
for r in &t.runs {
if r.text.is_empty() {
if range.is_none_or(|(a, b)| a < end && end < b) {
f(t.para_at(para), &r.style);
}
continue;
}
for piece in r.text.split_inclusive('\n') {
let start = end;
end += piece.len();
if range.is_none_or(|(a, b)| start < b && end > a) {
f(t.para_at(para), &r.style);
}
para += usize::from(piece.ends_with('\n'));
last = Some((&r.style, piece.ends_with('\n')));
}
}
if range.is_none()
&& let Some((style, true)) = last
{
f(t.para_at(para), style);
}
}
/// The value a run of styles share so far ([`shared`]).
pub(crate) enum Shared<T> {
None,
Same(T),
Differ,
}
impl<T: PartialEq> Shared<T> {
pub(crate) fn add(&mut self, v: T) {
match self {
Shared::None => *self = Shared::Same(v),
Shared::Same(x) if *x != v => *self = Shared::Differ,
_ => {}
}
}
pub(crate) fn value(self) -> Option<T> {
match self {
Shared::Same(v) => Some(v),
_ => None,
}
}
}
fn style(app: &mut VectorcraftApp, p: Value) {
char_cmd(app, "text.setStyle", p);
}
fn format(app: &mut VectorcraftApp, p: Value) {
char_cmd(app, "text.setFormat", p);
}
/// Apply character attributes: to the selected range while the Type tool edits text (the whole
/// object when nothing is selected), else to the selected text objects.
fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) {
if text_editing(app).is_none() {
app.run(cmd, p).ok();
return;
}
range_style(app, p);
}
/// Set the Kerning field's `value` (a number, "auto", or "optical"). At a caret in text the Type tool edits
/// it is the pair kerning after the character before the caret, as Alt+←/→ steps it
/// (`type.step`); nowhere to set at the start of the text. Else it is a character attribute like
/// the others: the selected range, or all the text.
fn set_kerning(app: &mut VectorcraftApp, value: Value) {
let Some((id, a, b)) = text_editing(app) else {
format(app, json!({"kerning": value}));
return;
};
if b > a {
range_style(app, json!({"kerning": value}));
return;
}
let Some(NodeKind::Text(t)) = app.session.active().and_then(|st| st.doc.node(id)).map(|n| &n.kind) else { return };
let start = vectorcraft_text::edit::prev_char(&t.plain_text(), a);
if start >= a {
return;
}
end_typing(app);
app.run("text.setRangeStyle", json!({"id": id.0, "start": start, "end": a, "kerning": value})).ok();
}
/// The pair before the caret, in 1/1000 em, using the layout the canvas draws.
fn caret_kerning(app: &VectorcraftApp, ui: &Ui) -> Option<(f64, bool)> {
let (id, a, b) = text_editing(app)?;
if a != b {
return None;
}
let NodeKind::Text(t) = &app.session.active()?.doc.node(id)?.kind else { return None };
let text = t.plain_text();
let start = vectorcraft_text::edit::prev_char(&text, a);
if start >= a {
return None;
}
let style = vectorcraft_text::edit::style_at(&t.runs, start + 1);
if let Some(v) = style.kerning {
return Some((v, false));
}
let state = app.session.active()?;
let key = (state.uid, state.revision, id, a);
let slot = ui.scope_id().with("caret-kerning");
if let Some((cached_key, value)) = ui.ctx().data(|d| d.get_temp::<((u64, u64, NodeId, usize), f64)>(slot))
&& cached_key == key
{
return Some((value, true));
}
let original = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
let value = vectorcraft_engine::cmd::effective_kerning(vectorcraft_text::FontDb::global(), t, &original, start, a);
let value = value.round();
ui.ctx().data_mut(|d| d.insert_temp(slot, (key, value)));
Some((value, true))
}
/// The faces Type › Bold and Type › Italic switch to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Face {
Bold,
Italic,
}
/// Words of a style name that set its weight: a style without any is the family's regular weight.
const WEIGHTS: [&str; 16] = [
"thin",
"hairline",
"extralight",
"ultralight",
"light",
"book",
"medium",
"semibold",
"demibold",
"demi",
"bold",
"extrabold",
"ultrabold",
"heavy",
"black",
"ultra",
];
/// Words that name the regular weight, upright: not part of what Bold and Italic keep.
const PLAIN: [&str; 5] = ["regular", "normal", "roman", "plain", "upright"];
/// A style name's (bold, regular weight, italic, the other words): `Bold Italic` is (true, false,
/// true, []), `Narrow Italic` (false, true, true, ["narrow"]), `SemiBold` (false, false, false, []).
fn face_of(style: &str) -> (bool, bool, bool, Vec<String>) {
let words: Vec<String> = style.split([' ', '-', '_']).filter(|w| !w.is_empty()).map(str::to_ascii_lowercase).collect();
let italic = words.iter().any(|w| w == "italic" || w == "oblique");
let bold = words.iter().any(|w| w == "bold");
let regular = !words.iter().any(|w| WEIGHTS.contains(&w.as_str()));
let mut rest: Vec<String> =
words.into_iter().filter(|w| !WEIGHTS.contains(&w.as_str()) && !PLAIN.contains(&w.as_str()) && w != "italic" && w != "oblique").collect();
rest.sort();
(bold, regular, italic, rest)
}
/// The style of `styles` that is bold (else of regular weight) and italic (else upright), with the
/// same other words as style `like` (a Narrow face stays Narrow, a plain one plain).
fn pick_face<'a>(styles: &'a [String], bold: bool, italic: bool, like: &str) -> Option<&'a str> {
let (.., keep) = face_of(like);
styles
.iter()
.map(String::as_str)
.filter(|s| {
let (b, r, i, rest) = face_of(s);
i == italic && (if bold { b } else { r }) && rest == keep
})
// `Regular` before `Regular Text`-like doubles: the plainest name is the family's own face.
.min_by_key(|s| s.len())
}
/// Type › Bold and Type › Italic (Cmd+Shift+B / Cmd+Shift+I while the Type tool edits text, as in
/// page layout apps): the family's own Bold or Italic face for the text the panels act on, or back
/// to its regular weight (upright) when it already is bold (italic). No fake bold or slant: a family
/// without that face is left as it is, and the message says so (#724).
pub(crate) fn toggle_face(app: &mut VectorcraftApp, face: Face) -> Result<Value, String> {
let (s, _) = text_style(app).ok_or("select some type first")?;
let styles = vectorcraft_text::FontDb::global().styles(&s.font_family);
let (bold, _, italic, _) = face_of(&s.font_style);
let (want_bold, want_italic) = match face {
Face::Bold => (!bold, italic),
Face::Italic => (bold, !italic),
};
let Some(name) = pick_face(&styles, want_bold, want_italic, &s.font_style) else {
let wanted = match (want_bold, want_italic) {
(true, true) => "Bold Italic",
(true, false) => "Bold",
(false, true) => "Italic",
(false, false) => "Regular",
};
return Err(format!("{} has no {} style", s.font_family, wanted));
};
let name = name.to_string();
style(app, json!({ "style": name }));
Ok(json!({ "style": name }))
}
/// The Character panel fields that the Highlight Font Name, Highlight Kerning and Highlight
/// Tracking commands give the keyboard focus ([`highlight`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Field {
/// The font family menu: it opens, and its search field takes the focus.
FontName,
Kerning,
Tracking,
}
impl Field {
/// The id the panel draws the field with ([`show`], [`metrics_grid`]).
fn salt(self) -> &'static str {
match self {
Field::FontName => "ch-font",
Field::Kerning => "ch-kern",
Field::Tracking => "ch-track",
}
}
fn name(self) -> &'static str {
match self {
Field::FontName => "fontName",
Field::Kerning => "kerning",
Field::Tracking => "tracking",
}
}
}
/// `type.highlightFontName`, `type.highlightKerning` and `type.highlightTracking`: show the
/// Character panel if it is hidden (a shown one is activated, so its floating window comes
/// forward), and give `field` the keyboard focus when the panel next draws it, a number field with
/// its value selected (typing replaces it, Enter applies it). Without selected type or text the
/// Type tool edits, the panel shows, nothing takes the focus and the command fails. → `{field}`.
pub(crate) fn highlight(app: &mut VectorcraftApp, field: Field) -> Result<Value, String> {
// `window.panel` would hide a floating panel in front of its window, or an open flyout.
if crate::panel_docking::showing(&app.ui, "character") {
app.run("window.panel.activate", json!({"panel": "character"}))?;
} else {
app.run("window.panel", json!({"panel": "character"}))?;
}
if text_style(app).is_none() {
app.character_focus = None;
return Err("select some type first".into());
}
app.character_focus = Some(field);
Ok(json!({ "field": field.name() }))
}
/// `type.underline` (Cmd+Shift+U): what the Underline button does, on the text the panels act on.
/// Returns whether the text is now underlined.
pub(crate) fn toggle_underline(app: &mut VectorcraftApp) -> Result<Value, String> {
let (s, _) = text_style(app).ok_or("select some type first")?;
format(app, json!({"underline": !s.underline}));
Ok(json!(!s.underline))
}
/// `text.setRangeStyle` with `p` on the range the Type tool has selected (the whole text when
/// nothing is selected). Does nothing unless the Type tool edits text.
pub(crate) fn range_style(app: &mut VectorcraftApp, p: Value) {
let Some((id, a, b)) = text_editing(app) else { return };
end_typing(app);
let mut q = p;
q["id"] = json!(id.0);
if b > a {
q["start"] = json!(a);
q["end"] = json!(b);
}
app.run("text.setRangeStyle", q).ok();
}
// ---------- Type tool editing glue (selection, clipboard, keys) ----------
/// The text object the Type tool is editing and its selection (clamped to the text).
pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize)> {
let o = app.session.tool_options();
let id = NodeId(o.get("editing")?.as_u64()?);
let len = match &app.session.active()?.doc.node(id)?.kind {
NodeKind::Text(t) => vectorcraft_text::edit::runs_len(&t.runs),
_ => return None,
};
let g = |k: &str| o.get(k).and_then(Value::as_u64).map_or(0, |v| (v as usize).min(len));
Some((id, g("start"), g("end")))
}
/// Close the Type tool's typing session (one undo step) before another command edits the text.
pub(crate) fn end_typing(app: &mut VectorcraftApp) {
if app.session.tool_options().get("typing").and_then(Value::as_bool) == Some(true) {
app.session.set_tool_option("commitTyping", &json!(true));
app.session.commit_interaction().ok();
app.sync_views();
}
}
static CTX: OnceLock<egui::Context> = OnceLock::new();
/// Plain text of the last text copied inside the Type tool (paste from the native menu, which
/// can't read the system clipboard).
static CLIP: Mutex<String> = Mutex::new(String::new());
static CLIP_RUNS: Mutex<Option<Value>> = Mutex::new(None);
fn text_copy(app: &mut VectorcraftApp) -> bool {
let Some((id, a, b)) = text_editing(app) else { return false };
if a == b {
return false;
}
let Ok(r) = app.session.execute("text.getRange", &json!({"id": id.0, "start": a, "end": b})) else { return false };
let runs = r["runs"].clone();
app.session.set_tool_option("copy", &runs);
*CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()) = Some(runs.clone());
let text = r["text"].as_str().unwrap_or("").to_string();
if let Some(ctx) = CTX.get() {
ctx.copy_text(text.clone());
}
*CLIP.lock().unwrap_or_else(|e| e.into_inner()) = text.clone();
if let Ok(parsed_runs) = serde_json::from_value::<Vec<vectorcraft_doc::TextRun>>(runs)
&& !parsed_runs.is_empty()
{
let mut t = vectorcraft_doc::TextObject::point(vectorcraft_geom::Point::ZERO, "", vectorcraft_doc::CharStyle::default());
t.runs = parsed_runs;
let node = vectorcraft_doc::Node::new(vectorcraft_doc::NodeId(1), vectorcraft_doc::NodeKind::Text(Box::new(t)));
app.session.clipboard.nodes = vec![node];
}
true
}
fn text_key(app: &mut VectorcraftApp, k: ToolKey, m: Mods) {
let v = app.view_info();
let target_before = crate::ime::target(&mut app.session, v);
let before = crate::ime::stamp(&mut app.session, v);
let r = app.session.tool_key(k, m, v);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => {
if let Some(ctx) = app.ui_ctx.clone().or_else(|| CTX.get().cloned()) {
crate::ime::note_application_edit(app, &ctx, v, target_before, before);
}
}
}
}
fn text_paste(app: &mut VectorcraftApp, s: Option<String>, plain: bool) {
let text_from_clip = CLIP.lock().unwrap_or_else(|e| e.into_inner()).clone();
let s = s.unwrap_or(text_from_clip);
if !s.is_empty() {
if plain {
app.session.set_tool_option("copy", &json!([]));
} else if let Some(runs) = CLIP_RUNS.lock().unwrap_or_else(|e| e.into_inner()).clone() {
let clip_text: String =
serde_json::from_value::<Vec<vectorcraft_doc::TextRun>>(runs.clone()).unwrap_or_default().into_iter().map(|r| r.text).collect();
if clip_text == s {
app.session.set_tool_option("copy", &runs);
}
}
let v = app.view_info();
let target_before = crate::ime::target(&mut app.session, v);
let before = crate::ime::stamp(&mut app.session, v);
let r = app.session.tool_text(&s, v);
match &r {
Err(e) => app.status(e.to_string()),
Ok(_) => {
if let Some(ctx) = app.ui_ctx.clone().or_else(|| CTX.get().cloned()) {
crate::ime::note_application_edit(app, &ctx, v, target_before, before);
}
}
}
}
}
/// Edit-menu commands while the Type tool edits text act on the text (Cut/Copy/Paste/Clear;
/// Select All is the engine's `select.all`). `None` = not intercepted.
pub(crate) fn intercept_text_command(app: &mut VectorcraftApp, id: &str) -> Option<Result<Value, String>> {
if !app.session.tool_wants_text() {
return None;
}
match id {
"edit.copy" => {
text_copy(app);
}
"edit.cut" => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
}
"edit.paste" => text_paste(app, None, false),
"edit.pasteWithoutFormatting" => text_paste(app, None, true),
"edit.clear" => text_key(app, ToolKey::Delete, Mods::default()),
_ => return None,
}
app.sync_views();
Some(Ok(Value::Null))
}
enum TextInput {
Key(ToolKey, Mods),
Copy,
Cut,
Paste(Option<String>),
Face(Face),
}
/// Route editing keys (with modifiers), clipboard events and Cmd+C/X/V to the Type tool (Cmd+A is
/// left to the Select All shortcut, which selects the text being edited).
pub(crate) fn route_type_input(app: &mut VectorcraftApp, ctx: &egui::Context) {
CTX.get_or_init(|| ctx.clone());
let mut todo = vec![];
ctx.input_mut(|i| {
i.events.retain(|e| match e {
egui::Event::Copy => {
todo.push(TextInput::Copy);
false
}
egui::Event::Cut => {
todo.push(TextInput::Cut);
false
}
egui::Event::Paste(s) => {
todo.push(TextInput::Paste(Some(s.clone())));
false
}
egui::Event::Key { key, pressed, modifiers: m, .. } => {
let mods = Mods { shift: m.shift, alt: m.alt, cmd: m.command, ctrl: m.ctrl && !m.command, space: false };
let tk = match key {
Key::ArrowLeft => Some(ToolKey::Left),
Key::ArrowRight => Some(ToolKey::Right),
Key::ArrowUp => Some(ToolKey::Up),
Key::ArrowDown => Some(ToolKey::Down),
Key::Home => Some(ToolKey::Home),
Key::End => Some(ToolKey::End),
Key::Backspace => Some(ToolKey::Backspace),
Key::Delete => Some(ToolKey::Delete),
Key::Tab if !m.command => Some(ToolKey::Tab),
_ => None,
};
if let Some(tk) = tk {
if *pressed {
todo.push(TextInput::Key(tk, mods));
}
return false;
}
// Type › Bold and Italic, as page layout apps have them while text is edited.
if m.command && m.shift && !m.alt && matches!(key, Key::B | Key::I) {
if *pressed {
todo.push(TextInput::Face(if *key == Key::B { Face::Bold } else { Face::Italic }));
}
return false;
}
if m.command && !m.shift && !m.alt && matches!(key, Key::C | Key::X | Key::V) {
if *pressed {
todo.push(match key {
Key::C => TextInput::Copy,
Key::X => TextInput::Cut,
_ => TextInput::Paste(None),
});
}
return false;
}
true
}
_ => true,
})
});
for t in todo {
match t {
TextInput::Key(k, m) => text_key(app, k, m),
TextInput::Copy => {
text_copy(app);
}
TextInput::Cut => {
if text_copy(app) {
text_key(app, ToolKey::Delete, Mods::default());
}
}
TextInput::Paste(s) => text_paste(app, s, false),
TextInput::Face(f) => {
if let Err(e) = toggle_face(app, f) {
app.status(e);
}
}
}
}
}
/// A compact labelled cell: short glyph label + spinner field.
fn cell(ui: &mut Ui, label: &str, tip: &str, add: impl FnOnce(&mut Ui)) {
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
let l =
ui.add_sized(vec2(22.0, 24.0), egui::Label::new(egui::RichText::new(label).size(11.5).strong().color(t.text))).on_hover_text(tl!(tip));
crate::scrub::note_label(ui, l.rect);
// The visible label names the value field beside it, also in a grid cell that draws more
// than one widget (egui 0.37's strict accessibility check).
ui.label_inputs_by(l.id, add);
});
}
/// The Character panel's grid of numeric fields, `w` wide: Font Size, Leading, Kerning and
/// Tracking, then with `more` the scales, Baseline Shift and Character Rotation (`s`: the style
/// shown). The Properties panel shows its first rows.
pub(crate) fn metrics_grid(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, s: &CharStyle, w: f32, more: bool) {
let fw = ((w - 66.0) / 2.0).clamp(60.0, 100.0);
let unit = app.session.type_unit();
egui::Grid::new(id).num_columns(2).spacing([6.0, 4.0]).show(ui, |ui| {
cell(ui, "T", tl!("Font Size"), |ui| size_field(app, ui, "ch-size", fw));
cell(ui, "A↕", tl!("Leading"), |ui| {
let leading = shared_leading(app);
let auto = shared(app, |s| s.leading.is_none()) == Some(true);
match widgets::spin_field_auto(ui, "ch-lead", leading, Some(auto), unit, fw, 1.0, 0.1, &SIZE_PRESETS) {
Some(SpinPick::Value(v)) => style(app, json!({"leading": v})),
Some(SpinPick::Auto) => style(app, json!({"leading": "auto"})),
Some(SpinPick::Optical) => {}
None => {}
}
});
ui.end_row();
cell(ui, "VA", tl!("Kerning"), |ui| {
let mode = shared(app, |s| (s.optical_kerning, s.kerning));
let caret = text_editing(app).is_some_and(|(_, a, b)| a == b);
let at_caret = if caret { caret_kerning(app, ui) } else { None };
let kerning = at_caret.map(|(v, _)| v).or_else(|| mode.map(|(_, manual)| manual.unwrap_or(0.0)));
let automatic = mode.map(|(optical, manual)| !optical && manual.is_none());
let optical = mode.map(|(optical, _)| optical);
match widgets::spin_kerning(
ui,
"ch-kern",
kerning,
automatic,
optical,
tl!("Optical"),
at_caret.is_some_and(|(_, auto)| auto),
fw,
&TRACKING_PRESETS,
) {
Some(SpinPick::Value(v)) => set_kerning(app, json!(v)),
Some(SpinPick::Auto) => set_kerning(app, json!("auto")),
Some(SpinPick::Optical) => set_kerning(app, json!("optical")),
None => {}
}
});
cell(ui, "VA↔", tl!("Tracking"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-track", shared(app, |s| s.tracking), "", 0, fw, 10.0, -1000.0, &TRACKING_PRESETS) {
style(app, json!({"tracking": v}));
}
});
ui.end_row();
if !more {
return;
}
cell(ui, "IT", tl!("Vertical Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-vs", shared(app, |s| s.v_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"vScale": v}));
}
});
cell(ui, "T↔", tl!("Horizontal Scale %"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-hs", shared_effective(app, |s| s.h_scale), "%", 1, fw, 1.0, 1.0, &SCALE_PRESETS) {
format(app, json!({"hScale": v}));
}
});
ui.end_row();
cell(ui, "Aª", tl!("Baseline Shift"), |ui| {
if let Some(v) = widgets::spin_field(ui, "ch-bs", shared_effective(app, |s| s.baseline_shift), unit, fw, 1.0, -1296.0, &[]) {
format(app, json!({"baselineShift": v}));
}
});
cell(ui, "⟲T", tl!("Character Rotation"), |ui| {
if let Some(v) = widgets::spin_plain(ui, "ch-rot", s.rotation, "°", 1, fw, 15.0, -360.0, &super::transform::ANGLE_PRESETS) {
format(app, json!({"rotation": v}));
}
});
ui.end_row();
});
}
/// The Font Size combo (Character and Properties panels, Control bar): typed, stepped, scrubbed or
/// picked from the presets, in the type unit, blank where the selected text's sizes differ.
pub(crate) fn size_field(app: &mut VectorcraftApp, ui: &mut Ui, id: &str, width: f32) {
let size = shared_effective(app, |s| s.size);
if let Some(v) = widgets::spin_field(ui, id, size, app.session.type_unit(), width, 1.0, 0.1, &SIZE_PRESETS) {
style(app, json!({"size": v}));
}
}
/// The font family menu and the font style dropdown of style `s` (Character and Properties panels,
/// Control bar), with their `ids` and `widths`.
pub(crate) fn font_pickers(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, ids: (&str, &str), widths: (f32, f32)) {
let sample = crate::font_menu::sample_text(app);
if let Some(pick) = crate::font_menu::font_menu(ui, ids.0, &s.font_family, widths.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 names: Vec<&str> = styles.iter().map(String::as_str).collect();
if let Some(name) = widgets::dropdown_names(ui, ids.1, &s.font_style, &names, widths.1).and_then(|i| names.get(i)) {
style(app, json!({"style": name}));
}
}
/// The Variable Font button, `width` wide, shown when the font of style `s` is a variable font: it
/// opens the font's axis sliders ([`axis_sliders`]).
pub(crate) fn variable_font(app: &mut VectorcraftApp, ui: &mut Ui, s: &CharStyle, width: f32) {
let Some(face) = vectorcraft_text::FontDb::global().face_version(&s.font_family, &s.font_style, s.font_version.as_deref()) else { return };
if face.axes().iter().all(|a| a.hidden) {
return;
}
let resp = widgets::flat_button(ui, tl!("Variable Font"), width);
widgets::popover(&resp, resp.clicked(), |ui| {
ui.set_width(240.0);
axis_sliders(app, ui, &face);
});
}
/// One slider and field per axis of `face`'s variable font (the axes it asks to hide left out),
/// named as the font names it: each sets that axis of the text the panel acts on
/// (`text.setFormat {fontAxes}`, or `text.setRangeStyle` while the Type tool edits text), one undo
/// step per drag. A blank field: the text's settings of that axis differ.
pub(crate) fn axis_sliders(app: &mut VectorcraftApp, ui: &mut Ui, face: &vectorcraft_text::FontFace) {
let t = Tokens::get(ui.ctx());
let db = vectorcraft_text::FontDb::global();
for axis in face.axes().into_iter().filter(|a| !a.hidden) {
let tag = String::from_utf8_lossy(&axis.tag).into_owned();
// Each character style's setting: its own, else its face's (a named instance's, or the default).
let setting = |s: &CharStyle| {
s.font_axes.get(&tag).copied().or_else(|| {
let face = db.face_version(&s.font_family, &s.font_style, s.font_version.as_deref())?;
face.axis_value(axis.tag).map(f64::from)
})
};
let value = shared(app, setting).flatten();
let (min, max) = (f64::from(axis.min.min(axis.max)), f64::from(axis.max.max(axis.min)));
ui.horizontal(|ui| {
let label = widgets::dim_name(ui, &axis.name);
crate::scrub::note_label(ui, label.rect);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if let Some(v) = widgets::spin_plain(ui, ("ch-axis-field", axis.tag), value, "", 1, 72.0, 1.0, min, &[]) {
axes_cmd(app, json!({ &tag: v.clamp(min, max) }), widgets::Live::Released);
}
});
});
let at = value.map_or(0.0, |v| if max > min { ((v - min) / (max - min)) as f32 } else { 0.0 });
// A mixed value leaves the field blank and the slider without a value.
let shown = value.map(|v| widgets::SliderValue::field(v, min..=max, "", 1));
let (picked, phase) =
widgets::color_slider_valued(ui, ("ch-axis", axis.tag), &axis.name, shown, at, ui.available_width(), &|_| t.button_border);
if let Some(p) = picked {
let v = ((min + f64::from(p) * (max - min)) * 10.0).round() / 10.0;
axes_cmd(app, json!({ &tag: v }), phase);
}
}
}
/// Set Variable Font settings `axes` on the text the panel acts on, live while a slider drags.
fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) {
match text_editing(app) {
Some((id, a, b)) => {
end_typing(app);
let mut q = json!({"id": id.0, "fontAxes": axes});
if b > a {
q["start"] = json!(a);
q["end"] = json!(b);
}
super::live_run(app, "Character", "text.setRangeStyle", q, phase);
}
None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase),
}
}
/// The Control bar's type controls while type is selected or edited: the Character link (the
/// Character panel in a popover), the font family and style menus and the Font Size combo.
pub(crate) fn control_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else { return };
let link = ui
.link(egui::RichText::new(tl!("Character:")).size(12.0).color(Tokens::get(ui.ctx()).text).underline())
.on_hover_text(tl!("Character options"));
widgets::popover(&link, link.clicked(), |ui| {
ui.set_width(260.0);
show(app, ui);
});
font_pickers(app, ui, &s, ("cb-font", "cb-font-style"), (150.0, 96.0));
size_field(app, ui, "cb-font-size", 96.0);
ui.add_space(4.0);
ui.separator();
}
/// The panel state key of the panel menu's Show Snap to Glyph Options.
const SNAP_TO_GLYPH_OPTIONS: &str = "ch-snap-to-glyph-options";
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
attributes(app, ui);
// Drawing over type needs no type selected, so the options show without one too.
if pstate::<bool>(ui.ctx(), SNAP_TO_GLYPH_OPTIONS) {
snap_to_glyph_options(app, ui);
}
}
/// The Snap to Glyph options (Show Snap to Glyph Options): which guides View › Snap to Glyph gives,
/// each a checkbox running `view.snapToGlyph.options`. Dimmed while Snap to Glyph is off.
fn snap_to_glyph_options(app: &mut VectorcraftApp, ui: &mut Ui) {
ui.add_space(4.0);
ui.separator();
widgets::section_header(ui, "Snap to Glyph");
let (o, enabled) = (app.session.prefs.glyph_snap, app.ui.view.snap_to_glyph);
egui::Grid::new("ch-snap-to-glyph").num_columns(2).show(ui, |ui| {
for (i, (label, key, on)) in [
("Baseline", "baseline", o.baseline),
("x-height", "xHeight", o.x_height),
("Glyph Bounds", "bounds", o.bounds),
("Proximity Guides", "proximity", o.proximity),
("Angular Guides", "angular", o.angular),
("Anchor Point", "anchors", o.anchors),
]
.into_iter()
.enumerate()
{
// A known key and a bool: the command can't refuse it.
if widgets::check(ui, label, on, enabled) {
app.run("view.snapToGlyph.options", json!({ key: !on })).ok();
}
if i % 2 == 1 {
ui.end_row();
}
}
});
}
/// The character attributes of the type selected (an empty state without any).
fn attributes(app: &mut VectorcraftApp, ui: &mut Ui) {
let Some((s, _)) = text_style(app) else {
app.character_focus = None;
super::empty_state(ui, "type", tl!("No text selected"), tl!("Select a text object to edit its character attributes."));
return;
};
let w = ui.available_width();
// The field a Highlight command asked for takes the focus as it is drawn. If no field took it
// (a sizing pass), the next frame tries again.
if let Some(f) = app.character_focus {
widgets::arm_field_focus(ui.ctx(), f.salt());
}
font_pickers(app, ui, &s, ("ch-font", "ch-style"), (w - 4.0, w - 4.0));
variable_font(app, ui, &s, w - 4.0);
ui.add_space(2.0);
let hidden = pstate::<bool>(ui.ctx(), "ch-hide-options");
metrics_grid(app, ui, "ch-grid", &s, w, !hidden);
if app.character_focus.is_some() && !widgets::disarm_field_focus(ui.ctx()) {
app.character_focus = None;
}
if hidden {
return;
}
ui.add_space(4.0);
ui.horizontal(|ui| {
let small = shared(app, |s| s.small_caps.is_some()) == Some(true);
let position = shared(app, |s| s.position).map(|p| p.id()).unwrap_or("");
for (kind, tip, on, change) in [
(Glyph::AllCaps, tl!("All Caps"), s.all_caps, json!({"allCaps": !s.all_caps})),
(Glyph::SmallCaps, tl!("Small Caps"), small, json!({"smallCaps": !small})),
(Glyph::Super, tl!("Superscript"), position == "superscript", position_toggle(position, "superscript")),
(Glyph::Sub, tl!("Subscript"), position == "subscript", position_toggle(position, "subscript")),
(Glyph::Underline, tl!("Underline"), s.underline, json!({"underline": !s.underline})),
(Glyph::Strike, tl!("Strikethrough"), s.strikethrough, json!({"strikethrough": !s.strikethrough})),
] {
if style_toggle(ui, kind, tip, on) {
format(app, change);
}
}
});
// Language: the characters are shaped in it, so that the font uses that language's forms.
ui.add_space(4.0);
let names: Vec<&str> = LANGUAGES.iter().map(|(n, _, _)| *n).collect();
// Mixed: blank; a tag from a file that is not in the list: as it is.
let current = shared(app, |s| s.language.clone())
.map_or_else(String::new, |l| char_language_name(l.as_deref()).map_or_else(|| l.unwrap_or_default(), str::to_string));
widgets::label_row(ui, tl!("Language:"), 64.0, |ui| {
if let Some(name) = widgets::dropdown(ui, "ch-language", &current, &names, w - 72.0).and_then(|i| names.get(i)) {
format(app, json!({"language": name}));
}
});
}
/// The `text.setFormat` params of the Superscript or Subscript toggle `p` for text whose
/// position is `position`: `normal` when `p` is on, else `p`, which turns the other one off.
fn position_toggle(position: &str, p: &str) -> Value {
json!({"position": if position == p { "normal" } else { p }})
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Glyph {
AllCaps,
SmallCaps,
Super,
Sub,
Underline,
Strike,
}
/// A drawn "T" style toggle (All Caps, Small Caps, Superscript, Subscript, Underline, Strikethrough).
fn style_toggle(ui: &mut Ui, g: Glyph, tip: &str, on: bool) -> bool {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 26.0), egui::Sense::click());
if on {
ui.painter().rect_filled(r, 3, t.tool_active);
} else if resp.hovered() {
ui.painter().rect_filled(r, 3, t.hover);
}
let col = t.text_strong;
let big = egui::FontId::proportional(15.0);
let small = egui::FontId::proportional(10.5);
let c = r.center();
let p = ui.painter();
match g {
Glyph::AllCaps => {
p.text(c - vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big.clone(), col);
p.text(c + vec2(5.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
}
Glyph::SmallCaps => {
p.text(c - vec2(4.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, 2.0), egui::Align2::CENTER_CENTER, "T", small, col);
}
Glyph::Super => {
p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, -5.0), egui::Align2::CENTER_CENTER, "1", small, col);
}
Glyph::Sub => {
p.text(c - vec2(3.0, 0.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.text(c + vec2(6.0, 5.0), egui::Align2::CENTER_CENTER, "1", small, col);
}
Glyph::Underline => {
p.text(c - vec2(0.0, 1.0), egui::Align2::CENTER_CENTER, "T", big, col);
p.line_segment([c + vec2(-5.0, 7.0), c + vec2(5.0, 7.0)], egui::Stroke::new(1.2, col));
}
Glyph::Strike => {
p.text(c, egui::Align2::CENTER_CENTER, "T", big, col);
p.line_segment([c + vec2(-6.0, 1.0), c + vec2(6.0, 1.0)], egui::Stroke::new(1.2, col));
}
}
let resp = resp.on_hover_text(tl!(tip));
resp.clicked()
}
/// The Character Direction submenu: Default, Left to Right and Right to Left set the characters the
/// panel acts on ([`char_cmd`]) as the bidi algorithm sets them, or left to right or right to left
/// inside their paragraph. The direction those characters share is checked, and none is checked
/// when they differ. At a caret in text the Type tool edits, with no characters selected, the items
/// are disabled.
fn char_direction_items(app: &mut VectorcraftApp, ui: &mut Ui) {
use vectorcraft_doc::ParaDirection;
let direction = shared(app, |s| s.char_direction);
let enabled = text_editing(app).is_none_or(|(_, a, b)| b > a);
for (label, d, key) in [
(tl!("Default"), None, "default"),
(tl!("Left to Right"), Some(ParaDirection::LeftToRight), "leftToRight"),
(tl!("Right to Left"), Some(ParaDirection::RightToLeft), "rightToLeft"),
] {
if menu_item(ui, label, enabled, direction == Some(d)) {
format(app, json!({"charDirection": key}));
}
}
}
/// The numeral systems Character panel menu › Digits lists after Default, with their English
/// labels, translated in the `digits` context (Hindi, Bengali, Tamil… are also languages): the
/// Arabic-script ones, then the Indic scripts' own digits from Devanagari on.
pub(crate) const DIGITS: [(&str, vectorcraft_doc::Digits); 12] = {
use vectorcraft_doc::Digits;
[
("Arabic", Digits::Arabic),
("Hindi", Digits::Hindi),
("Farsi", Digits::Farsi),
("Devanagari", Digits::Devanagari),
("Bengali", Digits::Bengali),
("Gurmukhi", Digits::Gurmukhi),
("Gujarati", Digits::Gujarati),
("Odia", Digits::Odia),
("Tamil", Digits::Tamil),
("Telugu", Digits::Telugu),
("Kannada", Digits::Kannada),
("Malayalam", Digits::Malayalam),
]
};
/// Character panel menu › Digits; `digits`: the selected characters' (`None` when they differ).
fn digits_items(app: &mut VectorcraftApp, ui: &mut Ui, digits: Option<vectorcraft_doc::Digits>) {
use vectorcraft_doc::Digits;
let mut item = |ui: &mut Ui, label: &str, d: Digits| {
if menu_item(ui, label, true, digits == Some(d)) {
format(app, json!({"digits": d.id()}));
}
};
item(ui, tl!("Default"), Digits::Default);
for (label, d) in DIGITS {
if d == Digits::Devanagari {
ui.separator();
}
item(ui, crate::i18n::tr_ctx(crate::i18n::current(), "digits", label), d);
}
}
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
let st = text_style(app);
let has = st.is_some();
let hidden: bool = pstate(ui.ctx(), "ch-hide-options");
if menu_item(ui, if hidden { tl!("Show Options") } else { tl!("Hide Options") }, true, false) {
set_pstate(ui.ctx(), "ch-hide-options", !hidden);
}
let glyph = pstate::<bool>(ui.ctx(), SNAP_TO_GLYPH_OPTIONS);
if menu_item(ui, if glyph { tl!("Hide Snap to Glyph Options") } else { tl!("Show Snap to Glyph Options") }, true, false) {
set_pstate(ui.ctx(), SNAP_TO_GLYPH_OPTIONS, !glyph);
}
ui.separator();
let s = st.map(|x| x.0);
for (label, key, on) in [
(tl!("All Caps"), "allCaps", s.as_ref().is_some_and(|s| s.all_caps)),
(tl!("Underline"), "underline", s.as_ref().is_some_and(|s| s.underline)),
(tl!("Strikethrough"), "strikethrough", s.as_ref().is_some_and(|s| s.strikethrough)),
] {
if menu_item(ui, label, has, on) {
format(app, json!({key: !on}));
}
}
let small = shared(app, |s| s.small_caps.is_some()) == Some(true);
let position = shared(app, |s| s.position).map(|p| p.id()).unwrap_or("");
for (label, on, change) in [
(tl!("Small Caps"), small, json!({"smallCaps": !small})),
(tl!("Superscript"), position == "superscript", position_toggle(position, "superscript")),
(tl!("Subscript"), position == "subscript", position_toggle(position, "subscript")),
] {
if menu_item(ui, label, has, on) {
format(app, change);
}
}
ui.separator();
// Character Alignment: where characters smaller than the largest on their line line up, with
// the East Asian options.
if app.session.prefs.show_east_asian_options {
let align = s.as_ref().map(|s| s.char_align);
ui.add_enabled_ui(has, |ui| {
// Indented like the items beside it (their check column).
ui.menu_button(format!(" {}", tl!("Character Alignment")), |ui| {
use vectorcraft_doc::CharAlign;
for (label, a, key) in [
(tl!("Roman Baseline"), CharAlign::RomanBaseline, "romanBaseline"),
(tl!("Em Box Top/Right"), CharAlign::EmBoxTop, "emBoxTop"),
(tl!("Em Box Center"), CharAlign::EmBoxCenter, "emBoxCenter"),
(tl!("Em Box Bottom/Left"), CharAlign::EmBoxBottom, "emBoxBottom"),
(tl!("ICF Top/Right"), CharAlign::IcfTop, "icfTop"),
(tl!("ICF Bottom/Left"), CharAlign::IcfBottom, "icfBottom"),
] {
if menu_item(ui, label, true, align == Some(a)) {
format(app, json!({"charAlign": key}));
}
}
});
});
// Proportional Metrics: full-width glyphs on the font's proportional widths (`palt`, `vpal`).
let proportional = s.as_ref().is_some_and(|s| s.proportional_metrics);
if menu_item(ui, tl!("Proportional Metrics"), has, proportional) {
format(app, json!({"proportionalMetrics": !proportional}));
}
// Tate-chu-yoko: the selected characters stand side by side across one em of a vertical column.
let tcy = s.as_ref().is_some_and(|s| s.tate_chu_yoko);
if menu_item(ui, tl!("Tate-chu-yoko"), has, tcy) {
format(app, json!({"tateChuYoko": !tcy}));
}
ui.separator();
}
// Digits: the numeral system the selected characters' digits are drawn in, with the Middle
// Eastern (Indic) options, as Paragraph Direction is in the Paragraph panel.
if app.session.prefs.show_indic_options {
let digits = s.as_ref().map(|s| s.digits);
ui.add_enabled_ui(has, |ui| {
ui.menu_button(format!(" {}", tl!("Digits")), |ui| digits_items(app, ui, digits));
});
ui.add_enabled_ui(has, |ui| {
ui.menu_button(format!(" {}", tl!("Character Direction")), |ui| char_direction_items(app, ui));
});
ui.separator();
}
// No Break: the selected characters stay on one line.
let no_break = s.as_ref().is_some_and(|s| s.no_break);
if menu_item(ui, tl!("No Break"), has, no_break) {
format(app, json!({"noBreak": !no_break}));
}
// Fractional Widths: the whole type object's spacing, on unless turned off. Not a character
// range, so a selection inside the text sets the object the Type tool is editing.
let fractional = fractional_widths_on(app);
if menu_item(ui, tl!("Fractional Widths"), has, fractional) {
let mut p = json!({});
if let Some((id, _, _)) = text_editing(app) {
p["id"] = json!(id.0);
}
app.run("type.fractionalWidths", p).ok();
}
// System Layout: whole-point spacing for every text object in the document. It does not need
// a type selection; a document is enough.
let open = app.session.active().is_some();
let system = app.session.active().is_some_and(|st| st.doc.setup.system_layout);
if menu_item(ui, tl!("System Layout"), open, system) {
app.run("type.systemLayout", json!({})).ok();
}
menu_item(ui, tl!("Standard Vertical Roman Alignment"), false, false);
ui.separator();
// The installed fonts are always listed; this picks up fonts installed since the app started.
#[cfg(not(target_arch = "wasm32"))]
if menu_item(ui, tl!("Refresh Font List"), true, false)
&& let Ok(r) = app.run("text.rescanFonts", json!({}))
{
app.status(format!("{} font families available", r["families"]));
}
if menu_item(ui, tl!("Reset Panel"), has, false) {
reset_panel(app);
}
}
/// Whether Fractional Widths is on for the type the panel shows: the object being edited, else
/// the first selected text object. On when there is no type (the item is disabled then).
fn fractional_widths_on(app: &VectorcraftApp) -> bool {
if let Some((id, _, _)) = text_editing(app)
&& let Some(NodeKind::Text(t)) = app.session.active().and_then(|d| d.doc.node(id)).map(|n| &n.kind)
{
return t.fractional_widths;
}
first_selected_text(app).is_none_or(|t| t.fractional_widths)
}
/// Reset Panel: the characters the panel acts on ([`char_cmd`]) take tracking 0, Auto leading and
/// kerning, no baseline shift, 100% scales, no rotation, no underline or strikethrough, All Caps
/// and Small Caps off, the normal position (neither superscript nor subscript), Roman Baseline
/// character alignment, Proportional Metrics, Tate-chu-yoko and No Break off, the Default character
/// direction and the Default digits, whether or not Show East Asian Options and Show Indic Options
/// are on.
fn reset_panel(app: &mut VectorcraftApp) {
let d = CharStyle::default();
style(app, json!({"tracking": 0, "leading": "auto"}));
format(
app,
json!({
"kerning": "auto", "baselineShift": 0, "hScale": 100, "vScale": 100, "rotation": 0, "underline": false, "strikethrough": false,
"allCaps": false, "smallCaps": d.small_caps.is_some(), "position": d.position.id(), "charAlign": d.char_align,
"proportionalMetrics": d.proportional_metrics, "tateChuYoko": d.tate_chu_yoko, "noBreak": d.no_break, "charDirection": "default",
"digits": d.digits
}),
);
}
#[cfg(test)]
mod tests;