mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-28 12:26:52 -06:00
refactor(ui-egui): split panels/typography.rs into typography/
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,187 @@
|
||||
use super::fields::menu_item_hover;
|
||||
use super::*;
|
||||
|
||||
/// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible
|
||||
/// axis (values the style name implies show until changed; "Mixed" when the shown styles differ).
|
||||
/// The canvas previews a hovered instance and a dragged slider or field live; letting go commits
|
||||
/// one undo step, Escape cancels.
|
||||
pub(super) fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle], face: &figcraft_text::Face) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let range = figcraft_engine::textedit::selected_range(&app.session);
|
||||
widgets::label(ui, &face.family, t.text_secondary);
|
||||
named_instances(app, ui, ids, range, styles, face);
|
||||
// Each style's axis values; a style drawn by another face (e.g. a separate italic file) is mixed.
|
||||
let per_style: Vec<Option<Vec<f64>>> = styles
|
||||
.iter()
|
||||
.map(|s| figcraft_text::find_face(&s.font_family, &s.font_style).filter(|f| f.id == face.id).map(|_| face.axis_values(s)))
|
||||
.collect();
|
||||
let w = ui.available_width();
|
||||
let field_w = 52.0;
|
||||
let label_w = 84.0;
|
||||
let slider_w = (w - label_w - field_w - 12.0).max(40.0);
|
||||
let cancel_id = Id::unique("ty_axis_cancelled");
|
||||
// A cancelled gesture ends when the pointer is let go.
|
||||
if !ui.input(|i| i.pointer.any_down()) {
|
||||
ui.data_mut(|d| d.remove::<bool>(cancel_id));
|
||||
}
|
||||
for (i, axis) in face.axes.iter().enumerate().filter(|(_, a)| !a.hidden) {
|
||||
let first = per_style.first().cloned().flatten().and_then(|v| v.get(i).copied());
|
||||
let value = first.filter(|v0| per_style.iter().all(|vs| vs.as_ref().and_then(|v| v.get(i)).is_some_and(|v| (v - v0).abs() < 1e-9)));
|
||||
// While a drag previews, the controls show the dragged value (the panel shows the document).
|
||||
let live_id = Id::unique(("ty_axis_live", &axis.tag));
|
||||
if app.ui.text_preview.is_none() {
|
||||
ui.data_mut(|d| d.remove::<f64>(live_id));
|
||||
}
|
||||
let shown = ui.data(|d| d.get_temp::<f64>(live_id)).or(value).map(|v| round_axis(axis, v));
|
||||
// (value, gesture ended)
|
||||
let mut set: Option<(Value, bool)> = None;
|
||||
ui.add_space(2.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 6.0;
|
||||
let (lr, _) = ui.allocate_exact_size(vec2(label_w, FIELD_H), Sense::hover());
|
||||
let name = ui.painter().layout(axis.name.clone(), regular(11.0), t.text, label_w);
|
||||
let tip = trf(
|
||||
"{name} ({tag}): {min} – {max}, default {default}. Double-click the slider to go back to the style's value; Escape while dragging cancels.",
|
||||
&[
|
||||
("name", &axis.name),
|
||||
("tag", &axis.tag),
|
||||
("min", &trim_num(axis.min)),
|
||||
("max", &trim_num(axis.max)),
|
||||
("default", &trim_num(axis.default)),
|
||||
],
|
||||
);
|
||||
ui.interact(lr, Id::unique(("ty_axis_label", &axis.tag)), Sense::hover()).on_hover_text(tip);
|
||||
ui.painter().galley(pos2(lr.min.x, lr.center().y - name.size().y / 2.0), name, t.text);
|
||||
match axis_slider(ui, Id::unique(("ty_axis_slider", &axis.tag)), axis, shown, slider_w) {
|
||||
Some((Some(v), done)) => set = Some((json!(round_axis(axis, v)), done)),
|
||||
Some((None, _)) => set = Some((Value::Null, true)),
|
||||
None => {}
|
||||
}
|
||||
let step = if axis.max - axis.min > 20.0 { 1.0 } else { 0.1 };
|
||||
match widgets::number_field(ui, Id::unique(("ty_axis_field", &axis.tag)), Lead::None, shown, "", field_w, step) {
|
||||
Some(FieldEdit::Set(v)) => set = Some((json!(axis.clamp(round_axis(axis, v))), true)),
|
||||
Some(FieldEdit::Scrub(v, done)) => set = Some((json!(axis.clamp(round_axis(axis, v))), done)),
|
||||
None => {}
|
||||
}
|
||||
});
|
||||
let Some((v, done)) = set else { continue };
|
||||
// Escape cancels a previewing gesture (egui may end the drag on it too): the preview ends
|
||||
// and the rest of the gesture is ignored until the pointer is let go.
|
||||
let previewing = ui.data(|d| d.get_temp::<f64>(live_id)).is_some();
|
||||
if previewing && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)) {
|
||||
ui.data_mut(|d| {
|
||||
d.insert_temp(cancel_id, true);
|
||||
d.remove::<f64>(live_id);
|
||||
});
|
||||
app.end_text_preview(ui.ctx());
|
||||
continue;
|
||||
}
|
||||
if ui.data(|d| d.get_temp::<bool>(cancel_id)).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
let params = json!({"ids": ids, "variations": {axis.tag.clone(): v}});
|
||||
if done {
|
||||
ui.data_mut(|d| d.remove::<f64>(live_id));
|
||||
app.run("text.setVariations", params);
|
||||
} else {
|
||||
if let Some(x) = v.as_f64() {
|
||||
ui.data_mut(|d| d.insert_temp(live_id, x));
|
||||
}
|
||||
app.preview_text(ui.ctx(), "text.setVariations", params);
|
||||
}
|
||||
}
|
||||
if styles.iter().any(|s| !s.variations.is_empty()) {
|
||||
ui.add_space(8.0);
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(tr("Reset to style")).font(regular(11.0)).color(t.accent_text)).sense(Sense::click()));
|
||||
if r.on_hover_text(tr("Back to the values the font style implies")).clicked() {
|
||||
app.run("text.setVariations", json!({"ids": ids, "variations": {}, "replace": true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The variable font's named instances as a menu: the one the text shows ("Custom" once an axis
|
||||
/// moved), hovering one previews it on the canvas, clicking applies it (its custom axis values go).
|
||||
fn named_instances(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
ids: &[NodeId],
|
||||
range: Option<(NodeId, usize, usize)>,
|
||||
styles: &[TextStyle],
|
||||
face: &figcraft_text::Face,
|
||||
) {
|
||||
if face.instances.is_empty() {
|
||||
return;
|
||||
}
|
||||
let current = uniform(styles, |s| (s.variations.is_empty(), s.font_style.clone()));
|
||||
let label = match ¤t {
|
||||
Some((true, st)) if face.instance(st).is_some() => st.clone(),
|
||||
Some(_) => tr("Custom").to_string(),
|
||||
None => tr(MIXED).to_string(),
|
||||
};
|
||||
ui.add_space(4.0);
|
||||
// An inline list (a menu would close the Type settings popover it opens from).
|
||||
let open_id = Id::unique("ty_instances_open");
|
||||
let mut open = ui.data(|d| d.get_temp::<bool>(open_id)).unwrap_or(false);
|
||||
if widgets::dropdown(ui, &label, ui.available_width(), None).on_hover_text(tr("Named instance")).clicked() {
|
||||
open = !open;
|
||||
}
|
||||
if open {
|
||||
ui.add_space(2.0);
|
||||
egui::ScrollArea::vertical().id_salt("ty_instances").max_height(220.0).show(ui, |ui| {
|
||||
for inst in &face.instances {
|
||||
let mark = if label == inst.name { "✓ " } else { " " };
|
||||
let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{}", inst.name));
|
||||
let props = json!({"fontStyle": inst.name, "fontVariations": null});
|
||||
if clicked {
|
||||
commit_text(app, ids, range, props);
|
||||
open = false;
|
||||
} else if hovered {
|
||||
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, props));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(open_id, open));
|
||||
ui.add_space(2.0);
|
||||
}
|
||||
|
||||
/// Axis values snap to whole numbers on wide axes (weight, width…) and tenths on narrow ones.
|
||||
pub(super) fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 {
|
||||
if axis.max - axis.min > 20.0 { v.round() } else { (v * 10.0).round() / 10.0 }
|
||||
}
|
||||
|
||||
/// A horizontal slider over an axis's range with a tick at its default. `value` None = mixed.
|
||||
/// Returns the new value (None = double-click: back to the style's value) and whether the
|
||||
/// gesture ended.
|
||||
fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option<f64>, width: f32) -> Option<(Option<f64>, bool)> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
let resp = ui.interact(rect, id, Sense::click_and_drag());
|
||||
let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0));
|
||||
let span = (axis.max - axis.min).max(1e-9);
|
||||
let x_of = |v: f64| track.min.x + ((v - axis.min) / span).clamp(0.0, 1.0) as f32 * track.width();
|
||||
let p = ui.painter();
|
||||
p.rect_filled(track, CornerRadius::same(2), t.border);
|
||||
let dx = x_of(axis.default);
|
||||
p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong));
|
||||
if let Some(v) = value {
|
||||
let x = x_of(v);
|
||||
p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent);
|
||||
p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE);
|
||||
let ring = if resp.hovered() || resp.dragged() { crate::theme::ACCENT } else { t.border_strong };
|
||||
p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring));
|
||||
}
|
||||
if resp.double_clicked() {
|
||||
return Some((None, true));
|
||||
}
|
||||
if (resp.dragged() || resp.clicked() || resp.drag_started())
|
||||
&& let Some(pos) = resp.interact_pointer_pos()
|
||||
{
|
||||
let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0));
|
||||
return Some((Some(axis.min + f * span), resp.drag_stopped() || resp.clicked()));
|
||||
}
|
||||
if resp.drag_stopped() {
|
||||
return value.map(|v| (Some(v), true));
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use super::axes::variable_axes;
|
||||
use super::glyphs::{LeadGlyph, caption, feature_row, text_seg};
|
||||
use super::picker::{font_picker, remember_family};
|
||||
use super::spacing::{SpacingField, commit_spacing, parse_letter_spacing, parse_line_height, spacing_field, spacing_input};
|
||||
use super::*;
|
||||
|
||||
/// The font rows: family, style and size, line height and letter spacing.
|
||||
pub(super) fn font_rows(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
ids: &[NodeId],
|
||||
styles: &[TextStyle],
|
||||
editing: bool,
|
||||
apply_style: &dyn Fn(&mut FigApp, &str, Value),
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let range = figcraft_engine::textedit::selected_range(&app.session);
|
||||
|
||||
// Font family.
|
||||
let family = uniform(styles, |s| s.font_family.clone());
|
||||
let fstyle = uniform(styles, |s| s.font_style.clone());
|
||||
row(ui, 32.0, |ui| {
|
||||
let w = ui.available_width();
|
||||
let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing);
|
||||
menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
|
||||
if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) {
|
||||
return;
|
||||
}
|
||||
let pick = font_picker(ui, family.as_deref(), resp.clicked());
|
||||
if let Some(f) = pick.picked {
|
||||
remember_family(ui.ctx(), &f);
|
||||
apply_style(app, "fontFamily", json!(f));
|
||||
ui.close();
|
||||
} else if let Some(h) = pick.hovered.or(pick.highlighted) {
|
||||
// The canvas shows the hovered family (or the one the arrow keys are on);
|
||||
// closing without picking ends the preview.
|
||||
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h})));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Style + size.
|
||||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||||
row(ui, 32.0, |ui| {
|
||||
let resp = font_dropdown(app, ui, ids, "fontStyle", fstyle.as_deref(), half, editing);
|
||||
let fam = family.clone();
|
||||
menu_popup(&resp).width(200.0).show(|ui| {
|
||||
if !editing && apply_variable_item(ui, ids, "fontStyle", &resp) {
|
||||
return;
|
||||
}
|
||||
match &fam {
|
||||
Some(fam) => {
|
||||
let list = figcraft_text::family_styles(fam);
|
||||
if list.is_empty() {
|
||||
widgets::label(ui, tr("Font not installed"), t.text_tertiary);
|
||||
}
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for s in list {
|
||||
let mark = if fstyle.as_deref() == Some(s.as_str()) { "✓ " } else { " " };
|
||||
let (clicked, hovered) = menu_item_hover(ui, &format!("{mark}{s}"));
|
||||
if clicked {
|
||||
apply_style(app, "fontStyle", json!(s));
|
||||
ui.close();
|
||||
} else if hovered {
|
||||
// The canvas shows the style (a variable font's named instance too).
|
||||
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontStyle": s})));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
None => widgets::label(ui, tr("Pick one font family first"), t.text_tertiary),
|
||||
}
|
||||
});
|
||||
let size = uniform(styles, |s| s.font_size);
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
// The whole layer's size can follow a variable; a selected range just takes a value.
|
||||
let e = if editing {
|
||||
widgets::number_field(ui, Id::unique("ty_size"), Lead::None, size, "", half - 20.0, 1.0)
|
||||
} else {
|
||||
crate::variable_ui::number_field(app, ui, ids, "fontSize", Id::unique("ty_size"), Lead::None, size, "", half - 20.0)
|
||||
};
|
||||
match e {
|
||||
Some(FieldEdit::Set(v)) => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))),
|
||||
// The text editing session already groups undo; just apply.
|
||||
Some(FieldEdit::Scrub(v, _)) if editing => apply_style(app, "fontSize", json!(v.clamp(1.0, 5000.0))),
|
||||
Some(FieldEdit::Scrub(v, done)) => {
|
||||
live(app, "Font size", "node.set", json!({"ids": ids, "props": {"fontSize": v.clamp(1.0, 5000.0)}}), done)
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
let more = icons::button(ui, Icon::Chevron, 20.0, false, tr("Font size"));
|
||||
menu_popup(&more).width(90.0).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
|
||||
for v in SIZES {
|
||||
let mark = if size == Some(v) { "✓ " } else { " " };
|
||||
if menu_item(ui, &format!("{mark}{}", trim_num(v)), "") {
|
||||
apply_style(app, "fontSize", json!(v));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Line height + letter spacing.
|
||||
row(ui, 32.0, |ui| {
|
||||
let lh = uniform(styles, |s| s.line_height);
|
||||
let lh_label = lh.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
|
||||
let field = SpacingField { field: "lineHeight", id: Id::unique("ty_lh"), glyph: LeadGlyph::LineHeight, tip: tr("Line height") };
|
||||
if let Some(edit) = spacing_field(app, ui, ids, &field, &lh_label, half, editing)
|
||||
&& let Some(v) = parse_line_height(&edit.text(), lh)
|
||||
{
|
||||
commit_spacing(app, ids, range, &field, &edit, v);
|
||||
}
|
||||
let ls = uniform(styles, |s| s.letter_spacing);
|
||||
let ls_label = ls.map(|l| l.label()).unwrap_or_else(|| tr(MIXED).into());
|
||||
let field = SpacingField { field: "letterSpacing", id: Id::unique("ty_ls"), glyph: LeadGlyph::LetterSpacing, tip: tr("Letter spacing") };
|
||||
if let Some(edit) = spacing_field(app, ui, ids, &field, &ls_label, half, editing)
|
||||
&& let Some(v) = parse_letter_spacing(&edit.text(), ls)
|
||||
{
|
||||
commit_spacing(app, ids, range, &field, &edit, v);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Paragraph spacing, resizing, alignment and the Type settings popover.
|
||||
pub(super) fn paragraph_rows(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
doc: &Document,
|
||||
ids: &[NodeId],
|
||||
styles: &[TextStyle],
|
||||
apply_style: &dyn Fn(&mut FigApp, &str, Value),
|
||||
) {
|
||||
let half = (ui.available_width() - PAD * 2.0 - 8.0) / 2.0;
|
||||
// Paragraph spacing + resizing.
|
||||
let ps = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_spacing.to_bits())).flatten().map(f64::from_bits);
|
||||
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
|
||||
row(ui, 32.0, |ui| {
|
||||
let ps_label = ps.map(trim_num).unwrap_or_else(|| tr(MIXED).into());
|
||||
let field =
|
||||
SpacingField { field: "paragraphSpacing", id: Id::unique("ty_ps"), glyph: LeadGlyph::ParagraphSpacing, tip: tr("Paragraph spacing") };
|
||||
if let Some(edit) = spacing_input(ui, &field, &ps_label, half)
|
||||
&& let Some(v) = widgets::eval(&edit.text(), ps)
|
||||
{
|
||||
commit_spacing(app, ids, None, &field, &edit, json!(v.clamp(0.0, 10_000.0)));
|
||||
}
|
||||
let items =
|
||||
[(Icon::Hug, tr("Auto width")), (Icon::LayoutV, tr("Auto height")), (Icon::Fixed, tr("Fixed size")), (Icon::Dots, tr("Truncate text"))];
|
||||
let active = resize.map(|r| match r {
|
||||
TextAutoResize::WidthAndHeight => 0,
|
||||
TextAutoResize::Height => 1,
|
||||
TextAutoResize::None => 2,
|
||||
TextAutoResize::Truncate => 3,
|
||||
});
|
||||
if let Some(i) = widgets::segmented(ui, &items, active, (half / 4.0).floor()) {
|
||||
let v = ["widthAndHeight", "height", "none", "truncate"].get(i).copied().unwrap_or("none");
|
||||
app.set_props(ids, json!({"textAutoResize": v}));
|
||||
}
|
||||
});
|
||||
|
||||
// Alignment + Type settings.
|
||||
let align = common(doc, ids, |n| n.kind.text().map(|t| (t.align_h, t.align_v))).flatten();
|
||||
row(ui, 32.0, |ui| {
|
||||
let items = [
|
||||
(Icon::TextAlignLeft, tr("Align left")),
|
||||
(Icon::TextAlignCenter, tr("Align center")),
|
||||
(Icon::TextAlignRight, tr("Align right")),
|
||||
(Icon::TextAlignJustify, tr("Justify")),
|
||||
];
|
||||
let active = align.map(|(h, _)| match h {
|
||||
TextAlignH::Left => 0,
|
||||
TextAlignH::Center => 1,
|
||||
TextAlignH::Right => 2,
|
||||
TextAlignH::Justified => 3,
|
||||
});
|
||||
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
|
||||
let v = ["left", "center", "right", "justified"].get(i).copied().unwrap_or("left");
|
||||
app.set_props(ids, json!({"textAlign": v}));
|
||||
}
|
||||
let items = [(Icon::TextTop, tr("Align top")), (Icon::TextMiddle, tr("Align middle")), (Icon::TextBottom, tr("Align bottom"))];
|
||||
let active = align.map(|(_, v)| match v {
|
||||
TextAlignV::Top => 0,
|
||||
TextAlignV::Middle => 1,
|
||||
TextAlignV::Bottom => 2,
|
||||
});
|
||||
if let Some(i) = widgets::segmented(ui, &items, active, 24.0) {
|
||||
let v = ["top", "middle", "bottom"].get(i).copied().unwrap_or("top");
|
||||
app.set_props(ids, json!({"textAlignVertical": v}));
|
||||
}
|
||||
let open_id = Id::unique("ty_settings_open");
|
||||
let open = egui::Popup::is_id_open(ui.ctx(), open_id);
|
||||
let more = icons::button(ui, Icon::Dots, 24.0, open, tr("Type settings"));
|
||||
egui::Popup::from_toggle_button_response(&more)
|
||||
.id(open_id)
|
||||
.width(264.0)
|
||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
||||
.show(|ui| type_settings(app, ui, doc, ids, styles, apply_style));
|
||||
});
|
||||
}
|
||||
|
||||
/// A menu item that also reports the pointer being over it: (clicked, hovered).
|
||||
pub(super) fn menu_item_hover(ui: &mut Ui, label: &str) -> (bool, bool) {
|
||||
let r = crate::right::menu_row(ui, label, "", 0.0, |_, _, _| {});
|
||||
(r.clicked(), r.hovered())
|
||||
}
|
||||
|
||||
/// The font family or font style dropdown for `field`, `width` wide, showing `shown` (Mixed when
|
||||
/// None). While whole layers are edited and `field` is bound to the same variable on every one of
|
||||
/// `ids`, the variable's chip is drawn in place of the dropdown (a click picks another variable,
|
||||
/// its detach button unbinds it), and the returned response opens no menu.
|
||||
fn font_dropdown(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], field: &str, shown: Option<&str>, width: f32, editing: bool) -> egui::Response {
|
||||
if !editing && app.session.doc().ok().and_then(|d| crate::variable_ui::bound_name(d, ids, field)).is_some() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
crate::variable_ui::bound_chip(app, ui, ids, field, VariableType::String, rect);
|
||||
return resp;
|
||||
}
|
||||
widgets::dropdown(ui, shown.unwrap_or(tr(MIXED)), width, None)
|
||||
}
|
||||
|
||||
/// Apply variable at the top of the font family or font style menu opened from `anchor`: a click
|
||||
/// opens the variable picker on string variables for `field` of `ids` and closes the menu (true).
|
||||
fn apply_variable_item(ui: &mut Ui, ids: &[NodeId], field: &str, anchor: &egui::Response) -> bool {
|
||||
let picked = menu_item(ui, tr("Apply variable"), "");
|
||||
if picked {
|
||||
crate::variable_ui::open(ui, ids, field, VariableType::String, anchor.rect.left_bottom());
|
||||
ui.close();
|
||||
} else {
|
||||
ui.separator();
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// The variable font the shown styles use (one family, one variable face), if any.
|
||||
fn variable_face(styles: &[TextStyle]) -> Option<Arc<figcraft_text::Face>> {
|
||||
let family = uniform(styles, |s| s.font_family.clone())?;
|
||||
let face = figcraft_text::find_face(&family, &styles.first()?.font_style)?;
|
||||
(face.is_variable() && face.family.eq_ignore_ascii_case(&family)).then_some(face)
|
||||
}
|
||||
|
||||
/// The Type settings popover: Basics (decoration, case, list, indent, truncation), Details
|
||||
/// (OpenType features) and, for variable fonts, Variable (axis sliders).
|
||||
fn type_settings(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
doc: &Document,
|
||||
ids: &[NodeId],
|
||||
styles: &[TextStyle],
|
||||
apply_style: &dyn Fn(&mut FigApp, &str, Value),
|
||||
) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let tab_id = Id::unique("ty_settings_tab");
|
||||
let vf = variable_face(styles);
|
||||
let tabs = &TABS[..if vf.is_some() { 3 } else { 2 }];
|
||||
let mut tab: usize = ui.data(|d| d.get_temp(tab_id)).filter(|i| *i < tabs.len()).unwrap_or(0);
|
||||
ui.horizontal(|ui| {
|
||||
for (i, l) in tabs.iter().enumerate() {
|
||||
let col = if tab == i { t.text } else { t.text_secondary };
|
||||
let r = ui.add(egui::Label::new(egui::RichText::new(tr(l)).font(semibold(11.5)).color(col)).sense(Sense::click()));
|
||||
if r.clicked() {
|
||||
tab = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(tab_id, tab));
|
||||
ui.add_space(4.0);
|
||||
let w = ui.available_width();
|
||||
if tab == 0 {
|
||||
let deco = uniform(styles, |s| s.decoration);
|
||||
caption(ui, tr("Decoration"));
|
||||
let active = deco.map(|d| match d {
|
||||
TextDecoration::None => 0,
|
||||
TextDecoration::Underline => 1,
|
||||
TextDecoration::Strikethrough => 2,
|
||||
});
|
||||
let items = [("—", tr_noop("None")), ("U", tr_noop("Underline")), ("S", tr_noop("Strikethrough"))];
|
||||
if let Some(i) = text_seg(ui, "deco", &items, active, w) {
|
||||
apply_style(app, "textDecoration", json!((["none", "underline", "strikethrough"].get(i).copied().unwrap_or("none"))));
|
||||
}
|
||||
if let Some(d @ (TextDecoration::Underline | TextDecoration::Strikethrough)) = deco {
|
||||
decoration_details(app, ui, styles, apply_style, d == TextDecoration::Underline, w);
|
||||
}
|
||||
caption(ui, tr("Letter case"));
|
||||
let case = uniform(styles, |s| s.case);
|
||||
let active = case.map(|c| match c {
|
||||
TextCase::Original => 0,
|
||||
TextCase::Upper => 1,
|
||||
TextCase::Lower => 2,
|
||||
TextCase::Title => 3,
|
||||
TextCase::SmallCaps => 4,
|
||||
});
|
||||
let items = [
|
||||
("—", tr_noop("As typed")),
|
||||
("AG", tr_noop("Uppercase")),
|
||||
("ag", tr_noop("Lowercase")),
|
||||
("Ag", tr_noop("Title case")),
|
||||
("Aɢ", tr_noop("Small caps")),
|
||||
];
|
||||
if let Some(i) = text_seg(ui, "case", &items, active, w) {
|
||||
apply_style(app, "textCase", json!((["original", "upper", "lower", "title", "smallCaps"].get(i).copied().unwrap_or("original"))));
|
||||
}
|
||||
caption(ui, tr("List style"));
|
||||
// While editing: the selected paragraphs' list.
|
||||
let range = figcraft_engine::textedit::selected_range(&app.session);
|
||||
let list = common(doc, ids, |n| {
|
||||
n.kind.text().and_then(|t| {
|
||||
let (a, b) = range.filter(|r| r.0 == n.id).map_or((0, t.char_count()), |(_, a, b)| (a, b));
|
||||
t.common_para_list(a, b).map(|p| p.list)
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let active = list.map(|l| match l {
|
||||
ListStyle::None => 0,
|
||||
ListStyle::Bulleted => 1,
|
||||
ListStyle::Numbered => 2,
|
||||
});
|
||||
let items = [("—", tr_noop("No list")), ("•", tr_noop("Bulleted list")), ("1.", tr_noop("Numbered list"))];
|
||||
if let Some(i) = text_seg(ui, "list", &items, active, w) {
|
||||
app.run("text.listStyle", json!({"ids": ids, "style": (["none", "bulleted", "numbered"].get(i).copied().unwrap_or("none"))}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let spacing = common(doc, ids, |n| n.kind.text().map(|t| t.list_spacing.to_bits())).flatten().map(f64::from_bits);
|
||||
field_row(ui, tr("List spacing"), |ui, fw| {
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field(ui, Id::unique("ty_list_spacing"), Lead::None, spacing, "", fw, 1.0)
|
||||
{
|
||||
app.set_props(ids, json!({"listSpacing": v.clamp(0.0, 10_000.0)}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let indent = common(doc, ids, |n| n.kind.text().map(|t| t.paragraph_indent.to_bits())).flatten().map(f64::from_bits);
|
||||
// Indent applies to start-aligned text only; elsewhere the field stays visible but off.
|
||||
let start_aligned =
|
||||
common(doc, ids, |n| n.kind.text().map(|t| t.align_h != TextAlignH::Center && t.align_h != TextAlignH::Justified)).flatten();
|
||||
ui.add_enabled_ui(start_aligned != Some(false), |ui| {
|
||||
let field =
|
||||
field_row(ui, tr("Paragraph indent"), |ui, fw| widgets::number_field(ui, Id::unique("ty_indent"), Lead::None, indent, "", fw, 1.0));
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) = field {
|
||||
app.set_props(ids, json!({"paragraphIndent": v.clamp(0.0, 10_000.0)}));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_disabled_hover_text(tr("Paragraph indent applies to left-aligned text"));
|
||||
ui.add_space(4.0);
|
||||
for (key, label) in [("hangingPunctuation", tr("Hanging punctuation")), ("hangingList", tr("Hanging list"))] {
|
||||
let all_on = ids.iter().all(|i| {
|
||||
doc.node(*i).and_then(|n| n.kind.text()).is_some_and(|t| if key == "hangingList" { t.hanging_list } else { t.hanging_punctuation })
|
||||
});
|
||||
if widgets::checkbox(ui, all_on, label) {
|
||||
app.set_props(ids, json!({ key: !all_on }));
|
||||
}
|
||||
}
|
||||
caption(ui, tr("Vertical trim"));
|
||||
let trim = common(doc, ids, |n| n.kind.text().map(|t| usize::from(t.leading_trim == LeadingTrim::CapHeight))).flatten();
|
||||
let items = [(tr("Standard"), tr_noop("Standard")), ("Ag", tr_noop("Cap height to baseline"))];
|
||||
if let Some(i) = text_seg(ui, "trim", &items, trim, w) {
|
||||
app.set_props(ids, json!({"leadingTrim": if i == 1 { "capHeight" } else { "none" }}));
|
||||
}
|
||||
let resize = common(doc, ids, |n| n.kind.text().map(|t| t.auto_resize)).flatten();
|
||||
// Auto-height text with a line limit truncates too.
|
||||
let truncating = common(doc, ids, |n| n.kind.text().map(TextData::truncates)).flatten();
|
||||
caption(ui, tr("Truncate text"));
|
||||
match text_seg(
|
||||
ui,
|
||||
"trunc",
|
||||
&[(tr("Off"), tr_noop("Don't truncate")), ("…", tr_noop("Truncate with an ellipsis"))],
|
||||
truncating.map(usize::from),
|
||||
w,
|
||||
) {
|
||||
Some(1) if truncating != Some(true) => app.set_props(ids, json!({"textAutoResize": "truncate"})),
|
||||
Some(0) if resize == Some(TextAutoResize::Height) => {
|
||||
if truncating == Some(true) {
|
||||
app.set_props(ids, json!({"maxLines": 0}));
|
||||
}
|
||||
}
|
||||
Some(0) if resize != Some(TextAutoResize::WidthAndHeight) => app.set_props(ids, json!({"textAutoResize": "none"})),
|
||||
_ => {}
|
||||
}
|
||||
// Max lines only matter while truncating; no limit shows "Auto".
|
||||
if truncating == Some(true) {
|
||||
let max = common(doc, ids, |n| n.kind.text().map(|t| t.max_lines)).flatten();
|
||||
ui.add_space(4.0);
|
||||
let shown = max.map(f64::from).filter(|m| *m > 0.0);
|
||||
let empty = if max.is_some() { tr("Auto") } else { tr(MIXED) };
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
field_row(ui, tr("Max lines"), |ui, fw| widgets::number_field_or(ui, Id::unique("ty_maxlines"), shown, empty, fw, 1.0))
|
||||
{
|
||||
app.run("text.maxLines", json!({"ids": ids, "lines": v.round().clamp(0.0, 10_000.0)}));
|
||||
}
|
||||
}
|
||||
} else if let (2, Some(face)) = (tab, &vf) {
|
||||
variable_axes(app, ui, ids, styles, face);
|
||||
} else {
|
||||
// Which features the font has (first style shown).
|
||||
let face = uniform(styles, |s| (s.font_family.clone(), s.font_style.clone())).and_then(|(f, s)| figcraft_text::find_face(&f, &s));
|
||||
let face_name = face.as_ref().map(|f| format!("{} {}", f.family, f.style)).unwrap_or_else(|| tr("Mixed fonts").into());
|
||||
widgets::label(ui, &face_name, t.text_secondary);
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| {
|
||||
for (tag, label, default_on) in FEATURES {
|
||||
let state = common(doc, ids, |n| n.kind.text().map(|t| t.features.get(tag).copied())).flatten();
|
||||
let on = state.map(|s| s.unwrap_or(default_on));
|
||||
let supported = face.as_ref().is_none_or(|f| f.has_feature(tag.as_bytes().try_into().unwrap_or(b" ")));
|
||||
if feature_row(ui, tag, tr(label), on, supported) {
|
||||
let want = !on.unwrap_or(false);
|
||||
// Back to the font's default clears the override.
|
||||
let v = if want == default_on { Value::Null } else { json!(want) };
|
||||
app.run("text.setFeatures", json!({"ids": ids, "features": {tag: v}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// How the decoration looks: style, thickness, (underlines) offset and skip ink, and colour.
|
||||
fn decoration_details(app: &mut FigApp, ui: &mut Ui, styles: &[TextStyle], apply_style: &dyn Fn(&mut FigApp, &str, Value), underline: bool, w: f32) {
|
||||
caption(ui, tr("Decoration style"));
|
||||
let active = uniform(styles, |s| s.look().style as usize);
|
||||
let items = [(tr("Solid"), tr_noop("Solid")), (tr("Dotted"), tr_noop("Dotted")), (tr("Wavy"), tr_noop("Wavy"))];
|
||||
if let Some(i) = text_seg(ui, "deco_style", &items, active, w) {
|
||||
apply_style(app, "textDecorationStyle", json!((["solid", "dotted", "wavy"].get(i).copied().unwrap_or("solid"))));
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
let mut lengths = vec![("textDecorationThickness", tr("Thickness"), uniform(styles, |s| s.look().thickness))];
|
||||
if underline {
|
||||
lengths.push(("textDecorationOffset", tr("Offset"), uniform(styles, |s| s.look().offset)));
|
||||
}
|
||||
for (key, label, value) in lengths {
|
||||
field_row(ui, label, |ui, fw| {
|
||||
let (shown, empty) = match value {
|
||||
Some(DecorationLength::Pixels(v)) => (Some(v), String::new()),
|
||||
Some(DecorationLength::Percent(p)) => (None, format!("{}%", trim_num(p))),
|
||||
Some(DecorationLength::Auto) => (None, tr("Auto").to_string()),
|
||||
None => (None, tr(MIXED).to_string()),
|
||||
};
|
||||
let auto = value == Some(DecorationLength::Auto);
|
||||
let field_w = if auto { fw } else { fw - 36.0 };
|
||||
if let Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true)) =
|
||||
widgets::number_field_or(ui, Id::unique(("ty_deco", key)), shown, &empty, field_w, 0.5)
|
||||
{
|
||||
// A thickness of 0 or less goes back to the font's.
|
||||
let v = if key == "textDecorationThickness" && v <= 0.0 { json!("auto") } else { json!(v.clamp(-1000.0, 1000.0)) };
|
||||
apply_style(app, key, v);
|
||||
}
|
||||
let tip = tr("Back to the font's value");
|
||||
if !auto
|
||||
&& ui
|
||||
.add(
|
||||
egui::Label::new(egui::RichText::new(tr("Auto")).font(regular(11.0)).color(Tokens::get(ui.ctx()).accent_text))
|
||||
.sense(Sense::click()),
|
||||
)
|
||||
.on_hover_text(tip)
|
||||
.clicked()
|
||||
{
|
||||
apply_style(app, key, json!("auto"));
|
||||
}
|
||||
});
|
||||
}
|
||||
if underline {
|
||||
let skip = uniform(styles, |s| s.look().skip_ink);
|
||||
if widgets::checkbox(ui, skip == Some(true), tr("Skip ink")) {
|
||||
apply_style(app, "textDecorationSkipInk", json!(skip != Some(true)));
|
||||
}
|
||||
}
|
||||
let color = uniform(styles, |s| s.look().color.map(|c| format!("#{}", c.to_hex())));
|
||||
field_row(ui, tr("Colour"), |ui, fw| {
|
||||
let (value, hint) = match &color {
|
||||
Some(c) => (c.clone().unwrap_or_default(), tr("Auto")),
|
||||
None => (String::new(), tr(MIXED)),
|
||||
};
|
||||
if let Some(s) = widgets::text_field(ui, Id::unique("ty_deco_color"), &value, fw, hint) {
|
||||
let s = s.trim();
|
||||
if s.is_empty() || s.eq_ignore_ascii_case("auto") {
|
||||
apply_style(app, "textDecorationColor", json!("auto"));
|
||||
} else if let Some(c) = figcraft_color::Color::parse_css(s).or_else(|| figcraft_color::Color::from_hex(s)) {
|
||||
apply_style(app, "textDecorationColor", json!(format!("#{}", c.to_hex())));
|
||||
} else {
|
||||
app.toast(trf("{value} isn't a colour", &[("value", &s)]));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
#[cfg(test)]
|
||||
use super::axes::round_axis;
|
||||
#[cfg(test)]
|
||||
use super::picker::{FontPick, font_picker};
|
||||
#[cfg(test)]
|
||||
use super::spacing::{parse_letter_spacing, parse_line_height};
|
||||
use super::*;
|
||||
|
||||
pub(super) fn caption(ui: &mut Ui, s: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new(s).font(regular(10.5)).color(t.text_secondary));
|
||||
}
|
||||
|
||||
/// A segmented control with short text labels (decoration, case, list…); `tip`s are English keys
|
||||
/// (matched below), translated where shown. Returns the clicked index.
|
||||
pub(super) fn text_seg(ui: &mut Ui, salt: &str, items: &[(&str, &str)], active: Option<usize>, width: f32) -> Option<usize> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
|
||||
let cell = width / items.len().max(1) as f32;
|
||||
let mut out = None;
|
||||
for (i, (label, tip)) in items.iter().enumerate() {
|
||||
let r = Rect::from_min_size(pos2(rect.min.x + i as f32 * cell, rect.min.y), vec2(cell, FIELD_H));
|
||||
let resp = ui.interact(r, Id::unique(("ty_seg", salt, i)), Sense::click()).on_hover_text(tr(tip));
|
||||
crate::a11y::toggle(&resp, tr(tip), active == Some(i));
|
||||
if active == Some(i) {
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.panel);
|
||||
ui.painter().rect_stroke(r.shrink(2.0), CornerRadius::same(4), Stroke::new(1.0, t.border), StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r.shrink(2.0), CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let col = if active == Some(i) { t.text } else { t.text_secondary };
|
||||
let galley = if *tip == "Small caps" {
|
||||
small_caps_sample(ui, label, col)
|
||||
} else {
|
||||
ui.painter().layout_no_wrap((*label).to_string(), regular(11.5), col)
|
||||
};
|
||||
let gr = Rect::from_center_size(r.center(), galley.size());
|
||||
let size = galley.size();
|
||||
ui.painter().galley(gr.min, galley, col);
|
||||
// Draw the decoration on its own sample letter.
|
||||
match *tip {
|
||||
"Underline" => {
|
||||
let y = gr.max.y - 1.5;
|
||||
ui.painter().line_segment([pos2(gr.min.x, y), pos2(gr.min.x + size.x, y)], Stroke::new(1.0, col));
|
||||
}
|
||||
"Strikethrough" => {
|
||||
let y = gr.center().y + 0.5;
|
||||
ui.painter().line_segment([pos2(gr.min.x - 1.0, y), pos2(gr.min.x + size.x + 1.0, y)], Stroke::new(1.0, col));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if resp.clicked() {
|
||||
out = Some(i);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The Small caps sample: a capital, then its other letters as smaller capitals. Drawn from
|
||||
/// ordinary capitals because the small-capital letters (`ɢ`, U+0262) are missing from many UI
|
||||
/// fonts, egui's defaults among them, where they showed as a box.
|
||||
fn small_caps_sample(ui: &Ui, label: &str, col: Color32) -> Arc<egui::Galley> {
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let mut chars = label.chars();
|
||||
let first: String = chars.next().into_iter().collect();
|
||||
let rest: String = chars.map(small_capital_to_capital).collect::<String>().to_uppercase();
|
||||
job.append(&first, 0.0, egui::TextFormat { font_id: regular(11.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() });
|
||||
job.append(&rest, 0.0, egui::TextFormat { font_id: regular(8.5), color: col, valign: egui::Align::BOTTOM, ..Default::default() });
|
||||
ui.painter().layout_job(job)
|
||||
}
|
||||
|
||||
/// The capital a small-capital letter stands for (the ones a sample uses), else the letter.
|
||||
fn small_capital_to_capital(c: char) -> char {
|
||||
match c {
|
||||
'ɢ' => 'G',
|
||||
'ʜ' => 'H',
|
||||
'ɪ' => 'I',
|
||||
'ʟ' => 'L',
|
||||
'ɴ' => 'N',
|
||||
'ʀ' => 'R',
|
||||
'ʏ' => 'Y',
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
/// One OpenType feature toggle; `on` None = mixed. Returns true when clicked.
|
||||
pub(super) fn feature_row(ui: &mut Ui, tag: &str, label: &str, on: Option<bool>, supported: bool) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let w = ui.available_width();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, 24.0), Sense::click());
|
||||
let resp = if supported { resp } else { resp.on_hover_text(tr("Not in this font")) };
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(4), t.hover);
|
||||
}
|
||||
let b = Rect::from_min_size(pos2(rect.min.x + 4.0, rect.center().y - 7.0), vec2(14.0, 14.0));
|
||||
match on {
|
||||
Some(true) => {
|
||||
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
|
||||
icons::paint(ui.painter(), b.shrink(1.5), Icon::Check, t.accent_on);
|
||||
}
|
||||
Some(false) => {
|
||||
ui.painter().rect_stroke(b, CornerRadius::same(3), Stroke::new(1.0, t.border_strong), StrokeKind::Inside);
|
||||
}
|
||||
None => {
|
||||
ui.painter().rect_filled(b, CornerRadius::same(3), t.accent);
|
||||
ui.painter().line_segment([pos2(b.min.x + 3.5, b.center().y), pos2(b.max.x - 3.5, b.center().y)], Stroke::new(1.5, t.accent_on));
|
||||
}
|
||||
}
|
||||
let col = if supported { t.text } else { t.text_tertiary };
|
||||
ui.painter().text(pos2(b.max.x + 8.0, rect.center().y), Align2::LEFT_CENTER, label, regular(11.0), col);
|
||||
ui.painter().text(pos2(rect.max.x - 6.0, rect.center().y), Align2::RIGHT_CENTER, tag, regular(10.0), t.text_tertiary);
|
||||
resp.clicked()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) enum LeadGlyph {
|
||||
LineHeight,
|
||||
LetterSpacing,
|
||||
ParagraphSpacing,
|
||||
}
|
||||
|
||||
/// Paint a small original glyph for a typography field's lead.
|
||||
pub(super) fn paint_lead(p: &egui::Painter, r: Rect, g: LeadGlyph, col: Color32) {
|
||||
let c = r.center();
|
||||
let s = Stroke::new(1.0, col);
|
||||
match g {
|
||||
LeadGlyph::LineHeight => {
|
||||
p.line_segment([pos2(c.x - 5.0, c.y - 6.0), pos2(c.x + 5.0, c.y - 6.0)], s);
|
||||
p.line_segment([pos2(c.x - 5.0, c.y + 6.0), pos2(c.x + 5.0, c.y + 6.0)], s);
|
||||
p.text(c, Align2::CENTER_CENTER, "A", regular(9.5), col);
|
||||
}
|
||||
LeadGlyph::LetterSpacing => {
|
||||
p.line_segment([pos2(c.x - 6.0, c.y - 5.0), pos2(c.x - 6.0, c.y + 5.0)], s);
|
||||
p.line_segment([pos2(c.x + 6.0, c.y - 5.0), pos2(c.x + 6.0, c.y + 5.0)], s);
|
||||
p.text(c, Align2::CENTER_CENTER, "A", regular(9.5), col);
|
||||
}
|
||||
LeadGlyph::ParagraphSpacing => {
|
||||
for dy in [-5.0, -2.5, 2.5, 5.0] {
|
||||
p.line_segment([pos2(c.x - 5.0, c.y + dy), pos2(c.x + 5.0, c.y + dy)], s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn small_caps_sample_draws_only_letters_the_ui_font_has() {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx, false, 0);
|
||||
let mut shown = None;
|
||||
let input = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0))), ..Default::default() };
|
||||
let mut out = ctx.run_ui(input, |ui| {
|
||||
shown = Some(small_caps_sample(ui, "Aɢ", Color32::WHITE));
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let g = shown.expect("drawn");
|
||||
assert_eq!(g.job.text, "AG");
|
||||
let [a, rest] = g.job.sections.as_slice() else { panic!("two sections: {:?}", g.job.sections) };
|
||||
assert!(rest.format.font_id.size < a.format.font_id.size, "the other letters are smaller capitals");
|
||||
ctx.fonts_mut(|f| {
|
||||
for sec in &g.job.sections {
|
||||
assert!(f.has_glyphs(&sec.format.font_id, &g.job.text), "UI font draws {:?}", g.job.text);
|
||||
}
|
||||
});
|
||||
assert_eq!(small_capital_to_capital('ʀ'), 'R');
|
||||
assert_eq!(small_capital_to_capital('x'), 'x');
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_line_height_and_letter_spacing() {
|
||||
assert_eq!(parse_line_height("Auto", None), Some(json!("auto")));
|
||||
assert_eq!(parse_line_height("", Some(LineHeight::Pixels(20.0))), Some(json!("auto")));
|
||||
assert_eq!(parse_line_height("150%", Some(LineHeight::Auto)), Some(json!("150%")));
|
||||
assert_eq!(parse_line_height("24", Some(LineHeight::Auto)), Some(json!(24.0)));
|
||||
assert_eq!(parse_line_height("24px", Some(LineHeight::Percent(120.0))), Some(json!(24.0)));
|
||||
assert_eq!(parse_line_height("130", Some(LineHeight::Percent(120.0))), Some(json!("130%")));
|
||||
assert_eq!(parse_line_height("-5", Some(LineHeight::Pixels(10.0))), Some(json!(0.0)));
|
||||
assert_eq!(parse_line_height("abc", None), None);
|
||||
// Enter on an untouched Mixed field commits nothing; typing Auto still does.
|
||||
assert_eq!(parse_line_height("", None), None);
|
||||
assert_eq!(parse_line_height(" ", None), None);
|
||||
assert_eq!(parse_letter_spacing("2%", None), Some(json!("2%")));
|
||||
assert_eq!(parse_letter_spacing("1.5px", None), Some(json!(1.5)));
|
||||
assert_eq!(parse_letter_spacing("-1", None), Some(json!("-1%")));
|
||||
assert_eq!(parse_letter_spacing("3", Some(LetterSpacing::Pixels(1.0))), Some(json!(3.0)));
|
||||
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
|
||||
}
|
||||
|
||||
/// One frame of the font picker with `events`.
|
||||
fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec<egui::Event>) -> FontPick {
|
||||
let mut out = FontPick::default();
|
||||
let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened));
|
||||
shown.textures_delta.clear();
|
||||
out
|
||||
}
|
||||
|
||||
fn key(key: egui::Key) -> egui::Event {
|
||||
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrow_keys_step_through_the_families_and_enter_picks() {
|
||||
// #345
|
||||
figcraft_text::scan_system_fonts();
|
||||
let all = figcraft_text::family_names();
|
||||
if all.len() < 3 {
|
||||
eprintln!("fewer than three font families; skipping");
|
||||
return;
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let current = all[1].as_str();
|
||||
assert!(pick(&ctx, current, true, vec![]).highlighted.is_none());
|
||||
// The first step starts from the current family; each press is one step.
|
||||
let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
|
||||
assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str()));
|
||||
let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]);
|
||||
assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str()));
|
||||
// At the top it stays; Enter picks the family it is on, not the first.
|
||||
assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str()));
|
||||
pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
|
||||
let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]);
|
||||
assert_eq!(enter.picked.as_deref(), Some(all[1].as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn axis_values_round_by_axis_width() {
|
||||
let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false };
|
||||
assert_eq!(round_axis(&axis(100.0, 900.0), 456.6), 457.0);
|
||||
assert_eq!(round_axis(&axis(-10.0, 0.0), -4.26), -4.3);
|
||||
assert_eq!(round_axis(&axis(0.0, 1.0), 0.04), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_reports_mixed() {
|
||||
let a = TextStyle::default();
|
||||
let b = TextStyle { font_size: 30.0, ..Default::default() };
|
||||
assert_eq!(uniform(&[a.clone(), b.clone()], |s| s.font_family.clone()), Some("Inter".to_string()));
|
||||
assert_eq!(uniform(&[a.clone(), b], |s| s.font_size), None);
|
||||
assert_eq!(uniform(&[], |s: &TextStyle| s.font_size), None);
|
||||
// Ranges pick only the spans they touch.
|
||||
let mut doc = Document::new("t");
|
||||
let page = doc.first_page().unwrap();
|
||||
let id = doc.alloc_id();
|
||||
let mut td = TextData::new("Hello world");
|
||||
td.restyle(6, 11, |s| s.font_size = 40.0);
|
||||
doc.insert(Node::new(id, NodeKind::Text(td), "t", 10.0, 10.0), page, None).unwrap();
|
||||
assert_eq!(target_styles(&doc, &[id], Some((id, 0, 5))).len(), 1);
|
||||
assert_eq!(target_styles(&doc, &[id], Some((id, 4, 8))).len(), 2);
|
||||
assert_eq!(target_styles(&doc, &[id], Some((id, 3, 3))).len(), 2);
|
||||
assert_eq!(target_styles(&doc, &[id], None).len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
use super::*;
|
||||
|
||||
// ---- Font picker ----
|
||||
|
||||
const RECENT_KEY: &str = "figcraft_recent_fonts";
|
||||
|
||||
const ROW: f32 = 26.0;
|
||||
|
||||
/// The font list's tallest (it scrolls past that).
|
||||
const LIST_HEIGHT: f32 = 380.0;
|
||||
|
||||
/// Return the persisted list of recently used font families.
|
||||
fn recent_families(ctx: &egui::Context) -> Vec<String> {
|
||||
ctx.data_mut(|d| d.get_persisted::<Vec<String>>(Id::unique(RECENT_KEY))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Remember a font family at the front of the recent list, removing an earlier
|
||||
/// case-insensitive match and keeping at most eight entries.
|
||||
pub(super) fn remember_family(ctx: &egui::Context, family: &str) {
|
||||
ctx.data_mut(|d| {
|
||||
let v = d.get_persisted_mut_or_default::<Vec<String>>(Id::unique(RECENT_KEY));
|
||||
v.retain(|f| !f.eq_ignore_ascii_case(family));
|
||||
v.insert(0, family.to_string());
|
||||
v.truncate(8);
|
||||
});
|
||||
}
|
||||
|
||||
enum PickRow {
|
||||
Header(&'static str),
|
||||
Family(String),
|
||||
}
|
||||
|
||||
/// What the family list did this frame.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct FontPick {
|
||||
/// The family clicked, or the one Enter picked.
|
||||
pub picked: Option<String>,
|
||||
/// The family Up and Down are on (Enter picks it).
|
||||
pub highlighted: Option<String>,
|
||||
/// The family the pointer is over (the caller previews it on the canvas).
|
||||
pub hovered: Option<String>,
|
||||
}
|
||||
|
||||
/// The searchable family list: click a family, or step through it with Up and Down and press Enter.
|
||||
/// Hovering a family previews it on the canvas (closing without picking restores the original).
|
||||
pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let q_id = Id::unique("font_query");
|
||||
// The row Up and Down are on, and the search it is a row of.
|
||||
let hi_id = Id::unique("font_highlight");
|
||||
let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default();
|
||||
if just_opened {
|
||||
q.clear();
|
||||
ui.data_mut(|d| d.remove::<(String, usize)>(hi_id));
|
||||
}
|
||||
// Taken before the search field sees them (it would move its cursor).
|
||||
let step = ui.input_mut(|i| {
|
||||
i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64
|
||||
- i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64
|
||||
});
|
||||
let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5)));
|
||||
crate::widgets::select_all_on_focus(ui, &search, &q);
|
||||
if just_opened {
|
||||
search.request_focus();
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(q_id, q.clone()));
|
||||
let ql = q.trim().to_lowercase();
|
||||
let all = figcraft_text::family_names();
|
||||
let matches: Vec<String> = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
|
||||
let mut rows: Vec<PickRow> = Vec::new();
|
||||
if ql.is_empty() {
|
||||
let recent: Vec<String> = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect();
|
||||
if !recent.is_empty() {
|
||||
rows.push(PickRow::Header(tr("Recent")));
|
||||
rows.extend(recent.into_iter().map(PickRow::Family));
|
||||
rows.push(PickRow::Header(tr("All fonts")));
|
||||
}
|
||||
}
|
||||
if matches.is_empty() {
|
||||
widgets::label(ui, tr("No fonts match"), t.text_tertiary);
|
||||
return FontPick::default();
|
||||
}
|
||||
rows.extend(matches.into_iter().map(PickRow::Family));
|
||||
let family = |i: usize| match rows.get(i) {
|
||||
Some(PickRow::Family(f)) => Some(f.clone()),
|
||||
_ => None,
|
||||
};
|
||||
// A new search starts again; the first step starts from the current family.
|
||||
let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i);
|
||||
let stride = ROW + ui.spacing().item_spacing.y;
|
||||
let mut offset = None;
|
||||
if step != 0 {
|
||||
let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) };
|
||||
let mut from =
|
||||
hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f)))));
|
||||
for _ in 0..step.unsigned_abs() {
|
||||
let mut at = match from {
|
||||
Some(i) => next(i),
|
||||
None if step > 0 => Some(0),
|
||||
None => rows.len().checked_sub(1),
|
||||
};
|
||||
// Past the headers; at either end it stays where it was.
|
||||
while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) {
|
||||
at = next(i);
|
||||
}
|
||||
if let Some(i) = at.filter(|i| family(*i).is_some()) {
|
||||
from = Some(i);
|
||||
hi = Some(i);
|
||||
}
|
||||
}
|
||||
if let Some(i) = hi {
|
||||
// Scrolled just enough to show it (the list is shorter than its tallest where the
|
||||
// window has less room).
|
||||
let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT));
|
||||
let top = i as f32 * stride;
|
||||
offset = Some(seen.max(top + stride - height).min(top));
|
||||
}
|
||||
}
|
||||
match hi {
|
||||
Some(i) => ui.data_mut(|d| {
|
||||
d.insert_temp(hi_id, (q.clone(), i));
|
||||
}),
|
||||
None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)),
|
||||
}
|
||||
if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let first = rows.iter().find_map(|r| match r {
|
||||
PickRow::Family(f) => Some(f.clone()),
|
||||
PickRow::Header(_) => None,
|
||||
});
|
||||
return FontPick { picked: hi.and_then(family).or(first), highlighted: None, hovered: None };
|
||||
}
|
||||
let mut budget = 4;
|
||||
let mut picked = None;
|
||||
let mut hovered_family = None;
|
||||
let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]);
|
||||
if let Some(y) = offset {
|
||||
area = area.vertical_scroll_offset(y);
|
||||
}
|
||||
let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| {
|
||||
for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) {
|
||||
let w = ui.available_width();
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() });
|
||||
match r {
|
||||
PickRow::Header(h) => {
|
||||
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary);
|
||||
}
|
||||
PickRow::Family(f) => {
|
||||
let hovered = resp.hovered() || hi == Some(i);
|
||||
if hovered {
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
|
||||
}
|
||||
let col = if hovered { t.accent_on } else { t.text };
|
||||
if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) {
|
||||
icons::paint(
|
||||
ui.painter(),
|
||||
Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)),
|
||||
Icon::Check,
|
||||
col,
|
||||
);
|
||||
}
|
||||
let text_min = pos2(rect.min.x + 22.0, rect.min.y);
|
||||
match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) {
|
||||
Some(tex) => {
|
||||
crate::font_preview::paint(
|
||||
ui.painter(),
|
||||
&tex,
|
||||
pos2(text_min.x, rect.center().y),
|
||||
rect.max.x - text_min.x - 6.0,
|
||||
ROW,
|
||||
col,
|
||||
);
|
||||
}
|
||||
None => {
|
||||
ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col);
|
||||
}
|
||||
}
|
||||
if resp.clicked() {
|
||||
picked = Some(f.clone());
|
||||
}
|
||||
if resp.hovered() {
|
||||
hovered_family = Some(f.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.clamped_offset().y, shown.inner_rect.height())));
|
||||
FontPick { highlighted: hi.and_then(family), picked, hovered: hovered_family }
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use super::glyphs::{LeadGlyph, paint_lead};
|
||||
use super::*;
|
||||
|
||||
/// Line height, letter spacing or paragraph spacing: property, field id, glyph and tooltip.
|
||||
pub(super) struct SpacingField {
|
||||
pub(super) field: &'static str,
|
||||
pub(super) id: Id,
|
||||
pub(super) glyph: LeadGlyph,
|
||||
pub(super) tip: &'static str,
|
||||
}
|
||||
|
||||
pub(super) enum SpacingEdit {
|
||||
Typed(String),
|
||||
Scrub(f64, bool),
|
||||
}
|
||||
|
||||
impl SpacingEdit {
|
||||
pub(super) fn text(&self) -> String {
|
||||
match self {
|
||||
Self::Typed(text) => text.clone(),
|
||||
Self::Scrub(value, _) => trim_num(*value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep typed unit parsing and character-range targeting; only a scrub outside a text-editing
|
||||
/// session needs its own interaction. The editing session already owns its undo step.
|
||||
pub(super) fn commit_spacing(
|
||||
app: &mut FigApp,
|
||||
ids: &[NodeId],
|
||||
range: Option<(NodeId, usize, usize)>,
|
||||
field: &SpacingField,
|
||||
edit: &SpacingEdit,
|
||||
value: Value,
|
||||
) {
|
||||
let props = json!({field.field: value});
|
||||
match edit {
|
||||
SpacingEdit::Scrub(_, done) if figcraft_engine::textedit::selected_range(&app.session).is_none() => {
|
||||
live(app, field.tip, "node.set", json!({"ids": ids, "props": props}), *done);
|
||||
}
|
||||
_ => commit_text(app, ids, range, props),
|
||||
}
|
||||
}
|
||||
|
||||
/// The original text editor plus the same leading-icon scrub gesture as ordinary number fields.
|
||||
/// Auto and Mixed have no numeric starting value and remain unchanged by dragging.
|
||||
pub(super) fn spacing_input(ui: &mut Ui, field: &SpacingField, value: &str, width: f32) -> Option<SpacingEdit> {
|
||||
let rect = Rect::from_min_size(ui.cursor().min, vec2(24.0, FIELD_H));
|
||||
let typed = lead_field(ui, field.id, field.glyph, value, width, field.tip);
|
||||
if ui.data(|d| d.get_temp::<String>(field.id.with("editing")).is_some()) {
|
||||
return typed.map(SpacingEdit::Typed);
|
||||
}
|
||||
let response = ui.interact(rect, field.id.with("lead"), Sense::drag());
|
||||
if response.hovered() || response.dragged() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||||
}
|
||||
let numeric = value.trim_end_matches('%').parse::<f64>().ok();
|
||||
match widgets::scrub(ui, field.id, &response, numeric, 1.0) {
|
||||
Some(FieldEdit::Scrub(v, done)) => Some(SpacingEdit::Scrub(v, done)),
|
||||
_ => typed.map(SpacingEdit::Typed),
|
||||
}
|
||||
}
|
||||
|
||||
/// A spacing field that whole layers can bind to a number variable (in pixels): the variable's
|
||||
/// chip when bound, else the editable field with a variable button on hover. While a text range is
|
||||
/// selected it only takes values. Returns a typed commit or a live scrub.
|
||||
pub(super) fn spacing_field(
|
||||
app: &mut FigApp,
|
||||
ui: &mut Ui,
|
||||
ids: &[NodeId],
|
||||
f: &SpacingField,
|
||||
value: &str,
|
||||
width: f32,
|
||||
range: bool,
|
||||
) -> Option<SpacingEdit> {
|
||||
let rect = Rect::from_min_size(ui.cursor().min, vec2(width, FIELD_H));
|
||||
if !range && crate::variable_ui::bound_chip(app, ui, ids, f.field, VariableType::Number, rect) {
|
||||
ui.allocate_rect(rect, Sense::hover());
|
||||
return None;
|
||||
}
|
||||
let out = spacing_input(ui, f, value, width);
|
||||
if !range && ui.data(|d| d.get_temp::<String>(f.id.with("editing")).is_none()) {
|
||||
crate::variable_ui::bind_button(ui, rect, ids, f.field, VariableType::Number);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn lead_field(ui: &mut Ui, id: Id, glyph: LeadGlyph, value: &str, width: f32, tip: &str) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover());
|
||||
let lead = Rect::from_min_size(rect.min, vec2(24.0, FIELD_H));
|
||||
let text_rect = Rect::from_min_max(pos2(lead.max.x, rect.min.y), rect.max);
|
||||
let editing_id = id.with("editing");
|
||||
let mut editing: Option<String> = ui.data(|d| d.get_temp(editing_id));
|
||||
let resp = ui.interact(rect, id.with("click"), Sense::click()).on_hover_text(tip);
|
||||
crate::a11y::text(&resp, tip, value);
|
||||
ui.painter().rect_filled(rect, CornerRadius::same(5), t.field);
|
||||
if editing.is_some() {
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, crate::theme::ACCENT), StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_stroke(rect, CornerRadius::same(5), Stroke::new(1.0, t.field_hover_border), StrokeKind::Inside);
|
||||
}
|
||||
paint_lead(ui.painter(), lead, glyph, t.icon_secondary);
|
||||
let started = editing.is_none() && resp.clicked();
|
||||
if started {
|
||||
editing = Some(if value == tr(MIXED) { String::new() } else { value.to_string() });
|
||||
ui.memory_mut(|m| m.request_focus(id.with("te")));
|
||||
}
|
||||
let mut out = None;
|
||||
match editing {
|
||||
Some(mut text) => {
|
||||
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(text_rect.shrink2(vec2(2.0, 4.0))));
|
||||
let r = child.add(
|
||||
egui::TextEdit::singleline(&mut text)
|
||||
.id(id.with("te"))
|
||||
.frame(egui::Frame::NONE)
|
||||
.font(regular(11.0))
|
||||
.desired_width(text_rect.width() - 6.0),
|
||||
);
|
||||
crate::a11y::own_focus(&r);
|
||||
crate::widgets::select_all_on_focus(ui, &r, &text);
|
||||
// The Enter that started the edit (from the keyboard) doesn't end it.
|
||||
let enter = !started && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||
let esc = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||
if esc {
|
||||
ui.data_mut(|d| d.remove::<String>(editing_id));
|
||||
} else if r.lost_focus() || enter {
|
||||
out = Some(text);
|
||||
ui.data_mut(|d| d.remove::<String>(editing_id));
|
||||
} else {
|
||||
ui.data_mut(|d| d.insert_temp(editing_id, text));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let col = if value == tr(MIXED) { t.text_tertiary } else { t.text };
|
||||
ui.painter().text(pos2(text_rect.min.x + 2.0, rect.center().y), Align2::LEFT_CENTER, value, regular(11.0), col);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// "Auto", "24", "150%" → the `lineHeight` value. A bare number keeps the current unit when it is
|
||||
/// a percentage, else means pixels. Empty text means Auto, except on a Mixed field (`cur` is
|
||||
/// `None`), where nothing typed is an untouched field and commits nothing.
|
||||
pub(crate) fn parse_line_height(s: &str, cur: Option<LineHeight>) -> Option<Value> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() && cur.is_none() {
|
||||
return None;
|
||||
}
|
||||
if s.is_empty() || s.eq_ignore_ascii_case("auto") {
|
||||
return Some(json!("auto"));
|
||||
}
|
||||
if let Some(p) = s.strip_suffix('%') {
|
||||
let cur_pct = match cur {
|
||||
Some(LineHeight::Percent(p)) => Some(p),
|
||||
_ => None,
|
||||
};
|
||||
return widgets::eval(p.trim(), cur_pct).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0)))));
|
||||
}
|
||||
if let Some(px) = s.strip_suffix("px") {
|
||||
return widgets::eval(px.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0)));
|
||||
}
|
||||
match cur {
|
||||
Some(LineHeight::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v.max(0.0))))),
|
||||
Some(LineHeight::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0))),
|
||||
_ => widgets::eval(s, None).filter(|v| v.is_finite()).map(|v| json!(v.max(0.0))),
|
||||
}
|
||||
}
|
||||
|
||||
/// "2%", "1.5px", "-0.5" → the `letterSpacing` value. A bare number keeps the current unit
|
||||
/// (percent by default).
|
||||
pub(crate) fn parse_letter_spacing(s: &str, cur: Option<LetterSpacing>) -> Option<Value> {
|
||||
let s = s.trim();
|
||||
if let Some(p) = s.strip_suffix('%') {
|
||||
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v))));
|
||||
}
|
||||
if let Some(p) = s.strip_suffix("px") {
|
||||
return widgets::eval(p.trim(), None).filter(|v| v.is_finite()).map(|v| json!(v));
|
||||
}
|
||||
match cur {
|
||||
Some(LetterSpacing::Pixels(px)) => widgets::eval(s, Some(px)).filter(|v| v.is_finite()).map(|v| json!(v)),
|
||||
Some(LetterSpacing::Percent(p)) => widgets::eval(s, Some(p)).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))),
|
||||
None => widgets::eval(s, None).filter(|v| v.is_finite()).map(|v| json!(format!("{}%", trim_num(v)))),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`parse_line_height`] value as a [`LineHeight`]: "auto", "150%" or a number of pixels.
|
||||
pub(crate) fn line_height_of(v: &Value) -> Option<LineHeight> {
|
||||
match v {
|
||||
Value::String(s) if s.eq_ignore_ascii_case("auto") => Some(LineHeight::Auto),
|
||||
Value::String(s) => s.strip_suffix('%')?.trim().parse().ok().filter(|p: &f64| p.is_finite()).map(LineHeight::Percent),
|
||||
other => other.as_f64().filter(|v| v.is_finite()).map(LineHeight::Pixels),
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`parse_letter_spacing`] value as a [`LetterSpacing`]: "2%" or a number of pixels.
|
||||
pub(crate) fn letter_spacing_of(v: &Value) -> Option<LetterSpacing> {
|
||||
match v {
|
||||
Value::String(s) => s.strip_suffix('%')?.trim().parse().ok().filter(|p: &f64| p.is_finite()).map(LetterSpacing::Percent),
|
||||
other => other.as_f64().filter(|v| v.is_finite()).map(LetterSpacing::Pixels),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user