Files
designcraft/crates/ui-egui/src/toolbar.rs
2026-10-16 04:48:56 +00:00

1081 lines
57 KiB
Rust
Raw Permalink 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.
//! The Tools panel (left): single or double column, tool groups with flyouts, fill/stroke proxy,
//! screen mode buttons.
use designcraft_tools::{SPLIT_IN_TWO_COLUMNS, TOOL_GROUPS};
use egui::{Color32, Sense, Stroke, StrokeKind, vec2};
use serde_json::{Value, json};
use crate::theme::Tokens;
use crate::{DesignApp, icons};
const BTN: f32 = 24.0;
pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let frame = egui::Frame::NONE.fill(t.panel).stroke(Stroke::new(1.0, t.divider));
// A single column that doesn't fit the window's height becomes two, without changing the
// preference; whatever two columns still can't fit scrolls.
let single_h_id = egui::Id::new("tools_single_column_height");
let single_h: Option<f32> = ui.data(|d| d.get_temp(single_h_id));
let viewport_h = ui.available_height() - frame.total_margin().sum().y;
let crowded = !app.ui.tools_double_column && single_h.is_some_and(|h| h > viewport_h);
let cols = if app.ui.tools_double_column || crowded { 2 } else { 1 };
let width = 8.0 + BTN * cols as f32 + 4.0 * (cols as f32 - 1.0) + 8.0;
let r = egui::Panel::left("tools").exact_size(width).resizable(false).frame(frame).show(ui, |ui| {
crate::widgets::overflow_scrolling(ui);
let scroll = egui::ScrollArea::vertical().id_salt("tools_scroll").auto_shrink([false, false]).show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
ui.add_space(3.0);
// Collapse chevrons (nothing to toggle while the window forces two columns).
ui.horizontal(|ui| {
ui.add_space(width / 2.0 - 8.0);
let toggle = ui.add_enabled_ui(!crowded, |ui| {
icons::button(
ui,
if cols == 1 { "double-chevron-right" } else { "double-chevron-left" },
16.0,
false,
crate::i18n::tr(&app.ui.language, "Toggle double column"),
)
});
if toggle.inner.clicked() {
app.ui.tools_double_column = !app.ui.tools_double_column;
}
});
ui.add_space(2.0);
let current = app.session.tool_id();
let mut chosen: Option<&'static str> = None;
let mut opened_now = false;
// A slot is a group (its current tool shown, the others in its flyout) or, in two
// columns, one tool of a group InDesign lays out tool by tool.
let mut row_items: Vec<Option<(usize, Option<usize>)>> = Vec::new();
for (gi, g) in TOOL_GROUPS.iter().enumerate() {
if g.is_empty() {
row_items.push(None);
} else if cols == 2 && SPLIT_IN_TWO_COLUMNS.contains(&g[0].id) {
row_items.extend((0..g.len()).map(|k| Some((gi, Some(k)))));
} else {
row_items.push(Some((gi, None)));
}
}
let mut i = 0;
while i < row_items.len() {
match row_items[i] {
None => {
let r = ui.available_rect_before_wrap();
ui.painter().line_segment(
[egui::pos2(r.min.x + 6.0, r.min.y + 2.0), egui::pos2(r.max.x - 6.0, r.min.y + 2.0)],
Stroke::new(1.0, t.divider),
);
ui.add_space(5.0);
i += 1;
}
Some(_) => {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.spacing_mut().item_spacing.x = 4.0;
for _ in 0..cols {
let Some(Some((gi, pinned))) = row_items.get(i).copied() else { break };
let group = TOOL_GROUPS[gi];
let g = match pinned {
Some(k) => &group[k..=k],
None => group,
};
let shown = g.iter().find(|tl| tl.id == current).unwrap_or(&g[0]);
let active = g.iter().any(|tl| tl.id == current);
let name = crate::i18n::tr(&app.ui.language, shown.label);
let tip = match shown.shortcut {
Some(s) => format!("{name} ({s})"),
None => name.to_string(),
};
let resp = tool_button(ui, shown.icon, active, &tip);
if g.len() > 1 {
// Flyout triangle.
let r = resp.rect;
let p = r.right_bottom() - vec2(3.0, 3.0);
ui.painter().add(egui::Shape::convex_polygon(
vec![p, p - vec2(4.0, 0.0), p - vec2(0.0, 4.0)],
t.icon,
Stroke::NONE,
));
}
// The release that ends a hold which opened the flyout isn't a click
// on the tool (egui reports releases within 0.8 s as clicks).
let hold_id = egui::Id::new(("flyout_hold", gi));
let ended_hold = resp.clicked() && ui.data_mut(|d| d.remove_temp::<bool>(hold_id)).unwrap_or(false);
if resp.clicked() && !ended_hold {
chosen = Some(shown.id);
}
if !resp.is_pointer_button_down_on() {
ui.data_mut(|d| d.remove::<bool>(hold_id));
}
// Double-click a polygon tool: Polygon Settings.
if resp.double_clicked() && matches!(shown.id, "polygon" | "polygonFrame") {
let cur = app.session.execute("tool.polygonSettings", &json!({})).unwrap_or_default();
app.ui.dialog = Some(crate::dialogs::Dialog::new("polygonSettings", cur));
}
// Double-click a transform tool: its Object › Transform dialog.
if resp.double_clicked()
&& let Some(cmd) = transform_dialog_of(shown.id)
{
crate::menus::activate(app, cmd, &Value::Null);
}
let held =
resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.press_start_time().is_some_and(|s| i.time - s > 0.35));
if g.len() > 1 && held {
ui.data_mut(|d| d.insert_temp(hold_id, true));
}
if g.len() > 1 && (resp.secondary_clicked() || resp.long_touched() || held) {
opened_now |= app.ui.flyout != Some(gi);
app.ui.flyout = Some(gi);
}
if app.ui.flyout == Some(gi) {
let pos = resp.rect.right_top() + vec2(4.0, 0.0);
egui::Area::new(egui::Id::new(("flyout", gi))).order(egui::Order::Foreground).fixed_pos(pos).show(
ui.ctx(),
|ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
for tl in g {
ui.horizontal(|ui| {
let (r, row) = ui.allocate_exact_size(vec2(210.0, 24.0), Sense::click());
if row.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
icons::paint(
ui.painter(),
egui::Rect::from_min_size(r.min + vec2(4.0, 3.0), vec2(18.0, 18.0)),
tl.icon,
t.icon,
);
if tl.id == current {
ui.painter().circle_filled(r.min + vec2(-2.0, 12.0), 2.0, t.accent);
}
ui.painter().text(
r.min + vec2(28.0, 12.0),
egui::Align2::LEFT_CENTER,
crate::i18n::tr(&app.ui.language, tl.label),
egui::FontId::proportional(12.5),
t.text,
);
if let Some(sc) = tl.shortcut {
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
sc,
egui::FontId::proportional(11.5),
t.text_dim,
);
}
row.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl.label));
if row.clicked() {
chosen = Some(tl.id);
}
});
}
});
},
);
}
i += 1;
if matches!(row_items.get(i), Some(None) | None) {
break;
}
}
});
}
}
}
if let Some(id) = chosen {
app.select_tool(id);
}
// As InDesign's, an open flyout closes on Esc and on a click anywhere outside it (a
// panel, a swatch, the canvas), not only on the canvas. A new press counts, not a
// click: the release that ends the hold which opened it is one.
if let Some(gi) = app.ui.flyout
&& chosen.is_none()
&& !opened_now
{
let area = ui.ctx().memory(|m| m.area_rect(egui::Id::new(("flyout", gi))));
let outside = ui.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| area.is_none_or(|a| !a.contains(p))));
let esc = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape));
if outside || esc {
app.ui.flyout = None;
}
}
ui.add_space(8.0);
fill_stroke_proxy(app, ui, width, cols);
});
if cols == 1 {
let h = scroll.content_size.y;
ui.data_mut(|d| d.insert_temp(single_h_id, h));
// Laid out in one column that turned out too tall: lay out again in two.
if h > viewport_h {
ui.ctx().request_discard("tools panel: one column does not fit");
}
}
});
if std::env::var_os("DESIGNCRAFT_DEBUG_LAYOUT").is_some() {
eprintln!("tools panel rect {:?} (wanted width {width}); remaining {:?}", r.response.rect, ui.available_rect_before_wrap());
}
}
/// The area under the tools (InDesign 2026 §3 group 5): mini Default Fill/Stroke and Swap, the
/// overlapping Fill/Stroke proxy, Formatting affects container / text, the Apply None well, a
/// utility button and the Screen Mode well.
fn fill_stroke_proxy(app: &mut DesignApp, ui: &mut egui::Ui, width: f32, cols: usize) {
let t = Tokens::get(ui.ctx());
let target = crate::panels::color_target(app);
let doc = app.session.active().map(|d| d.doc.clone());
let stroke_front = app.ui.stroke_front;
let text_mode = crate::panels::colors_affect_text(app) || app.ui.formatting_affects_text;
let (fill_sw, stroke_sw) = match &target {
Some((fill, _, stroke, _)) => (fill.clone(), stroke.clone()),
// Nothing selected: the colours new shapes are drawn with.
None => doc
.as_ref()
.map_or(("[None]".to_string(), "[Black]".to_string()), |d| (d.settings.new_object.fill.clone(), d.settings.new_object.stroke.clone())),
};
let x0 = ui.max_rect().min.x + (width - 30.0) / 2.0;
// Row 1: default colours + swap.
let (row, _) = ui.allocate_exact_size(vec2(width, 11.0), Sense::hover());
let def = egui::Rect::from_min_size(egui::pos2(x0, row.min.y), vec2(10.0, 10.0));
icons::paint(ui.painter(), def, "default-colors", t.icon);
if ui
.interact(def, ui.id().with("proxy_default"), Sense::click())
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Default Fill and Stroke (D)"));
})
.clicked()
{
let _ = app.run("color.defaultFillStroke", json!({}));
}
let swap = egui::Rect::from_min_size(egui::pos2(x0 + 19.0, row.min.y), vec2(11.0, 11.0));
icons::paint(ui.painter(), swap, "swap", t.icon);
if ui
.interact(swap, ui.id().with("proxy_swap_btn"), Sense::click())
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Swap Fill and Stroke (Shift+X)"));
})
.clicked()
{
let _ = app.run("color.swapFillStroke", json!({}));
}
ui.add_space(2.0);
// Proxy: 19.5 pt squares, fill top-left, stroke bottom-right.
let (r, _) = ui.allocate_exact_size(vec2(width, 31.0), Sense::hover());
let fill_r = egui::Rect::from_min_size(egui::pos2(x0, r.min.y), vec2(19.5, 19.5));
let stroke_r = egui::Rect::from_min_size(egui::pos2(x0 + 10.0, r.min.y + 10.0), vec2(19.5, 19.5));
let (fc, fg) = match &doc {
Some(d) => crate::widgets::swatch_colors(d, &fill_sw, 1.0),
None => (None, None),
};
let sc = doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, &stroke_sw, 1.0).0);
let p = ui.painter();
let paint_stroke = |p: &egui::Painter| {
match sc {
Some(c) => {
p.rect_filled(stroke_r, 0.0, c);
p.rect_filled(stroke_r.shrink(5.5), 0.0, t.panel);
p.rect_stroke(stroke_r.shrink(5.5), 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
}
None => {
crate::widgets::paint_chip(p, stroke_r, None, None);
p.rect_filled(stroke_r.shrink(5.5), 0.0, t.panel);
}
}
p.rect_stroke(stroke_r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
};
let paint_fill = |p: &egui::Painter| {
crate::widgets::paint_chip(p, fill_r, fc, fg);
p.rect_stroke(fill_r, 0.0, Stroke::new(1.0, t.border), StrokeKind::Outside);
};
if stroke_front {
paint_fill(p);
paint_stroke(p);
} else {
paint_stroke(p);
paint_fill(p);
}
let fill_tip = crate::i18n::tr(&app.ui.language, "Fill (X)");
let fresp = ui.interact(fill_r, ui.id().with("proxy_fill"), Sense::click()).on_hover_ui(|ui| {
crate::rtl::label(ui, fill_tip);
});
fresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, fill_tip));
let stroke_tip = crate::i18n::tr(&app.ui.language, "Stroke (X)");
let sresp = ui.interact(stroke_r.translate(vec2(0.0, 0.0)), ui.id().with("proxy_stroke"), Sense::click()).on_hover_ui(|ui| {
crate::rtl::label(ui, stroke_tip);
});
sresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, stroke_tip));
if fresp.clicked() || sresp.clicked() {
let front =
if stroke_front { !fresp.clicked() } else { sresp.clicked() && !fill_r.contains(sresp.interact_pointer_pos().unwrap_or_default()) };
app.ui.stroke_front = front;
}
// A double-click opens the Color Picker for that box (as InDesign's), with the colour of what
// colour edits apply to (see `colors_affect_text`).
if fresp.double_clicked() || sresp.double_clicked() {
let on_fill = fresp.double_clicked() && (!stroke_front || !sresp.double_clicked());
let stroke = !on_fill;
let current = if stroke { &stroke_sw } else { &fill_sw };
let mut fields = crate::panels::swatches::picker_fields(doc.as_ref().and_then(|d| d.resolve_color(current, 1.0)), stroke);
fields["text"] = json!(crate::panels::colors_affect_text(app));
app.ui.dialog = Some(crate::dialogs::Dialog::new("colorPicker", fields));
}
ui.add_space(3.0);
// Formatting affects container / text.
let (row, _) = ui.allocate_exact_size(vec2(width, 13.0), Sense::hover());
for (k, (icon, tip)) in
[("format-container", "Formatting affects container (J)"), ("format-text", "Formatting affects text (J)")].into_iter().enumerate()
{
let r = egui::Rect::from_min_size(egui::pos2(x0 - 1.0 + k as f32 * 18.0, row.min.y), vec2(13.0, 13.0));
let on = text_mode == (k == 1);
if on {
ui.painter().rect(r, 1.5, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Inside);
}
icons::paint(ui.painter(), r.shrink(1.5), icon, if on { t.text_strong } else { t.icon });
if ui
.interact(r, ui.id().with(("proxy_fmt", k)), Sense::click())
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
})
.clicked()
{
let _ = app.run("app.formattingAffectsText", json!({"on": k == 1}));
}
}
ui.add_space(5.0);
// Apply Color / Apply Gradient / Apply None: three buttons in two columns, as InDesign lays
// them out; one well with the kind in front (and a flyout of the three) in one column.
let front_sw = if stroke_front { &stroke_sw } else { &fill_sw };
let front_kind = match doc.as_ref() {
Some(_) if front_sw == designcraft_color::swatch::NONE => 2,
Some(d) if designcraft_color::swatch::resolve_gradient(&d.swatches, front_sw).is_some() => 1,
_ => 0,
};
let applies: [(&str, &str); 3] =
[("Apply Color (,)", "color.applyColor"), ("Apply Gradient (.)", "color.applyGradient"), ("Apply None (/)", "color.applyNone")];
// Apply Color shows the colour it applies.
let last_color = app.ui.last_color.as_deref().and_then(|sw| doc.as_ref().and_then(|d| crate::widgets::swatch_colors(d, sw, 1.0).0));
let paint_kind = |p: &egui::Painter, r: egui::Rect, k: usize| {
let chip = egui::Rect::from_center_size(r.center(), vec2(13.0, 13.0));
match k {
0 => {
p.rect_filled(chip, 0.0, last_color.unwrap_or(Color32::BLACK));
}
1 => crate::widgets::paint_chip(p, chip, None, Some((Color32::WHITE, Color32::BLACK))),
_ => crate::widgets::paint_chip(p, chip, None, None),
}
p.rect_stroke(chip, 0.0, Stroke::new(0.5, t.border), StrokeKind::Outside);
};
let (row, _) = ui.allocate_exact_size(vec2(width, 21.0), Sense::hover());
if cols == 2 {
let pitch = 19.0;
for (k, (tip, id)) in applies.into_iter().enumerate() {
let c = egui::pos2(row.min.x + width / 2.0 + (k as f32 - 1.0) * pitch, row.center().y);
let r = egui::Rect::from_center_size(c, vec2(18.0, 18.0));
let resp = ui.interact(r, ui.id().with(("proxy_apply", k)), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip)));
if k == front_kind {
ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside);
}
paint_kind(ui.painter(), r, k);
if resp
.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
})
.clicked()
{
let _ = app.run(id, json!({}));
}
}
} else {
let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0));
let (tip, id) = applies[front_kind];
let resp = ui.interact(well, ui.id().with("proxy_apply"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, crate::i18n::tr(&app.ui.language, tip)));
let resp = resp.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, tip));
});
ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside);
paint_kind(ui.painter(), well, front_kind);
flyout_triangle(ui.painter(), well, t.icon);
if resp.clicked() {
let _ = app.run(id, json!({}));
}
egui::Popup::context_menu(&resp).show(|ui| {
for (tip, id) in applies {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, tip))).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
});
}
ui.add_space(6.0);
separator(ui, &t);
// View Options (InDesign's: Frame Edges, Rulers, Guides, Smart Guides, Baseline Grid, Hidden
// Characters, each checked when shown).
ui.vertical_centered(|ui| {
let resp = tool_button(ui, "view-options", false, crate::i18n::tr(&app.ui.language, "View Options"));
flyout_triangle(ui.painter(), resp.rect.shrink2(vec2(2.0, 2.0)), t.icon);
let open = resp.clicked();
let popup = egui::Popup::menu(&resp).open_memory(open.then_some(egui::SetOpenCommand::Toggle));
popup.show(|ui| {
for (k, id) in
["view.frameEdges", "view.rulers", "-", "view.guides", "view.smartGuides", "view.baselineGrid", "-", "view.hiddenCharacters"]
.into_iter()
.enumerate()
{
if id == "-" {
ui.separator();
continue;
}
let on = crate::menus::checked(app, id, &Value::Null).unwrap_or(false);
let label = VIEW_OPTIONS[k];
let sc = crate::menus::ui_label(id).and_then(|c| c.1).unwrap_or("");
let text = format!("{} {}", if on { "✓" } else { " " }, crate::i18n::tr(&app.ui.language, label));
if ui.add(egui::Button::new(crate::rtl::widget(ui, &text)).shortcut_text(sc)).clicked() {
let _ = app.run(id, json!({}));
ui.close();
}
}
});
});
separator(ui, &t);
// Screen Mode: Normal and Preview (with Bleed, Slug and Presentation) side by side in two
// columns, as InDesign's; one well with the five in its flyout in one column.
let (row, _) = ui.allocate_exact_size(vec2(width, 22.0), Sense::hover());
let mode = app.ui.screen_mode;
let modes = [("Preview", "preview"), ("Bleed", "bleed"), ("Slug", "slug"), ("Presentation", "presentation")];
if cols == 2 {
let normal = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 - 15.0, row.center().y), vec2(28.0, 21.0));
let other = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0 + 15.0, row.center().y), vec2(28.0, 21.0));
let is_normal = mode == crate::ScreenMode::Normal;
for (r, on, icon) in [(normal, is_normal, "screen-normal"), (other, !is_normal, "screen-preview")] {
if on {
ui.painter().rect(r, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside);
}
icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(15.0, 15.0)), icon, if on { t.text_strong } else { t.icon });
}
flyout_triangle(ui.painter(), other, t.icon);
let tr = |s: &str| crate::i18n::tr(&app.ui.language, s).to_string();
let (normal_tip, other_tip) = (tr("Normal"), format!("{} (W)", tr("Preview")));
let resp = ui.interact(normal, ui.id().with("screen_normal"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &normal_tip));
if resp.on_hover_text(&normal_tip).clicked() {
let _ = app.run("view.screenMode", json!({"mode": "normal"}));
}
let resp = ui.interact(other, ui.id().with("screen_mode"), Sense::click());
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &other_tip));
let resp = resp.on_hover_text(&other_tip);
if resp.clicked() {
let _ = app.run("view.screenMode", json!({"mode": "preview"}));
}
egui::Popup::context_menu(&resp).show(|ui| {
for (label, m) in modes {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
let _ = app.run("view.screenMode", json!({"mode": m}));
ui.close();
}
}
});
return;
}
let well = egui::Rect::from_center_size(egui::pos2(row.min.x + width / 2.0, row.center().y), vec2(28.0, 21.0));
let resp = ui.interact(well, ui.id().with("screen_mode"), Sense::click());
ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside);
let preview = mode == crate::ScreenMode::Preview;
icons::paint(
ui.painter(),
egui::Rect::from_center_size(well.center(), vec2(15.0, 15.0)),
if preview { "screen-preview" } else { "screen-normal" },
t.text_strong,
);
flyout_triangle(ui.painter(), well, t.icon);
let resp = resp.on_hover_ui(|ui| {
crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, if preview { "Preview (W)" } else { "Normal (W)" }));
});
if resp.clicked() {
let _ = app.run("view.togglePreview", json!({}));
}
egui::Popup::context_menu(&resp).show(|ui| {
for (label, mode) in std::iter::once(("Normal", "normal")).chain(modes) {
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() {
let _ = app.run("view.screenMode", json!({"mode": mode}));
ui.close();
}
}
});
}
/// The View Options flyout's labels, by position (separators keep theirs empty).
const VIEW_OPTIONS: [&str; 8] = ["Frame Edges", "Rulers", "", "Guides", "Smart Guides", "Baseline Grid", "", "Hidden Characters"];
/// The Object › Transform command a double-clicked tool opens (the Selection tool opens Move).
fn transform_dialog_of(tool: &str) -> Option<&'static str> {
match tool {
"selection" => Some("transform.move"),
"rotate" => Some("transform.rotate"),
"scale" => Some("transform.scale"),
"shear" => Some("transform.shear"),
_ => None,
}
}
fn flyout_triangle(p: &egui::Painter, r: egui::Rect, c: Color32) {
let q = r.right_bottom() - vec2(2.0, 2.0);
p.add(egui::Shape::convex_polygon(vec![q, q - vec2(4.0, 0.0), q - vec2(0.0, 4.0)], c, Stroke::NONE));
}
fn separator(ui: &mut egui::Ui, t: &Tokens) {
ui.add_space(4.0);
let r = ui.available_rect_before_wrap();
ui.painter().line_segment(
[egui::pos2(r.min.x + 6.0, r.min.y), egui::pos2(r.max.x - 6.0, r.min.y)],
Stroke::new(1.0, Color32::from_rgb(0x4c, 0x4b, 0x4b)),
);
let _ = t;
ui.add_space(5.0);
}
/// A Tools-panel button: 24 pt pitch; the active tool sits in a 28×20 pt `#303030` well with a rim.
fn tool_button(ui: &mut egui::Ui, icon: &str, active: bool, tip: &str) -> egui::Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(egui::vec2(BTN, BTN), Sense::click());
if active {
let well = egui::Rect::from_center_size(r.center(), egui::vec2(28.0, 20.0));
ui.painter().rect(well, 2.0, t.well, Stroke::new(1.0, t.well_rim), StrokeKind::Outside);
} else if resp.hovered() {
ui.painter().rect_filled(egui::Rect::from_center_size(r.center(), egui::vec2(28.0, 20.0)), 2.0, t.hover);
}
icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), egui::vec2(17.0, 17.0)), icon, if active { t.text_strong } else { t.icon });
let enabled = ui.is_enabled();
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, tip));
resp.on_hover_ui(|ui| {
crate::rtl::label(ui, tip);
})
}
/// Default Fill and Stroke (D): text gets a black fill and no stroke, objects no fill and a black
/// stroke. Colour keys act on what the proxy shows (see `panels::colors_affect_text`).
pub(crate) fn default_fill_stroke(app: &mut DesignApp) -> Result<Value, String> {
if crate::panels::colors_affect_text(app) {
let attrs = json!({"fill": "[Black]", "fillTint": 1.0, "stroke": "[None]", "strokeTint": 1.0});
return app.run("type.char", json!({ "attrs": attrs }));
}
app.run("object.defaultFillStroke", json!({}))
}
/// Swap Fill and Stroke (Shift+X): the text's fill and stroke, or each selected object's, trade
/// places, tints included.
pub(crate) fn swap_fill_stroke(app: &mut DesignApp) -> Result<Value, String> {
if crate::panels::colors_affect_text(app) { app.run("type.swapFillStroke", json!({})) } else { app.run("object.swapFillStroke", json!({})) }
}
/// Fill/Stroke toggle (X): brings the other of fill and stroke to the front of the proxy.
pub(crate) fn toggle_fill_stroke(app: &mut DesignApp) -> Result<Value, String> {
app.ui.stroke_front = !app.ui.stroke_front;
Ok(json!({"strokeFront": app.ui.stroke_front}))
}
/// Apply Color (,): the colour applied last ([Black] at first) to the fill or stroke in front.
pub(crate) fn apply_color(app: &mut DesignApp) -> Result<Value, String> {
let sw = app.ui.last_color.clone().filter(|s| app.session.active().is_some_and(|d| d.doc.swatch(s).is_some()));
crate::panels::apply_swatch(app, app.ui.stroke_front, sw.as_deref().unwrap_or("[Black]"), None)
}
/// Apply Gradient (.): the gradient applied last, else the document's first gradient swatch, else
/// a new white-to-black gradient (InDesign's default), on the fill or stroke in front.
pub(crate) fn apply_gradient(app: &mut DesignApp) -> Result<Value, String> {
let st = app.session.active().ok_or("no document")?;
let known = app
.ui
.last_gradient
.clone()
.filter(|s| designcraft_color::swatch::resolve_gradient(&st.doc.swatches, s).is_some())
.or_else(|| st.doc.swatches.iter().find(|w| matches!(w.value, designcraft_color::SwatchValue::Gradient { .. })).map(|w| w.name.clone()));
let stroke = app.ui.stroke_front;
if let Some(sw) = known {
return crate::panels::apply_swatch(app, stroke, &sw, None);
}
if crate::panels::colors_affect_text(app) {
return Err("no gradient to apply yet: make one in the Gradient panel".into());
}
let stops = json!([{"location": 0, "color": "#ffffff"}, {"location": 100, "color": "#000000"}]);
let r = app.run("object.gradient", json!({"stops": stops, "target": if stroke { "stroke" } else { "fill" }}))?;
if let Some(sw) = r["swatch"].as_str() {
app.ui.last_gradient = Some(sw.to_string());
}
Ok(r)
}
/// Apply None (/): to the fill or the stroke, whichever is in front of the proxy, of the text or
/// the objects.
pub(crate) fn apply_none(app: &mut DesignApp) -> Result<Value, String> {
crate::panels::apply_swatch(app, app.ui.stroke_front, "[None]", None)
}
#[cfg(test)]
mod tests {
use egui::{pos2, vec2};
use egui_kittest::kittest::Queryable;
use crate::test_window::{self, wheel};
/// The last tool group's button and the Fill proxy, near the bottom of the panel.
const LAST_TOOL: &str = "Zoom Tool (Z)";
const FILL: &str = "Fill (X)";
/// Status bar height: the Tools panel ends above it.
const STATUS_H: f32 = 17.0;
fn app(double: bool) -> crate::DesignApp {
let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.ui.tools_double_column = double;
app
}
fn column_width(cols: f32) -> f32 {
8.0 + super::BTN * cols + 4.0 * (cols - 1.0) + 8.0
}
fn rect(h: &egui_kittest::Harness<'static, test_window::Window>, label: &str) -> egui::Rect {
h.get_by_label(label).rect()
}
/// As InDesign's: a tool group's flyout closes on Esc and on a click outside it (the fill
/// swatch, a panel), not only on the canvas.
#[test]
fn a_tool_flyout_closes_on_escape_and_on_a_click_elsewhere() {
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable;
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mut h = crate::test_window::open(app, vec2(1400.0, 900.0));
let open = |h: &mut egui_kittest::Harness<'static, crate::test_window::Window>| {
let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center();
h.input_mut().events.push(Event::PointerMoved(at));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE });
}
h.run_steps(3);
assert!(h.state().app.ui.flyout.is_some(), "a right-click opens the flyout");
let _ = h.get_by_label("Ellipse Frame Tool");
};
open(&mut h);
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert!(h.state().app.ui.flyout.is_none(), "Esc closes it");
// A click on a panel closes it.
open(&mut h);
crate::test_window::click_at(&mut h, egui::pos2(1300.0, 700.0));
assert!(h.state().app.ui.flyout.is_none(), "a click elsewhere closes it");
// Choosing a tool in it closes it and selects the tool.
open(&mut h);
h.get_by_label("Ellipse Frame Tool").click();
h.run_steps(3);
assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("ellipseFrame", None));
}
/// A flyout opened by holding the group button stays open after the release, and Esc closes
/// it without switching the active tool.
#[test]
fn a_tool_flyout_opened_by_holding_stays_open_and_esc_keeps_the_tool() {
use egui::{Event, Modifiers, PointerButton};
use egui_kittest::kittest::Queryable;
let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let mut h = crate::test_window::open_stepped(app, vec2(1400.0, 900.0), 1.0, 1.0 / 60.0);
h.state_mut().app.select_tool("rectangleFrame");
h.run_steps(2);
let at = h.get_by_label("Rectangle Frame Tool (F)").rect().center();
h.input_mut().events.push(Event::PointerMoved(at));
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
// Held for half a second: shorter than egui's longest click.
h.run_steps(30);
assert!(h.state().app.ui.flyout.is_some(), "holding opens the flyout");
h.input_mut().events.push(Event::PointerButton { pos: at, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
h.run_steps(3);
assert!(h.state().app.ui.flyout.is_some(), "the release keeps it open");
h.key_press(egui::Key::Escape);
h.run_steps(2);
assert_eq!((h.state().app.session.tool_id(), h.state().app.ui.flyout), ("rectangleFrame", None));
}
#[test]
fn a_short_window_reaches_every_tool() {
for double in [false, true] {
let (w, ht) = (1000.0, 500.0);
let mut h = test_window::open(app(double), vec2(w, ht));
let panel = test_window::panel_rect(&h, "tools");
wheel(&mut h, panel.center(), vec2(0.0, -2000.0));
let visible = egui::Rect::from_min_max(pos2(0.0, panel.min.y), pos2(w, ht - STATUS_H));
for label in [LAST_TOOL, FILL] {
let r = rect(&h, label);
assert!(visible.contains_rect(r), "double column {double}: {label} at {r:?} is outside {visible:?}");
}
assert_eq!(h.state().app.ui.tools_double_column, double, "the preference is the user's");
}
}
#[test]
fn tools_take_two_columns_while_one_does_not_fit() {
let (w, ht) = (1086.0, 612.0);
let mut h = test_window::open(app(false), vec2(w, ht));
assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(2.0));
let visible = egui::Rect::from_min_max(pos2(0.0, 0.0), pos2(w, ht - STATUS_H));
for label in [LAST_TOOL, FILL] {
let r = rect(&h, label);
assert!(visible.contains_rect(r), "{label} at {r:?} is outside {visible:?} without scrolling");
}
assert!(!h.state().app.ui.tools_double_column);
// A taller window brings the single column back.
h.set_size(vec2(1400.0, 900.0));
h.run_steps(4);
assert_eq!(test_window::panel_rect(&h, "tools").width(), column_width(1.0));
assert!(!h.state().app.ui.tools_double_column);
}
/// Runs `app` in a window and double-clicks the Tools panel's Fill box.
fn double_click_fill(app: crate::DesignApp) -> egui_kittest::Harness<'static, crate::DesignApp> {
// Frames 1 ms apart, so two clicks make a double-click.
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state(
|ui, app: &mut crate::DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
let at = h.get_by_label(FILL).rect().center();
h.hover_at(at);
h.step();
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
h
}
#[test]
fn double_clicking_the_fill_box_opens_the_color_picker_with_the_selections_colour() {
let mut app = app(false);
app.run("file.new", serde_json::json!({})).unwrap();
let id = app.run("frame.create", serde_json::json!({"rect": [72, 72, 200, 200]})).unwrap()["id"].clone();
app.run("object.color", serde_json::json!({"color": "#456daf", "ids": [id]})).unwrap();
app.run("selection.set", serde_json::json!({"ids": [id]})).unwrap();
let h = double_click_fill(app);
let d = h.state().ui.dialog.clone().expect("the Color Picker opened");
assert_eq!(d.id, "colorPicker");
assert_eq!((d.fields["target"].as_str(), d.fields["hex"].as_str()), (Some("fill"), Some("#456daf")), "{:?}", d.fields);
}
/// With Formatting Affects Text on and a text frame selected, the picker opened from the Fill
/// box colours the frame's text, not the frame.
#[test]
fn the_color_picker_from_the_fill_box_follows_formatting_affects_text() {
let mut app = app(false);
app.run("file.new", serde_json::json!({})).unwrap();
let r = app.run("frame.create", serde_json::json!({"rect": [72, 72, 300, 200], "content": "text", "text": "words", "caret": false})).unwrap();
let id = designcraft_doc::ItemId(r["id"].as_u64().unwrap());
app.run("selection.set", serde_json::json!({"ids": [id.0]})).unwrap();
app.ui.formatting_affects_text = true;
let mut h = double_click_fill(app);
let app = h.state_mut();
let d = app.ui.dialog.as_mut().expect("the Color Picker opened");
assert_eq!(d.fields["text"], true, "{:?}", d.fields);
// What the picker's fields hold after #456daf is typed.
for (k, v) in [("hex", serde_json::json!("#456daf")), ("rgb", serde_json::json!([0x45, 0x6d, 0xaf])), ("model", serde_json::json!("rgb"))] {
d.fields.insert(k.into(), v);
}
d.fields.insert("edited".into(), serde_json::json!(true));
crate::dialogs::confirm(app).unwrap();
let st = app.session.active().unwrap();
let it = st.doc.item(id).unwrap();
assert_eq!(it.fill.swatch, "[None]", "the frame keeps its fill");
let story = st.doc.stories.values().find(|s| s.text.starts_with("words")).unwrap();
let fill = story.runs().next().unwrap().1.over.fill.clone().expect("the text has a fill");
let rgb = st.doc.resolve_color(&fill, 1.0).unwrap().to_rgb();
assert!((rgb[0] - 0x45 as f32 / 255.0).abs() < 0.01 && (rgb[2] - 0xaf as f32 / 255.0).abs() < 0.01, "{rgb:?}");
}
#[test]
fn a_tall_window_keeps_one_column_and_does_not_scroll() {
let mut h = test_window::open(app(false), vec2(1400.0, 900.0));
let panel = test_window::panel_rect(&h, "tools");
assert_eq!(panel.width(), column_width(1.0));
let before = (rect(&h, LAST_TOOL), rect(&h, FILL));
assert!(panel.contains_rect(before.0) && panel.contains_rect(before.1), "{before:?} outside {panel:?}");
wheel(&mut h, panel.center(), vec2(0.0, -2000.0));
assert_eq!((rect(&h, LAST_TOOL), rect(&h, FILL)), before);
}
#[test]
fn the_tools_panel_lays_out_like_indesigns() {
// Chris Nielsen's "The Adobe InDesign Toolbox" (InDesign 2020): Gradient Swatch and Gradient
// Feather are tools of their own; in two columns Content Collector and Placer, Apply
// Color / Gradient / None and Normal / Preview each get a button.
for double in [false, true] {
let h = test_window::open(app(double), vec2(1400.0, 900.0));
let _ = rect(&h, "Gradient Swatch Tool (G)");
let _ = rect(&h, "Gradient Feather Tool (Shift+G)");
let _ = rect(&h, "View Options");
let split = ["Content Placer Tool (B)", "Apply Color (,)", "Apply Gradient (.)", "Normal"];
for label in split {
assert_eq!(h.query_by_label(label).is_some(), double, "double column {double}: {label}");
}
let _ = rect(&h, "Content Collector Tool (B)");
// One column: the Apply well names what is in front (no fill: Apply None).
let _ = rect(&h, "Apply None (/)");
if double {
let (a, b) = (rect(&h, "Content Collector Tool (B)"), rect(&h, "Content Placer Tool (B)"));
assert!((a.center().y - b.center().y).abs() < 1.0 && a.center().x < b.center().x, "side by side: {a:?} {b:?}");
let (n, p) = (rect(&h, "Normal"), rect(&h, "Preview (W)"));
assert!((n.center().y - p.center().y).abs() < 1.0, "Normal and Preview side by side");
}
}
}
#[test]
fn apply_color_and_gradient_reuse_the_last_ones() {
use serde_json::json;
let mut app = app(true);
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone();
app.run("selection.set", json!({"ids": [id]})).unwrap();
let fill = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
let it = st.doc.item(st.selection.items[0]).unwrap();
(it.fill.swatch.clone(), it.stroke.swatch.clone())
};
// Apply Color: [Black] at first, then the colour last applied.
app.run("color.applyColor", json!({})).unwrap();
assert_eq!(fill(&app).0, "[Black]");
crate::panels::apply_swatch(&mut app, false, "C=100 M=0 Y=0 K=0", None).unwrap();
app.run("color.applyNone", json!({})).unwrap();
assert_eq!(fill(&app).0, "[None]");
app.run("color.applyColor", json!({})).unwrap();
assert_eq!(fill(&app).0, "C=100 M=0 Y=0 K=0");
// Apply Gradient: white to black when there's none yet, then that one again.
app.run("color.applyGradient", json!({})).unwrap();
let g = fill(&app).0;
let doc = &app.session.active().unwrap().doc;
let grad = designcraft_color::swatch::resolve_gradient(&doc.swatches, &g).expect("a gradient");
assert_eq!((grad.stops[0].color.to_rgb(), grad.stops[1].color.to_rgb()), ([1.0; 3], [0.0; 3]));
app.run("color.applyColor", json!({})).unwrap();
// With the stroke in front, it goes to the stroke; the fill keeps its colour.
app.ui.stroke_front = true;
app.run("color.applyGradient", json!({})).unwrap();
assert_eq!(fill(&app), ("C=100 M=0 Y=0 K=0".to_string(), g));
// The shortcuts: , and .
for (id, sc) in [("color.applyColor", ","), ("color.applyGradient", ".")] {
assert_eq!(crate::menus::ui_label(id).and_then(|c| c.1), Some(sc));
}
}
/// A new default gradient on the stroke in front is one undo step; the fill keeps its swatch and tint.
#[test]
fn apply_gradient_to_the_stroke_is_one_undo_step() {
use serde_json::json;
let mut app = app(true);
app.run("file.new", json!({})).unwrap();
let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].clone();
app.run("selection.set", json!({"ids": [id]})).unwrap();
app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap();
app.run("object.stroke", json!({"swatch": "[Black]", "tint": 0.7})).unwrap();
let paints = |app: &crate::DesignApp| {
let st = app.session.active().unwrap();
let it = st.doc.item(st.selection.items[0]).unwrap();
(it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint)
};
let before = paints(&app);
app.ui.stroke_front = true;
app.run("color.applyGradient", json!({})).unwrap();
let after = paints(&app);
assert_eq!((&after.0, after.1), (&before.0, before.1), "the fill is untouched");
assert_ne!(after.2, before.2, "the stroke is the gradient");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(paints(&app), before, "one undo restores the stroke");
}
/// With nothing selected, the Color Picker and Apply None set the colours of the next shape
/// (the course's poster: a scarlet fill picked before drawing), and the proxy shows them.
#[test]
fn with_nothing_selected_the_proxy_sets_the_next_shapes_colours() {
use serde_json::json;
let mut app = app(false);
app.run("file.new", json!({})).unwrap();
// Frames 1 ms apart, so two clicks make a double-click.
let mut h = egui_kittest::Harness::builder().with_size(vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state(
|ui, app: &mut crate::DesignApp| {
let ctx = ui.ctx().clone();
app.logic(&ctx);
app.ui(ui);
},
app,
);
h.run_steps(6);
// Double-click the Fill box: the picker opens; OK with nothing selected keeps the colour.
let at = h.get_by_label(FILL).rect().center();
h.hover_at(at);
h.step();
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
let app = h.state_mut();
assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("colorPicker"));
let d = app.ui.dialog.as_mut().unwrap();
// What the picker's fields hold after #7c1315 is typed.
for (k, v) in [("hex", json!("#7c1315")), ("rgb", json!([0x7c, 0x13, 0x15])), ("model", json!("rgb")), ("edited", json!(true))] {
d.fields.insert(k.into(), v);
}
crate::dialogs::confirm(app).unwrap();
// Apply None with the stroke in front.
app.ui.stroke_front = true;
app.run("color.applyNone", json!({})).unwrap();
let red = app.session.active().unwrap().doc.settings.new_object.fill.clone();
assert_eq!(app.session.active().unwrap().doc.resolve_color(&red, 1.0).unwrap().to_hex(), "#7c1315");
// The next rectangle has them.
let id = app.run("frame.create", json!({"rect": [72, 72, 200, 200], "content": "unassigned"})).unwrap()["id"].as_u64().unwrap();
let st = app.session.active().unwrap();
let it = st.doc.item(designcraft_doc::ItemId(id)).unwrap();
assert_eq!((it.fill.swatch.as_str(), it.stroke.swatch.as_str()), (red.as_str(), "[None]"));
}
/// The Fill/Stroke proxy's keys run on the selection and stay out of text being typed.
#[test]
fn fill_stroke_proxy_keys() {
use egui::{Event, Key, Modifiers};
use serde_json::json;
fn press(app: &mut crate::DesignApp, ctx: &egui::Context, key: Key, modifiers: Modifiers) {
let events = [true, false].map(|pressed| Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec();
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| crate::menus::shortcuts(app, ui.ctx())).textures_delta.clear();
}
fn colors(app: &crate::DesignApp) -> (String, f32, String, f32) {
let st = app.session.active().unwrap();
let it = st.doc.item(st.selection.items[0]).unwrap();
(it.fill.swatch.clone(), it.fill.tint, it.stroke.swatch.clone(), it.stroke.tint)
}
let mut app = app(false);
let ctx = egui::Context::default();
app.run("file.new", json!({})).unwrap();
let f = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap();
app.run("selection.set", json!({"ids": [f["id"]]})).unwrap();
app.run("object.fill", json!({"swatch": "[Paper]", "tint": 0.5})).unwrap();
app.run("object.stroke", json!({"swatch": "[Black]", "tint": 1.0})).unwrap();
press(&mut app, &ctx, Key::X, Modifiers::SHIFT);
assert_eq!(colors(&app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps, tints included");
press(&mut app, &ctx, Key::D, Modifiers::NONE);
assert_eq!(colors(&app), ("[None]".into(), 1.0, "[Black]".into(), 1.0), "D: no fill, black stroke");
press(&mut app, &ctx, Key::X, Modifiers::NONE);
assert!(app.ui.stroke_front, "X brings the stroke to the front");
press(&mut app, &ctx, Key::Slash, Modifiers::NONE);
assert_eq!(colors(&app).2, "[None]", "/ applies None to the stroke in front");
// Formatting Affects Text (J) with the frame selected: the keys colour its text, as the
// proxy shows it, and leave the frame alone.
press(&mut app, &ctx, Key::X, Modifiers::NONE);
press(&mut app, &ctx, Key::J, Modifiers::NONE);
assert!(crate::panels::colors_affect_text(&app));
let frame = colors(&app);
app.run("type.char", json!({"attrs": {"fill": "[Paper]", "fillTint": 0.5, "stroke": "[Black]", "strokeTint": 1.0}})).unwrap();
let text = |app: &mut crate::DesignApp| crate::panels::color_target(app).unwrap();
press(&mut app, &ctx, Key::X, Modifiers::SHIFT);
assert_eq!(text(&mut app), ("[Black]".into(), 1.0, "[Paper]".into(), 0.5), "Shift+X swaps the text's colours");
press(&mut app, &ctx, Key::D, Modifiers::NONE);
assert_eq!(text(&mut app), ("[Black]".into(), 1.0, "[None]".into(), 1.0), "D: text gets a black fill and no stroke");
press(&mut app, &ctx, Key::Slash, Modifiers::NONE);
assert_eq!(text(&mut app).0, "[None]", "/ applies None to the text's fill in front");
assert_eq!(colors(&app), frame, "the frame keeps its colours");
// Typing in a story: the keys are text, not shortcuts.
app.session.execute("text.select", &json!({"story": f["story"], "anchor": 5})).unwrap();
app.select_tool("type");
assert!(app.session.wants_text());
let front = app.ui.stroke_front;
press(&mut app, &ctx, Key::X, Modifiers::NONE);
assert_eq!(app.ui.stroke_front, front, "X while typing leaves the proxy alone");
}
}
#[cfg(test)]
mod text_color_tests {
use super::*;
use designcraft_doc::{ItemId, StoryId};
#[test]
fn integration_color_swap_preserves_each_run_and_frame() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let mut frames = vec![];
let mut stories = vec![];
for text in ["abcd", "efgh"] {
let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":text})).unwrap();
frames.push(f["id"].clone());
stories.push(f["story"].as_u64().unwrap());
}
let pairs = [("[Paper]", "[Black]", 0.2, 0.8), ("[Black]", "[Paper]", 0.4, 0.6)];
for (i, sid) in stories.iter().enumerate() {
for j in 0..2 {
let (fill, stroke, ft, st) = pairs[(i + j) % 2];
app.run("text.select", json!({"story":sid,"anchor":j*2,"focus":j*2+2})).unwrap();
app.run("type.char", json!({"fill":fill,"stroke":stroke,"fillTint":ft,"strokeTint":st})).unwrap();
}
}
app.run("selection.set", json!({"ids":frames})).unwrap();
app.run("app.formattingAffectsText", json!({"on":true})).unwrap();
let before = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap();
let n = app.session.doc().unwrap().history.undo.len();
swap_fill_stroke(&mut app).unwrap();
assert_eq!(app.session.doc().unwrap().history.undo.len(), n + 1);
for (i, sid) in stories.iter().enumerate() {
let story = app.session.doc().unwrap().doc.story(StoryId(*sid)).unwrap();
for j in 0..2 {
let (fill, stroke, ft, st) = pairs[(i + j) % 2];
let a = &story.format_after(j * 2).over;
assert_eq!((a.fill.as_deref(), a.stroke.as_deref(), a.fill_tint, a.stroke_tint), (Some(stroke), Some(fill), Some(st), Some(ft)));
}
}
let swapped = serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap();
app.run("edit.undo", json!({})).unwrap();
assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(serde_json::to_value(app.session.doc().unwrap().doc.as_ref()).unwrap(), swapped);
}
#[test]
fn integration_color_toolbar_picker_targets_selected_frame_text() {
use egui_kittest::kittest::Queryable;
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
app.run("file.new", json!({})).unwrap();
let f = app.run("frame.create", json!({"rect":[36,36,300,200],"content":"text","text":"hello"})).unwrap();
app.run("selection.set", json!({"ids":[f["id"]]})).unwrap();
app.run("app.formattingAffectsText", json!({"on":true})).unwrap();
let before = app.session.doc().unwrap().doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill.clone();
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.001).build_ui_state(
|ui, app: &mut DesignApp| {
app.logic(&ui.ctx().clone());
app.ui(ui);
},
app,
);
h.run_steps(6);
let at = h.get_by_label("Fill (X)").rect().center();
h.hover_at(at);
h.step();
for pressed in [true, false, true, false] {
h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
h.step();
}
h.run_steps(3);
let d = h.state().ui.dialog.clone().unwrap();
assert_eq!(d.id, "colorPicker");
assert_eq!(d.fields["text"], true);
h.state_mut().run("object.color", json!({"color":"#123456","target":"fill","text":d.fields["text"]})).unwrap();
let doc = &h.state().session.doc().unwrap().doc;
assert_eq!(doc.item(ItemId(f["id"].as_u64().unwrap())).unwrap().fill, before);
assert!(doc.story(StoryId(f["story"].as_u64().unwrap())).unwrap().format_after(0).over.fill.is_some());
}
}