mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-28 20:26:57 -06:00
refactor(ui-egui): split chrome.rs into chrome/
This commit is contained in:
+19
-1470
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,411 @@
|
||||
use super::*;
|
||||
|
||||
pub(super) fn search_id() -> egui::Id {
|
||||
egui::Id::unique("app_bar_search")
|
||||
}
|
||||
|
||||
/// Whether the application bar's search field had the keyboard last frame. Escape takes egui's
|
||||
/// focus away before any widget runs, so that press must not reach the global shortcuts.
|
||||
pub fn search_had_focus(ctx: &egui::Context) -> bool {
|
||||
ctx.memory(|m| m.had_focus_last_frame(search_id()))
|
||||
}
|
||||
|
||||
/// The search field's state between frames: the query, the highlighted match, whether the matches
|
||||
/// are shown, and the widget that had the keyboard before the field took it.
|
||||
#[derive(Clone, Default)]
|
||||
struct SearchState {
|
||||
query: String,
|
||||
highlight: usize,
|
||||
open: bool,
|
||||
previous_focus: Option<egui::Id>,
|
||||
}
|
||||
|
||||
/// The application bar's search field: finds menu commands by their labels in the interface
|
||||
/// language. ↑/↓ move through the matches; Return or a click runs one as its menu item would.
|
||||
/// Running one or pressing Escape clears the field and gives the keyboard back to the widget that
|
||||
/// had it. While the field has the keyboard, no key reaches the shortcuts or the document.
|
||||
fn search_field(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = search_id();
|
||||
let state_id = id.with("state");
|
||||
let mut st: SearchState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default();
|
||||
let (r, _) = ui.allocate_exact_size(vec2(125.0, 18.0), Sense::hover());
|
||||
ui.painter().rect(r, 1.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), egui::Rect::from_min_size(r.min + vec2(3.0, 2.0), vec2(14.0, 14.0)), "search", t.icon);
|
||||
let focused = ui.memory(|m| m.has_focus(id));
|
||||
let had_focus = search_had_focus(ui.ctx());
|
||||
if !focused && !had_focus {
|
||||
st.previous_focus = ui.memory(|m| m.focused());
|
||||
}
|
||||
let none = egui::Modifiers::NONE;
|
||||
let (down, up, enter, escape) = if focused || had_focus {
|
||||
ui.input_mut(|i| {
|
||||
(
|
||||
i.consume_key(none, egui::Key::ArrowDown),
|
||||
i.consume_key(none, egui::Key::ArrowUp),
|
||||
i.consume_key(none, egui::Key::Enter),
|
||||
i.consume_key(none, egui::Key::Escape),
|
||||
)
|
||||
})
|
||||
} else {
|
||||
(false, false, false, false)
|
||||
};
|
||||
let lang = app.ui.language.clone();
|
||||
let hint = crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&lang, "Search")).color(t.text_dim));
|
||||
let field = egui::Rect::from_min_max(r.min + vec2(19.0, 0.0), r.max - vec2(3.0, 0.0));
|
||||
let resp = ui
|
||||
.put(
|
||||
field,
|
||||
egui::TextEdit::singleline(&mut st.query)
|
||||
.id(id)
|
||||
.frame(egui::Frame::NONE)
|
||||
.margin(egui::Margin::ZERO)
|
||||
.font(egui::FontId::proportional(11.0))
|
||||
.text_color(t.text)
|
||||
.hint_text(hint)
|
||||
.desired_width(field.width())
|
||||
.align(egui::Align2::LEFT_CENTER)
|
||||
// Return runs the highlighted match instead of leaving the field.
|
||||
.return_key(None),
|
||||
)
|
||||
.on_hover_text(crate::i18n::tr(&lang, "Search commands"));
|
||||
if resp.changed() {
|
||||
st.highlight = 0;
|
||||
st.open = true;
|
||||
}
|
||||
if resp.gained_focus() {
|
||||
st.open = true;
|
||||
}
|
||||
let items = if st.open { crate::menus::command_search(app, &st.query) } else { Vec::new() };
|
||||
let last = items.len().saturating_sub(1);
|
||||
if down {
|
||||
st.highlight = st.highlight.saturating_add(1);
|
||||
}
|
||||
if up {
|
||||
st.highlight = st.highlight.saturating_sub(1);
|
||||
}
|
||||
st.highlight = st.highlight.min(last);
|
||||
let mut run = None;
|
||||
if enter && let Some(it) = items.get(st.highlight).filter(|it| crate::menus::menu_enabled(app, &it.id)) {
|
||||
run = Some(it.id.clone());
|
||||
}
|
||||
let mut over_matches = false;
|
||||
if st.open && !st.query.trim().is_empty() {
|
||||
let shown = egui::Area::new(id.with("matches"))
|
||||
.order(egui::Order::Foreground)
|
||||
.pivot(egui::Align2::RIGHT_TOP)
|
||||
.fixed_pos(r.right_bottom() + vec2(0.0, 2.0))
|
||||
.show(ui.ctx(), |ui| {
|
||||
egui::Frame::menu(ui.style()).show(ui, |ui| {
|
||||
let size = vec2(300.0, 22.0);
|
||||
ui.set_width(size.x);
|
||||
if items.is_empty() {
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No matching commands")).color(t.text_dim));
|
||||
}
|
||||
for (k, it) in items.iter().enumerate() {
|
||||
let mut b = egui::Button::new(crate::rtl::widget(ui, it.label.as_str())).frame(false).min_size(size);
|
||||
if let Some(sc) = &it.shortcut {
|
||||
b = b.shortcut_text(crate::menus::shortcut_text(sc));
|
||||
}
|
||||
if k == st.highlight {
|
||||
ui.painter().rect_filled(egui::Rect::from_min_size(ui.cursor().min, size), 2.0, t.hover);
|
||||
}
|
||||
let enabled = crate::menus::menu_enabled(app, &it.id);
|
||||
// The button aligns its label by the layout it's in: left, like the menus.
|
||||
let row = ui.allocate_ui_with_layout(
|
||||
size,
|
||||
egui::Layout::left_to_right(egui::Align::Center).with_main_align(egui::Align::Min),
|
||||
|ui| ui.add_enabled(enabled, b),
|
||||
);
|
||||
if row.inner.clicked() {
|
||||
run = Some(it.id.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
over_matches = shown.response.contains_pointer();
|
||||
}
|
||||
if escape || run.is_some() {
|
||||
st.query.clear();
|
||||
st.open = false;
|
||||
let previous = st.previous_focus.take();
|
||||
ui.memory_mut(|m| match previous {
|
||||
Some(p) => m.request_focus(p),
|
||||
None => m.surrender_focus(id),
|
||||
});
|
||||
} else if !resp.has_focus() && !over_matches {
|
||||
// A click elsewhere or Tab took the keyboard: the matches close, the query stays.
|
||||
st.open = false;
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(state_id, st));
|
||||
if let Some(cmd) = run {
|
||||
crate::menus::activate(app, &cmd, &serde_json::Value::Null);
|
||||
}
|
||||
}
|
||||
|
||||
/// Width of the app bar's right-hand slot: what is left of the row, minus a pixel of slack so
|
||||
/// that rounding at fractional scales (115%) never makes the row a hair wider than the scroll
|
||||
/// area and shows a needless scroll bar. Never narrower than the controls themselves.
|
||||
fn tools_slot_width(available: f32, tools_w: f32) -> f32 {
|
||||
(available - 1.0).max(tools_w)
|
||||
}
|
||||
|
||||
/// Lay out the application bar, native/custom title handling, menu and tool scrolling, drag region, caption controls and search.
|
||||
pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let mac = app.integrated_titlebar;
|
||||
let left = if mac { MAC_CONTENT_LEFT as i8 } else { 8 };
|
||||
let bar = if mac { MAC_APP_BAR } else { APP_BAR };
|
||||
// The tallest control keeps a margin above and below it in the shorter macOS bar.
|
||||
let home = if mac { 22.0 } else { 24.0 };
|
||||
let resp = egui::Panel::top("app_bar")
|
||||
.exact_size(bar + APP_BAR_BAND)
|
||||
.frame(egui::Frame::NONE.fill(t.app_bar).inner_margin(egui::Margin {
|
||||
left,
|
||||
right: if app.custom_titlebar { 0 } else { 10 },
|
||||
top: 0,
|
||||
bottom: (APP_BAR_BAND - SEPARATOR) as i8,
|
||||
}))
|
||||
.show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
if mac {
|
||||
// The whole title-bar band, margins included; the controls drawn below take
|
||||
// their own clicks.
|
||||
let band = egui::Rect::from_min_max(
|
||||
egui::pos2(full.min.x - f32::from(left), full.min.y),
|
||||
egui::pos2(full.max.x + 10.0, full.min.y + MAC_APP_BAR),
|
||||
);
|
||||
let _ = craft_window::title_bar::empty_area(ui, band);
|
||||
}
|
||||
let mut menus_end = full.min.x;
|
||||
let mut tools_start = full.max.x;
|
||||
// Windows caption buttons stay at the right edge; the rest of the bar scrolls.
|
||||
let caption_w = if app.custom_titlebar { CAPTION_W } else { 0.0 };
|
||||
// Undecorated window: the bar moves it. The drag area is the whole bar but the caption
|
||||
// buttons, lower band included, registered before the menus and tools so they stay on
|
||||
// top of it: any gap drags, and the lower band is always there, however narrow the
|
||||
// window (the menus and tools scroll).
|
||||
if app.custom_titlebar {
|
||||
let drag = egui::Rect::from_min_max(
|
||||
egui::pos2(full.min.x - left as f32, full.min.y),
|
||||
egui::pos2(full.max.x - caption_w, full.min.y + bar + APP_BAR_BAND),
|
||||
);
|
||||
let response = ui.interact(drag, egui::Id::unique("window_title_drag"), Sense::click_and_drag());
|
||||
// Only from an empty part of the bar: the lower band, or the gap between the menus
|
||||
// and the tools (as they were last frame). A menu title pressed and dragged doesn't
|
||||
// move the window, as with native title bars.
|
||||
let (gap0, gap1) = ui
|
||||
.data(|d| (d.get_temp::<f32>(egui::Id::unique("app_bar_menus_end")), d.get_temp::<f32>(egui::Id::unique("app_bar_tools_start"))));
|
||||
let empty = |p: egui::Pos2| p.y >= full.min.y + bar || matches!((gap0, gap1), (Some(a), Some(b)) if p.x >= a && p.x <= b);
|
||||
let from_empty = ui.input(|i| i.pointer.press_origin().or(i.pointer.interact_pos())).is_some_and(empty);
|
||||
if response.double_clicked() && from_empty {
|
||||
let on = !ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
let _ = app.run("window.maximize", json!({"on": on}));
|
||||
} else if response.drag_started_by(egui::PointerButton::Primary) && from_empty {
|
||||
let _ = app.run("window.drag", json!({}));
|
||||
}
|
||||
app.flush_window_commands(ui.ctx());
|
||||
}
|
||||
if app.custom_titlebar {
|
||||
let caption = egui::Rect::from_min_max(egui::pos2(full.max.x - caption_w, full.min.y), full.max);
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(caption).layout(egui::Layout::right_to_left(egui::Align::Center)), |ui| {
|
||||
caption_buttons(app, ui);
|
||||
});
|
||||
}
|
||||
let scrolled = egui::Rect::from_min_max(full.min, egui::pos2(full.max.x - caption_w, full.max.y));
|
||||
ui.scope_builder(egui::UiBuilder::new().max_rect(scrolled), |ui| {
|
||||
// The right-hand controls' width, measured last frame: in a window too narrow for them
|
||||
// and the menus they follow the menus, and the bar scrolls sideways.
|
||||
let tools_w_id = ui.scope_id().with("tools_width");
|
||||
let tools_w: f32 = ui.data(|d| d.get_temp(tools_w_id)).unwrap_or(0.0);
|
||||
crate::widgets::overflow_scrolling(ui);
|
||||
// (Drag-scrolling is touch-only by default, so a mouse drag on the bar moves the window.)
|
||||
egui::ScrollArea::horizontal().id_salt("app_bar_scroll").auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
if icons::button(ui, "home", home, app.session.active().is_none(), crate::i18n::tr(&app.ui.language, "Home")).clicked() {
|
||||
app.session_home();
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
if !app.native_menu {
|
||||
ui.style_mut().visuals.widgets.inactive.weak_bg_fill = Color32::TRANSPARENT;
|
||||
ui.style_mut().visuals.widgets.inactive.bg_stroke = Stroke::NONE;
|
||||
ui.style_mut().visuals.widgets.hovered.bg_stroke = Stroke::NONE;
|
||||
ui.style_mut().spacing.button_padding = vec2(7.0, 3.0);
|
||||
crate::menus::menu_bar(app, ui);
|
||||
}
|
||||
menus_end = ui.min_rect().max.x;
|
||||
// Where the menus end on screen (moves as a narrow bar scrolls).
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique("app_bar_menus_end"), menus_end));
|
||||
let size = vec2(tools_slot_width(ui.available_width(), tools_w), ui.available_height());
|
||||
let tools = ui.allocate_ui_with_layout(size, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.add_space(4.0);
|
||||
search_field(app, ui);
|
||||
ui.add_space(8.0);
|
||||
let current = app.ui.workspace.clone();
|
||||
let shown = crate::i18n::workspace_name(&app.ui.language, ¤t);
|
||||
crate::menus::menu_button(
|
||||
ui,
|
||||
crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)),
|
||||
|ui| {
|
||||
let customs: Vec<String> = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect();
|
||||
for w in crate::menus::WORKSPACES.iter().map(|w| w.to_string()).chain(customs.iter().cloned()) {
|
||||
if ui
|
||||
.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w)))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("window.workspace", json!({"name": w}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
if ui
|
||||
.button(crate::rtl::widget(
|
||||
ui,
|
||||
format!(
|
||||
"{} {}",
|
||||
crate::i18n::tr(&app.ui.language, "Reset"),
|
||||
crate::i18n::workspace_name(&app.ui.language, ¤t)
|
||||
),
|
||||
))
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("window.resetWorkspace", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() {
|
||||
let _ = app.run("window.newWorkspace", json!({}));
|
||||
ui.close();
|
||||
}
|
||||
if !customs.is_empty() {
|
||||
crate::menus::menu_button(
|
||||
ui,
|
||||
crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")),
|
||||
|ui| {
|
||||
for w in &customs {
|
||||
if ui.button(w).clicked() {
|
||||
let _ = app.run("window.deleteWorkspace", json!({"name": w}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() {
|
||||
app.status(crate::i18n::tr(&app.ui.language, "Export a PDF, IDML or package to share — no cloud account needed."));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
// Always one click away: the ArtCraft community Discord.
|
||||
if crate::about::discord_button(ui, "Discord", vec2(78.0, 22.0)) {
|
||||
let _ = app.run("help.discord", json!({}));
|
||||
}
|
||||
tools_start = ui.min_rect().min.x;
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::unique("app_bar_tools_start"), tools_start));
|
||||
ui.min_rect().width()
|
||||
});
|
||||
if (tools.inner - tools_w).abs() > 0.5 {
|
||||
ui.data_mut(|d| d.insert_temp(tools_w_id, tools.inner));
|
||||
ui.ctx().request_discard("app bar: the right-hand controls changed width");
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
// Centred title.
|
||||
let title = match app.session.active() {
|
||||
Some(d) => format!("DesignCraft — {}", crate::rtl::isolate(&d.title())),
|
||||
None => "DesignCraft".to_string(),
|
||||
};
|
||||
// Centred between the menus and the right-hand controls; left out when it won't fit.
|
||||
let galley = crate::rtl::plain(ui.ctx(), &title, egui::FontId::proportional(11.5), t.text);
|
||||
let w = galley.size().x;
|
||||
let cx = full.center().x;
|
||||
if cx - w / 2.0 > menus_end + 12.0 && cx + w / 2.0 < tools_start - 12.0 {
|
||||
ui.painter().galley(egui::pos2(cx - w / 2.0, full.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
}
|
||||
});
|
||||
let r = resp.response.rect;
|
||||
ui.painter().rect_filled(egui::Rect::from_min_max(egui::pos2(r.min.x, r.max.y - APP_BAR_BAND), r.max), 0.0, t.pasteboard);
|
||||
ui.painter().line_segment([egui::pos2(r.min.x, r.max.y), egui::pos2(r.max.x, r.max.y)], Stroke::new(1.0, t.border));
|
||||
}
|
||||
|
||||
const CAPTION_BUTTON_W: f32 = 46.0;
|
||||
|
||||
/// Minimize, maximize and close, and the space after them.
|
||||
const CAPTION_W: f32 = 3.0 * CAPTION_BUTTON_W + 8.0;
|
||||
|
||||
/// Windows caption buttons. The macOS traffic lights remain native.
|
||||
fn caption_buttons(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let maximized = ui.input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
let spacing = ui.spacing().item_spacing.x;
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
for (command, label) in
|
||||
[("window.close", "Close"), ("window.maximize", if maximized { "Restore" } else { "Maximize" }), ("window.minimize", "Minimize")]
|
||||
{
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(CAPTION_BUTTON_W, APP_BAR), Sense::hover());
|
||||
let response = ui.interact(rect, egui::Id::unique(command), Sense::click());
|
||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), label));
|
||||
let hovered = response.hovered();
|
||||
if hovered {
|
||||
ui.painter().rect_filled(rect, 0.0, if command == "window.close" { t.caption_close } else { t.hover });
|
||||
}
|
||||
let color = if hovered && command == "window.close" { t.caption_close_text } else { t.text };
|
||||
let stroke = Stroke::new(1.0, color);
|
||||
let icon = egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0));
|
||||
match command {
|
||||
"window.close" => {
|
||||
ui.painter().line_segment([icon.left_top(), icon.right_bottom()], stroke);
|
||||
ui.painter().line_segment([icon.right_top(), icon.left_bottom()], stroke);
|
||||
}
|
||||
"window.maximize" if maximized => {
|
||||
let back = icon.translate(vec2(2.0, -2.0));
|
||||
ui.painter().line_segment([back.left_top(), back.right_top()], stroke);
|
||||
ui.painter().line_segment([back.right_top(), back.right_bottom()], stroke);
|
||||
ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
"window.maximize" => {
|
||||
ui.painter().rect_stroke(icon, 0.0, stroke, egui::StrokeKind::Inside);
|
||||
}
|
||||
_ => {
|
||||
ui.painter().line_segment([icon.left_center(), icon.right_center()], stroke);
|
||||
}
|
||||
}
|
||||
if response.clicked() {
|
||||
let params = if command == "window.maximize" { json!({"on": !maximized}) } else { json!({}) };
|
||||
let _ = app.run(command, params);
|
||||
app.flush_window_commands(ui.ctx());
|
||||
}
|
||||
response.on_hover_text(crate::i18n::tr(&app.ui.language, label));
|
||||
}
|
||||
ui.spacing_mut().item_spacing.x = spacing;
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
|
||||
/// Undecorated Windows windows need resize grips as well as caption controls.
|
||||
pub(crate) fn resize_borders(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if ui.input(|i| i.viewport().maximized.unwrap_or(false) || i.viewport().fullscreen.unwrap_or(false)) {
|
||||
return;
|
||||
}
|
||||
let r = ui.ctx().content_rect();
|
||||
let edge = 4.0;
|
||||
let corner = 10.0;
|
||||
let rect = |x0, y0, x1, y1| egui::Rect::from_min_max(egui::pos2(x0, y0), egui::pos2(x1, y1));
|
||||
for (name, region, cursor) in [
|
||||
("northWest", rect(r.left(), r.top(), r.left() + corner, r.top() + corner), egui::CursorIcon::ResizeNwSe),
|
||||
("northEast", rect(r.right() - corner, r.top(), r.right(), r.top() + corner), egui::CursorIcon::ResizeNeSw),
|
||||
("southWest", rect(r.left(), r.bottom() - corner, r.left() + corner, r.bottom()), egui::CursorIcon::ResizeNeSw),
|
||||
("southEast", rect(r.right() - corner, r.bottom() - corner, r.right(), r.bottom()), egui::CursorIcon::ResizeNwSe),
|
||||
("north", rect(r.left() + corner, r.top(), r.right() - corner, r.top() + edge), egui::CursorIcon::ResizeVertical),
|
||||
("south", rect(r.left() + corner, r.bottom() - edge, r.right() - corner, r.bottom()), egui::CursorIcon::ResizeVertical),
|
||||
("west", rect(r.left(), r.top() + corner, r.left() + edge, r.bottom() - corner), egui::CursorIcon::ResizeHorizontal),
|
||||
("east", rect(r.right() - edge, r.top() + corner, r.right(), r.bottom() - corner), egui::CursorIcon::ResizeHorizontal),
|
||||
] {
|
||||
let response = ui.interact(region, egui::Id::unique(("window_resize", name)), Sense::drag()).on_hover_cursor(cursor);
|
||||
if response.drag_started_by(egui::PointerButton::Primary) {
|
||||
let _ = app.run("window.resize", json!({"edge": name}));
|
||||
app.flush_window_commands(ui.ctx());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
use super::*;
|
||||
|
||||
fn vsep(ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, _) = ui.allocate_exact_size(vec2(9.0, 46.0), Sense::hover());
|
||||
ui.painter().line_segment([r.center_top() + vec2(0.0, 4.0), r.center_bottom() - vec2(0.0, 4.0)], Stroke::new(1.0, t.divider));
|
||||
}
|
||||
|
||||
/// Two-row context-sensitive Control panel.
|
||||
pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::top("control_bar")
|
||||
.exact_size(59.0)
|
||||
.frame(
|
||||
egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin { left: 8, right: 8, top: 5, bottom: 3 }).stroke(Stroke::new(1.0, t.divider)),
|
||||
)
|
||||
.show(ui, |ui| {
|
||||
// Wider than the window: the bar scrolls sideways.
|
||||
crate::widgets::overflow_scrolling(ui);
|
||||
egui::ScrollArea::horizontal().id_salt("control_bar_scroll").auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
let text_mode =
|
||||
matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some());
|
||||
if app.session.tool_id() == "page" {
|
||||
control_page(app, ui);
|
||||
} else if text_mode {
|
||||
control_text(app, ui);
|
||||
} else {
|
||||
crate::object_control::show(app, ui);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// Page-size preset menu and the portrait / landscape toggles of the Control panel.
|
||||
fn page_preset_and_orientation(app: &mut DesignApp, ui: &mut egui::Ui, lang: &str, size: (f64, f64), n: usize, anchor: u8) {
|
||||
let landscape = size.0 > size.1;
|
||||
ui.vertical(|ui| {
|
||||
let cur = panels::properties::preset_name(size.0, size.1);
|
||||
egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, cur))).width(130.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (name, w, h) in panels::properties::PAGE_PRESETS {
|
||||
if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(lang, name))).clicked() {
|
||||
let (w, h) = if landscape { (h, w) } else { (w, h) };
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] {
|
||||
if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(lang, tip)).clicked() && landscape != want {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": size.1, "height": size.0, "anchor": anchor}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size
|
||||
/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for
|
||||
/// the page the Page tool selected (else the page in view). W/H keep the reference point in place.
|
||||
fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let lang = app.ui.language.clone();
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let units = st.doc.settings.horizontal_units;
|
||||
let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return };
|
||||
let Some(st) = app.session.active() else { return };
|
||||
let d = st.doc.clone();
|
||||
let Some((si, pi)) = d.page_loc(page) else { return };
|
||||
let pg = d.spreads[si].pages[pi].clone();
|
||||
let n = page + 1;
|
||||
// Reference point: W/H edits keep it in place.
|
||||
let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover());
|
||||
if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) {
|
||||
app.ui.ref_point = p;
|
||||
}
|
||||
let anchor = app.ui.ref_point.min(8);
|
||||
let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0);
|
||||
// The reference point's position: from the spine across, from the top down.
|
||||
let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height);
|
||||
egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "X:"));
|
||||
// Pages don't move within their spread yet.
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "W:"));
|
||||
if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
caption(ui, crate::i18n::tr(&lang, "Y:"));
|
||||
ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0));
|
||||
caption(ui, crate::i18n::tr(&lang, "H:"));
|
||||
if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) {
|
||||
let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor}));
|
||||
}
|
||||
ui.end_row();
|
||||
});
|
||||
vsep(ui);
|
||||
// Page-size preset and orientation.
|
||||
page_preset_and_orientation(app, ui, &lang, (pg.width, pg.height), n, anchor);
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:"));
|
||||
let label = panels::interactive::liquid_rule_label(pg.liquid);
|
||||
egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui(
|
||||
ui,
|
||||
|ui| {
|
||||
for (r, l) in panels::interactive::LIQUID_RULES {
|
||||
if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r {
|
||||
let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]}));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
let mut overlay = pg.show_parent_overlay;
|
||||
if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() {
|
||||
let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay}));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The Control panel's font sizes (and leadings), as InDesign lists them.
|
||||
const SIZES: &[f64] = &[6.0, 8.0, 9.0, 10.0, 11.0, 12.0, 14.0, 18.0, 24.0, 30.0, 36.0, 48.0, 60.0, 72.0];
|
||||
|
||||
/// The Control panel's tracking values (1/1000 em).
|
||||
const TRACKING: &[f64] = &[-100.0, -75.0, -50.0, -25.0, -10.0, -5.0, 0.0, 5.0, 10.0, 25.0, 50.0, 75.0, 100.0, 200.0];
|
||||
|
||||
/// Whether the text Control panel shows Paragraph Formatting Controls (¶) rather than Character
|
||||
/// Formatting Controls (A).
|
||||
const PARA_MODE: &str = "ctl_text_para_mode";
|
||||
|
||||
/// An icon and a field after it, as the Control panel lays its fields out.
|
||||
pub(crate) fn icon_field(ui: &mut egui::Ui, icon: &str, tip: &str, field: NumField) -> Option<f64> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), r, icon, t.icon);
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, true, tip));
|
||||
let label = resp.id;
|
||||
resp.on_hover_text(tip);
|
||||
field.spinner().labelled_by(label).show(ui)
|
||||
}
|
||||
|
||||
/// A column of two rows (the Control panel's two-row groups).
|
||||
fn rows(ui: &mut egui::Ui, mut row: impl FnMut(&mut egui::Ui, usize)) {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
ui.horizontal(|ui| row(ui, 0));
|
||||
ui.horizontal(|ui| row(ui, 1));
|
||||
});
|
||||
}
|
||||
|
||||
/// A small toggle button showing `text` (the case and position buttons).
|
||||
fn text_toggle(ui: &mut egui::Ui, text: &str, strike: bool, on: bool, tip: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 2.0, t.tool_active);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
let col = if on { t.text_strong } else { t.icon };
|
||||
if text == "small-caps" {
|
||||
crate::icons::paint(ui.painter(), r, "small-caps", col);
|
||||
} else {
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, egui::FontId::proportional(13.0), col);
|
||||
}
|
||||
if strike {
|
||||
ui.painter().line_segment([r.center() + vec2(-6.0, 0.5), r.center() + vec2(6.0, 0.5)], Stroke::new(1.2, col));
|
||||
}
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, tip));
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
/// The text Control panel, as InDesign's: A shows the Character Formatting Controls (font, size
|
||||
/// and leading, the case and position buttons, kerning and tracking, the scales, baseline shift
|
||||
/// and skew, the character style and language); ¶ the Paragraph Formatting Controls (alignment,
|
||||
/// the indents, space before and after, the drop cap, the paragraph style, Hyphenate and Align to
|
||||
/// Baseline Grid).
|
||||
fn control_text(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let lang = app.ui.language.clone();
|
||||
let tr = |s: &str| crate::i18n::tr(&lang, s).to_string();
|
||||
let attrs = panels::text_attrs(app);
|
||||
let chars = attrs.as_ref().map(|a| a["chars"].clone()).unwrap_or_default();
|
||||
let para = attrs.as_ref().map(|a| a["para"].clone()).unwrap_or_default();
|
||||
let mode_id = egui::Id::unique(PARA_MODE);
|
||||
let para_mode = ui.data(|d| d.get_temp::<bool>(mode_id)).unwrap_or(false);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
for (para, text, tip) in [(false, "A", "Character Formatting Controls"), (true, "¶", "Paragraph Formatting Controls")] {
|
||||
let on = para_mode == para;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 2.0, t.tool_active);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 2.0, t.hover);
|
||||
}
|
||||
ui.painter().text(r.center(), egui::Align2::CENTER_CENTER, text, semibold(15.0), if on { t.text_strong } else { t.text_dim });
|
||||
let tip = tr(tip);
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, on, &tip));
|
||||
if resp.on_hover_text(&tip).clicked() {
|
||||
ui.data_mut(|d| d.insert_temp(mode_id, para));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
if para_mode {
|
||||
control_paragraph(app, ui, &attrs, ¶, &tr);
|
||||
} else {
|
||||
control_character(app, ui, &attrs, &chars, ¶, &tr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the character-mode controls and issue their corresponding text commands.
|
||||
fn control_character(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
attrs: &Option<serde_json::Value>,
|
||||
chars: &serde_json::Value,
|
||||
para: &serde_json::Value,
|
||||
tr: &dyn Fn(&str) -> String,
|
||||
) {
|
||||
ui.vertical(|ui| {
|
||||
let fam = chars["fontFamily"].as_str().unwrap_or("").to_string();
|
||||
let sty = chars["fontStyle"].as_str().unwrap_or("").to_string();
|
||||
panels::font_family_picker(app, ui, &fam, 170.0);
|
||||
panels::font_style_picker(app, ui, &fam, &sty, 170.0);
|
||||
});
|
||||
vsep(ui);
|
||||
// Size and leading: a spinner on the left and a list of common values on the right;
|
||||
// automatic leading in parentheses.
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"font-size",
|
||||
&tr("Font Size"),
|
||||
NumField::number("csize", chars["size"].as_f64(), " pt", 2).width(84.0).presets(SIZES).range(0.1, 1296.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"size": v}}));
|
||||
}
|
||||
} else {
|
||||
let auto = chars["leading"]["kind"].as_str() != Some("points");
|
||||
let lead = if auto {
|
||||
chars["size"].as_f64().map(|s| s * para["autoLeading"].as_f64().unwrap_or(1.2))
|
||||
} else {
|
||||
chars["leading"]["value"].as_f64()
|
||||
};
|
||||
let lead = lead.map(|v| (v * 100.0).round() / 100.0);
|
||||
let key = if auto { "cleadauto" } else { "clead" };
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"leading",
|
||||
&tr("Leading"),
|
||||
NumField::number(key, lead, " pt", 2).width(84.0).presets(SIZES).range(0.0, 5000.0).parens(auto),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"leading": {"kind": "points", "value": v}}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
// All Caps, Superscript, Underline / Small Caps, Subscript, Strikethrough.
|
||||
let caps = chars["capitalization"].as_str().unwrap_or("normal").to_string();
|
||||
let pos = chars["position"].as_str().unwrap_or("normal").to_string();
|
||||
let (ul, st) = (chars["underline"].as_bool().unwrap_or(false), chars["strikethrough"].as_bool().unwrap_or(false));
|
||||
let mut set = None;
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
if text_toggle(ui, "TT", false, caps == "allCaps", &tr("All Caps")) {
|
||||
set = Some(json!({"capitalization": if caps == "allCaps" { "normal" } else { "allCaps" }}));
|
||||
}
|
||||
if text_toggle(ui, "T¹", false, pos == "superscript", &tr("Superscript")) {
|
||||
set = Some(json!({"position": if pos == "superscript" { "normal" } else { "superscript" }}));
|
||||
}
|
||||
if text_toggle(ui, "T̲", false, ul, &tr("Underline")) {
|
||||
set = Some(json!({"underline": !ul}));
|
||||
}
|
||||
} else {
|
||||
if text_toggle(ui, "small-caps", false, caps == "smallCaps", &tr("Small Caps")) {
|
||||
set = Some(json!({"capitalization": if caps == "smallCaps" { "normal" } else { "smallCaps" }}));
|
||||
}
|
||||
if text_toggle(ui, "T₁", false, pos == "subscript", &tr("Subscript")) {
|
||||
set = Some(json!({"position": if pos == "subscript" { "normal" } else { "subscript" }}));
|
||||
}
|
||||
if text_toggle(ui, "T", true, st, &tr("Strikethrough")) {
|
||||
set = Some(json!({"strikethrough": !st}));
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(a) = set {
|
||||
let _ = app.run("type.char", json!({"attrs": a}));
|
||||
}
|
||||
vsep(ui);
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::hover());
|
||||
icons::paint(ui.painter(), r, "kerning", t.icon);
|
||||
resp.on_hover_text(tr("Kerning"));
|
||||
crate::panels::properties::kerning_control(app, ui, "ckern", &chars["kerning"], 92.0);
|
||||
} else {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"tracking",
|
||||
&tr("Tracking"),
|
||||
NumField::number("ctrack", chars["tracking"].as_f64(), "", 0).width(92.0).presets(TRACKING).step(5.0).range(-1000.0, 10000.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"tracking": v}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
const SCALES: &[f64] = &[25.0, 50.0, 75.0, 80.0, 90.0, 100.0, 110.0, 120.0, 125.0, 150.0, 200.0];
|
||||
let pct = |v: &serde_json::Value| v.as_f64().map(|s| (s * 1000.0).round() / 10.0);
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"vscale",
|
||||
&tr("Vertical Scale"),
|
||||
NumField::number("cvs", pct(&chars["vScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"vScale": v / 100.0}}));
|
||||
}
|
||||
} else {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"hscale",
|
||||
&tr("Horizontal Scale"),
|
||||
NumField::number("chs", pct(&chars["hScale"]), "%", 1).width(84.0).presets(SCALES).range(1.0, 1000.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"hScale": v / 100.0}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"baseline-shift",
|
||||
&tr("Baseline Shift"),
|
||||
NumField::number("cbs", chars["baselineShift"].as_f64(), " pt", 2).width(76.0).range(-5000.0, 5000.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"baselineShift": v}}));
|
||||
}
|
||||
} else {
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"skew",
|
||||
&tr("Skew (False Italic)"),
|
||||
NumField::number("csk", chars["skew"].as_f64(), "°", 1).width(76.0).range(-85.0, 85.0),
|
||||
) {
|
||||
let _ = app.run("type.char", json!({"attrs": {"skew": v}}));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
// The character style, then the language.
|
||||
let cs = attrs.as_ref().and_then(|a| a["characterStyle"].as_str()).unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string();
|
||||
let names: Vec<String> = app.session.active().map(|d| d.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default();
|
||||
let mut pick = None;
|
||||
egui::ComboBox::from_id_salt("ctl_char_style")
|
||||
.selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &cs)))
|
||||
.width(150.0)
|
||||
.show_ui(ui, |ui| {
|
||||
for n in &names {
|
||||
if ui.selectable_label(*n == cs, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() {
|
||||
pick = Some(n.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(n) = pick {
|
||||
let _ = app.run("style.character.apply", json!({"name": n}));
|
||||
}
|
||||
let lang = chars["language"].as_str().unwrap_or("English: USA").to_string();
|
||||
egui::ComboBox::from_id_salt("ctl_language").selected_text(crate::rtl::widget(ui, tr(&lang))).width(150.0).show_ui(ui, |ui| {
|
||||
egui::ScrollArea::vertical().max_height(320.0).show(ui, |ui| crate::panels::properties::language_menu(app, ui, &lang));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn control_paragraph(
|
||||
app: &mut DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
attrs: &Option<serde_json::Value>,
|
||||
para: &serde_json::Value,
|
||||
tr: &dyn Fn(&str) -> String,
|
||||
) {
|
||||
let cur: Align = serde_json::from_value(para["align"].clone()).unwrap_or_default();
|
||||
if let Some(a) = align_buttons(ui, cur, tr) {
|
||||
let _ = app.run("type.para", json!({"attrs": {"align": a}}));
|
||||
}
|
||||
vsep(ui);
|
||||
let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or(Unit::Picas);
|
||||
if let Some((k, v)) = indent_space_fields(ui, para, units, tr) {
|
||||
let _ = app.run("type.para", json!({"attrs": {k: v}}));
|
||||
}
|
||||
if let Some((k, v)) = drop_cap_fields(ui, para, tr) {
|
||||
let _ = app.run("type.para", json!({"attrs": {k: v.max(0.0) as u64}}));
|
||||
}
|
||||
vsep(ui);
|
||||
// Bulleted List over Numbered List, as InDesign's (a list with the default hanging indent, or,
|
||||
// pressed, none). Pressed when every selected paragraph has it, as Type › Apply Bullets shows.
|
||||
use designcraft_doc::ListType;
|
||||
let pressed = [ListType::Bullets, ListType::Numbers].map(|k| app.session.selection_has_list(k));
|
||||
let mut list = None;
|
||||
rows(ui, |ui, row| {
|
||||
let (icon, cmd, tip) = if row == 0 { ("bullets", "list.bullets", "Bulleted List") } else { ("numbering", "list.numbers", "Numbered List") };
|
||||
let on = pressed.get(row).copied().unwrap_or(false);
|
||||
if crate::widgets::icon_toggle(ui, icon, on, &tr(tip)).clicked() {
|
||||
list = Some((cmd, !on));
|
||||
}
|
||||
});
|
||||
if let Some((cmd, on)) = list {
|
||||
let _ = app.run(cmd, json!({"on": on}));
|
||||
}
|
||||
vsep(ui);
|
||||
let (shade, dialog) = shading_buttons(app, ui, para, tr);
|
||||
if let Some(attrs) = shade {
|
||||
let _ = app.run("type.para", json!({ "attrs": attrs }));
|
||||
}
|
||||
if let Some(tab) = dialog {
|
||||
let _ = app.run("app.paragraphBordersDialog", json!({ "tab": tab }));
|
||||
}
|
||||
vsep(ui);
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 3.0;
|
||||
let ps = attrs.as_ref().and_then(|a| a["paragraphStyle"].as_str()).unwrap_or("").to_string();
|
||||
let ov = attrs.as_ref().and_then(|a| a["paraOverrides"].as_u64()).unwrap_or(0)
|
||||
+ attrs.as_ref().and_then(|a| a["charOverrides"].as_u64()).unwrap_or(0);
|
||||
panels::para_style_picker(app, ui, &ps, ov > 0, 170.0);
|
||||
ui.horizontal(|ui| {
|
||||
let mut h = para["hyphenate"].as_bool().unwrap_or(true);
|
||||
if crate::widgets::checkbox(ui, &mut h, &tr("Hyphenate")).changed() {
|
||||
let _ = app.run("type.para", json!({"attrs": {"hyphenate": h}}));
|
||||
}
|
||||
// First Line Only is aligned too: the button shows it pressed, and a click turns alignment off.
|
||||
let grid = matches!(para["gridAlign"].as_str(), Some("allLines" | "firstLineOnly"));
|
||||
if crate::widgets::icon_toggle(ui, "grid-baseline", grid, &tr("Align to Baseline Grid")).clicked() {
|
||||
let _ = app.run("type.para", json!({"attrs": {"gridAlign": if grid { "none" } else { "allLines" }}}));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The paragraph alignment buttons, in two rows: the alignment picked this frame, if any.
|
||||
fn align_buttons(ui: &mut egui::Ui, cur: Align, tr: &dyn Fn(&str) -> String) -> Option<Align> {
|
||||
let mut align = None;
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
for (a, icon) in [
|
||||
(Align::Left, "palign-left"),
|
||||
(Align::Center, "palign-center"),
|
||||
(Align::Right, "palign-right"),
|
||||
(Align::TowardsSpine, "palign-spine-towards"),
|
||||
] {
|
||||
if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
|
||||
align = Some(a);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (a, icon) in [
|
||||
(Align::LeftJustified, "palign-justify-left"),
|
||||
(Align::CenterJustified, "palign-justify-center"),
|
||||
(Align::RightJustified, "palign-justify-right"),
|
||||
(Align::FullyJustified, "palign-justify-all"),
|
||||
(Align::AwayFromSpine, "palign-spine-away"),
|
||||
] {
|
||||
if icons::button(ui, icon, 20.0, cur == a, &tr(a.label())).clicked() {
|
||||
align = Some(a);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
align
|
||||
}
|
||||
|
||||
/// The indents and space before and after, two per column. Returns the one field changed this
|
||||
/// frame, with its paragraph attribute and value.
|
||||
fn indent_space_fields(ui: &mut egui::Ui, para: &serde_json::Value, units: Unit, tr: &dyn Fn(&str) -> String) -> Option<(&'static str, f64)> {
|
||||
let mut set = None;
|
||||
for ((ia, ta, ka), (ib, tb, kb)) in [
|
||||
(("indent-left", "Left Indent", "leftIndent"), ("indent-first", "First Line Left Indent", "firstLineIndent")),
|
||||
(("indent-right", "Right Indent", "rightIndent"), ("indent-last", "Last Line Right Indent", "lastLineIndent")),
|
||||
(("space-before", "Space Before", "spaceBefore"), ("space-after", "Space After", "spaceAfter")),
|
||||
] {
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
if let Some(v) = icon_field(ui, ia, &tr(ta), NumField::measure(ka, para[ka].as_f64(), units).width(84.0).range(0.0, 10_000.0)) {
|
||||
set = Some((ka, v));
|
||||
}
|
||||
} else {
|
||||
let min = crate::panels::properties::para_field_min(kb);
|
||||
if let Some(v) = icon_field(ui, ib, &tr(tb), NumField::measure(kb, para[kb].as_f64(), units).width(84.0).range(min, 10_000.0)) {
|
||||
set = Some((kb, v));
|
||||
}
|
||||
}
|
||||
});
|
||||
vsep(ui);
|
||||
}
|
||||
set
|
||||
}
|
||||
|
||||
/// The drop cap's line and character counts. Returns the one changed this frame, with its
|
||||
/// paragraph attribute and value.
|
||||
fn drop_cap_fields(ui: &mut egui::Ui, para: &serde_json::Value, tr: &dyn Fn(&str) -> String) -> Option<(&'static str, f64)> {
|
||||
let mut drop = None;
|
||||
rows(ui, |ui, row| {
|
||||
if row == 0 {
|
||||
let max = designcraft_compose::MAX_DROP_CAP_LINES as f64;
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"drop-lines",
|
||||
&tr("Drop Cap Number of Lines"),
|
||||
NumField::number("cdl", para["dropCapLines"].as_f64(), "", 0).width(64.0).range(0.0, max),
|
||||
) {
|
||||
drop = Some(("dropCapLines", v));
|
||||
}
|
||||
} else {
|
||||
let max = designcraft_compose::MAX_DROP_CAP_CHARS as f64;
|
||||
if let Some(v) = icon_field(
|
||||
ui,
|
||||
"drop-chars",
|
||||
&tr("Drop Cap One or More Characters"),
|
||||
NumField::number("cdc", para["dropCapChars"].as_f64(), "", 0).width(64.0).range(0.0, max),
|
||||
) {
|
||||
drop = Some(("dropCapChars", v));
|
||||
}
|
||||
}
|
||||
});
|
||||
drop
|
||||
}
|
||||
|
||||
/// Shading over Border: the on/off checkboxes, the colour swatches and the Paragraph Borders and
|
||||
/// Shading buttons. Returns the attributes to set and the dialog tab to open, if any.
|
||||
fn shading_buttons(
|
||||
app: &DesignApp,
|
||||
ui: &mut egui::Ui,
|
||||
para: &serde_json::Value,
|
||||
tr: &dyn Fn(&str) -> String,
|
||||
) -> (Option<serde_json::Value>, Option<&'static str>) {
|
||||
// On or off, the Paragraph Borders and Shading dialog on its tab, and the colour, as InDesign's.
|
||||
let doc = app.session.active().map(|d| d.doc.clone());
|
||||
let mut shade = None;
|
||||
let mut dialog = None;
|
||||
// Both checkboxes as wide as the wider label, so the icons and colours line up in columns.
|
||||
let check_w = ["Shading", "Border"]
|
||||
.iter()
|
||||
.map(|l| ui.fonts_mut(|f| f.layout_no_wrap(tr(l), egui::FontId::proportional(11.5), Color32::WHITE).size().x))
|
||||
.fold(0.0, f32::max)
|
||||
+ 28.0;
|
||||
rows(ui, |ui, row| {
|
||||
let (on_key, color_key, label, icon, tab) = if row == 0 {
|
||||
("shadingOn", "shadingColor", "Shading", "para-shading", "shading")
|
||||
} else {
|
||||
("borderOn", "borderColor", "Border", "para-border", "border")
|
||||
};
|
||||
let mut on = para[on_key].as_bool().unwrap_or(false);
|
||||
ui.allocate_ui_with_layout(vec2(check_w, crate::widgets::FIELD_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
ui.set_min_width(check_w);
|
||||
if crate::widgets::checkbox(ui, &mut on, &tr(label)).changed() {
|
||||
shade = Some(json!({ on_key: on }));
|
||||
}
|
||||
});
|
||||
if crate::widgets::icon_toggle(ui, icon, false, &tr("Paragraph Borders and Shading…")).clicked() {
|
||||
dialog = Some(tab);
|
||||
}
|
||||
if let Some(doc) = &doc {
|
||||
let cur = para[color_key].as_str().unwrap_or("[Black]").to_string();
|
||||
if let Some(w) = swatch_field(ui, doc, &cur, 96.0, &tr(if row == 0 { "Paragraph Shading" } else { "Paragraph Border" })) {
|
||||
shade = Some(json!({ color_key: w }));
|
||||
}
|
||||
}
|
||||
});
|
||||
(shade, dialog)
|
||||
}
|
||||
|
||||
/// A swatch field of the Control panel (Shading and Border colours): the swatch's chip and name,
|
||||
/// and the document's swatches in its menu. Returns the swatch picked.
|
||||
pub(crate) fn swatch_field(ui: &mut egui::Ui, doc: &designcraft_doc::Document, cur: &str, width: f32, label: &str) -> Option<String> {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(width, crate::widgets::FIELD_H), Sense::click());
|
||||
let resp = resp.on_hover_text(label);
|
||||
resp.widget_info(|| {
|
||||
let mut info = egui::WidgetInfo::labeled(egui::Role::ComboBox, true, label);
|
||||
info.current_text_value = Some(cur.to_string());
|
||||
info
|
||||
});
|
||||
let p = ui.painter();
|
||||
p.rect_filled(r, 0.0, if resp.hovered() { t.hover } else { t.input });
|
||||
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let seg = egui::Rect::from_min_max(egui::pos2(r.max.x - crate::widgets::SEG_W, r.min.y), r.max);
|
||||
p.line_segment([seg.left_top(), seg.left_bottom()], Stroke::new(1.0, t.field_border));
|
||||
icons::paint(p, egui::Rect::from_center_size(seg.center(), vec2(12.0, 12.0)), "chevron-down", t.icon);
|
||||
let (c, g) = crate::widgets::swatch_colors(doc, cur, 1.0);
|
||||
let chip = egui::Rect::from_min_size(egui::pos2(r.min.x + 4.0, r.center().y - 6.0), vec2(12.0, 12.0));
|
||||
crate::widgets::paint_chip(p, chip, c, g);
|
||||
let clip = egui::Rect::from_min_max(r.min, egui::pos2(seg.min.x - 2.0, r.max.y));
|
||||
p.with_clip_rect(clip).text(egui::pos2(chip.max.x + 5.0, r.center().y), egui::Align2::LEFT_CENTER, cur, egui::FontId::proportional(11.5), t.text);
|
||||
let mut picked = None;
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
picked = crate::panels::swatch_list(ui, doc, cur);
|
||||
});
|
||||
picked
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use super::*;
|
||||
|
||||
pub fn start_screen(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let r = ui.available_rect_before_wrap();
|
||||
ui.painter().rect_filled(r, 0.0, t.panel_darker);
|
||||
ui.scope_builder(
|
||||
egui::UiBuilder::new().max_rect(r.shrink(48.0)).layout(egui::Layout::top_down(if crate::i18n::is_rtl(&app.ui.language) {
|
||||
egui::Align::Max
|
||||
} else {
|
||||
egui::Align::Min
|
||||
})),
|
||||
|ui| {
|
||||
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Welcome to DesignCraft")).font(semibold(28.0)).color(t.text_strong),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page layout for print and screen — fast, open and scriptable."))
|
||||
.size(15.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(24.0);
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " New file ")).size(14.0).color(Color32::WHITE),
|
||||
))
|
||||
.fill(t.accent_strong)
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.newDocumentDialog", json!({}));
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open ")).size(14.0)))
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("app.openDialog", json!({}));
|
||||
}
|
||||
if ui
|
||||
.add(
|
||||
egui::Button::new(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, " Open sample magazine ")).size(14.0),
|
||||
))
|
||||
.corner_radius(16.0)
|
||||
.min_size(vec2(0.0, 32.0)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
let _ = app.run("file.newSample", json!({}));
|
||||
}
|
||||
});
|
||||
ui.add_space(20.0);
|
||||
community_card(app, ui);
|
||||
recent_files(app, ui);
|
||||
ui.add_space(24.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Start a new document")).font(semibold(15.0)).color(t.text_strong),
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in designcraft_doc::build::PRESETS.iter().filter(|p| p.intent == designcraft_doc::Intent::Print) {
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(120.0, 150.0), Sense::click());
|
||||
let hov = resp.hovered();
|
||||
ui.painter().rect_filled(cr, 6.0, if hov { t.hover } else { t.panel });
|
||||
let k = (80.0 / p.width.max(p.height)) as f32;
|
||||
let pr = egui::Rect::from_center_size(cr.center() - vec2(0.0, 14.0), vec2(p.width as f32 * k, p.height as f32 * k));
|
||||
ui.painter().rect_filled(pr, 0.0, Color32::from_gray(245));
|
||||
ui.painter().text(
|
||||
egui::pos2(cr.center().x, cr.max.y - 26.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
p.name,
|
||||
semibold(12.0),
|
||||
t.text_strong,
|
||||
);
|
||||
ui.painter().text(
|
||||
egui::pos2(cr.center().x, cr.max.y - 11.0),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
format!(
|
||||
"{} × {}",
|
||||
designcraft_geom::format_measure(p.width, p.units),
|
||||
designcraft_geom::format_measure(p.height, p.units)
|
||||
),
|
||||
egui::FontId::proportional(10.0),
|
||||
t.text_dim,
|
||||
);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"preset": p.name}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
let _ = Content::Unassigned;
|
||||
}
|
||||
|
||||
/// Recent files shown on the start screen (File › Open Recent has them all).
|
||||
const START_SCREEN_RECENT: usize = 8;
|
||||
|
||||
/// Start screen: the most recent files as cards with the first page's picture (saved in the file)
|
||||
/// and the name; the full path is the tooltip. A file that is gone is dimmed and says so when
|
||||
/// clicked. Nothing when the list is empty.
|
||||
fn recent_files(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
if app.session.prefs.recent_files.is_empty() {
|
||||
return;
|
||||
}
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.add_space(24.0);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Recent files")).font(semibold(15.0)).color(t.text_strong),
|
||||
));
|
||||
ui.add_space(8.0);
|
||||
let rows: Vec<(usize, String)> = app.session.prefs.recent_files.iter().take(START_SCREEN_RECENT).cloned().enumerate().collect();
|
||||
let mut open = None;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for (i, path) in rows {
|
||||
let stat = recent_file_stat(ui.ctx(), &path);
|
||||
let there = stat.exists;
|
||||
let modified = there.then_some(stat.modified);
|
||||
let (cr, resp) = ui.allocate_exact_size(vec2(140.0, 170.0), Sense::click());
|
||||
let resp = resp.on_hover_text(&path);
|
||||
ui.painter().rect_filled(cr, 6.0, if resp.hovered() { t.hover } else { t.panel });
|
||||
let area = egui::Rect::from_min_size(cr.min + vec2(20.0, 12.0), vec2(100.0, 108.0));
|
||||
match modified.and_then(|m| recent_preview_texture(ui.ctx(), &path, m)) {
|
||||
Some(tex) => {
|
||||
let size = tex.size_vec2();
|
||||
let k = (area.width() / size.x).min(area.height() / size.y);
|
||||
let pr = egui::Rect::from_center_size(area.center(), size * k);
|
||||
ui.painter().image(tex.id(), pr, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
ui.painter().rect_stroke(pr, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
}
|
||||
// No picture (saved before files had one) or the file is gone: a blank page.
|
||||
None => {
|
||||
let pr = egui::Rect::from_center_size(area.center(), vec2(62.0, 80.0));
|
||||
ui.painter().rect_filled(pr, 0.0, if there { Color32::from_gray(245) } else { Color32::from_gray(110) });
|
||||
}
|
||||
}
|
||||
let color = if there { t.text_strong } else { t.text_dim };
|
||||
// The name without `.designcraft`, on up to two lines (broken after a space or dash).
|
||||
let label = crate::menus::recent_label(&path);
|
||||
let name = label.strip_suffix(".designcraft").unwrap_or(&label).to_string();
|
||||
let mut job = egui::text::LayoutJob::simple(name, semibold(12.0), color, cr.width() - 12.0);
|
||||
job.wrap.max_rows = 2;
|
||||
job.halign = egui::Align::Center;
|
||||
let galley = ui.painter().layout_job(job);
|
||||
ui.painter().galley(egui::pos2(cr.center().x, area.max.y + 8.0), galley, color);
|
||||
if resp.clicked() {
|
||||
open = Some(i);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(i) = open {
|
||||
let _ = app.run("app.openRecent", json!({"index": i}));
|
||||
}
|
||||
}
|
||||
|
||||
/// How often a recent file is looked up on disk again, in ms: the start screen and the Open Recent
|
||||
/// menu draw every frame and must not touch the file system each time.
|
||||
pub(super) const RECENT_STAT_MS: f64 = 3000.0;
|
||||
|
||||
/// A recent file as last seen on disk.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct RecentStat {
|
||||
pub exists: bool,
|
||||
pub modified: Option<std::time::SystemTime>,
|
||||
checked: f64,
|
||||
}
|
||||
|
||||
/// Whether recent file `path` is there and when it last changed, looked up again at most every
|
||||
/// [`RECENT_STAT_MS`].
|
||||
pub(crate) fn recent_file_stat(ctx: &egui::Context, path: &str) -> RecentStat {
|
||||
recent_file_stat_at(ctx, path, crate::now_ms())
|
||||
}
|
||||
|
||||
pub(super) fn recent_file_stat_at(ctx: &egui::Context, path: &str, now: f64) -> RecentStat {
|
||||
let key = egui::Id::unique(("recent_stat", path));
|
||||
if let Some(st) = ctx.data(|d| d.get_temp::<RecentStat>(key))
|
||||
&& (0.0..RECENT_STAT_MS).contains(&(now - st.checked))
|
||||
{
|
||||
return st;
|
||||
}
|
||||
let meta = designcraft_engine::io::metadata(path).ok().filter(designcraft_engine::io::Metadata::is_file);
|
||||
let st = RecentStat { exists: meta.is_some(), modified: meta.and_then(|m| m.modified().ok()), checked: now };
|
||||
ctx.data_mut(|d| d.insert_temp(key, st.clone()));
|
||||
st
|
||||
}
|
||||
|
||||
/// The texture of a recent file's saved first-page picture, decoded once per file version.
|
||||
fn recent_preview_texture(ctx: &egui::Context, path: &str, modified: Option<std::time::SystemTime>) -> Option<egui::TextureHandle> {
|
||||
type Cached = (Option<std::time::SystemTime>, Option<egui::TextureHandle>);
|
||||
let key = egui::Id::unique(("recent_preview", path));
|
||||
if let Some((m, tex)) = ctx.data(|d| d.get_temp::<Cached>(key))
|
||||
&& m == modified
|
||||
{
|
||||
return tex;
|
||||
}
|
||||
let tex = designcraft_engine::cmd::recent_preview(path).and_then(|p| {
|
||||
let size = [usize::try_from(p.width).ok()?, usize::try_from(p.height).ok()?];
|
||||
(size[0].checked_mul(size[1])?.checked_mul(4)? == p.rgba.len()).then(|| {
|
||||
ctx.load_texture(format!("recent_preview:{path}"), egui::ColorImage::from_rgba_unmultiplied(size, &p.rgba), egui::TextureOptions::LINEAR)
|
||||
})
|
||||
});
|
||||
ctx.data_mut(|d| d.insert_temp::<Cached>(key, (modified, tex.clone())));
|
||||
tex
|
||||
}
|
||||
|
||||
/// Start screen: the ArtCraft community (Discord first) and project links.
|
||||
fn community_card(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Frame::NONE.fill(t.panel).corner_radius(10.0).inner_margin(egui::Margin::same(14)).show(ui, |ui| {
|
||||
// Wide enough for the longer translations of the line under the title, with the button
|
||||
// at the right edge; the text wraps before the button's space rather than run under it.
|
||||
const BUTTON: egui::Vec2 = vec2(190.0, 38.0);
|
||||
ui.set_max_width(760.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(44.0, 44.0), Sense::hover());
|
||||
crate::about::paint_mark(ui, r, crate::about::BRAND);
|
||||
ui.add_space(8.0);
|
||||
let label = crate::i18n::tr(&app.ui.language, "Join our Discord");
|
||||
let button = crate::about::discord_button_size(ui.ctx(), label, BUTTON);
|
||||
let text_width = (ui.available_width() - button.x - 16.0).max(120.0);
|
||||
ui.vertical(|ui| {
|
||||
ui.set_max_width(text_width);
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Join the ArtCraft community")).font(semibold(14.0)).color(t.text_strong),
|
||||
));
|
||||
ui.label(crate::rtl::widget(
|
||||
ui,
|
||||
egui::RichText::new(crate::i18n::tr(&app.ui.language, "Get help, share your layouts and shape what we build next."))
|
||||
.size(12.0)
|
||||
.color(t.text_dim),
|
||||
));
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (text, id) in [("DesignCraft page", "help.appPage"), ("GitHub", "help.github"), ("getartcraft.com", "help.website")] {
|
||||
let url = designcraft_engine::links::get(&id[5..]).unwrap_or_default();
|
||||
let l = ui
|
||||
.add(egui::Label::new(egui::RichText::new(text).size(12.0).color(t.accent).underline()).sense(Sense::click()))
|
||||
.on_hover_text(url)
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||||
if l.clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if crate::about::discord_button(ui, label, BUTTON) {
|
||||
let _ = app.run("help.discord", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use super::*;
|
||||
|
||||
/// The widest a document tab grows: a longer name is shortened so its close button and the
|
||||
/// tabs after it stay in view.
|
||||
pub(super) const DOC_TAB_MAX_W: f32 = 260.0;
|
||||
|
||||
/// Close button and padding around a tab's title.
|
||||
const DOC_TAB_PAD: f32 = 44.0;
|
||||
|
||||
/// Draw bounded document tabs with active, dirty, preview and zoom state, plus activation and close actions.
|
||||
pub fn doc_tabs(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (bar, _) = ui.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::hover());
|
||||
let max_w = DOC_TAB_MAX_W.min(bar.width());
|
||||
let close_label = crate::i18n::tr(&app.ui.language, "Close");
|
||||
ui.painter().rect_filled(bar, 0.0, t.tab_strip);
|
||||
ui.painter().line_segment([bar.left_bottom(), bar.right_bottom()], Stroke::new(1.0, t.border));
|
||||
let mut x = bar.min.x;
|
||||
let active = app.session.active_index();
|
||||
let zoom = app.view().map(|v| v.zoom).unwrap_or(1.0);
|
||||
let mut activate = None;
|
||||
let mut close = None;
|
||||
let titles: Vec<(String, bool)> = app.session.documents().iter().map(|d| (d.title(), d.is_dirty())).collect();
|
||||
for (i, (title, dirty)) in titles.iter().enumerate() {
|
||||
let is_active = Some(i) == active;
|
||||
// `*` = unsaved; the view-mode suffix only in Preview (InDesign shows its GPU mode there).
|
||||
let suffix = if is_active && app.ui.screen_mode == crate::ScreenMode::Preview { " [Preview]" } else { "" };
|
||||
let color = if is_active { t.text_strong } else { t.text_dim };
|
||||
// The name gives way first; the dirty marker and zoom stay readable.
|
||||
let zoom_label = crate::rtl::plain(ui.ctx(), &format!(" @ {:.0}%{suffix}", zoom * 100.0), semibold(11.5), color);
|
||||
let name = format!("{}{}", if *dirty { "*" } else { "" }, crate::rtl::isolate(title));
|
||||
let name_max = max_w - DOC_TAB_PAD - zoom_label.size().x;
|
||||
let galley = crate::rtl::truncated(ui.ctx(), &name, semibold(11.5), color, name_max);
|
||||
let elided = galley.elided;
|
||||
let w = (galley.size().x + zoom_label.size().x + DOC_TAB_PAD).max(if is_active { 210.0 } else { 150.0 }).min(max_w);
|
||||
let r = egui::Rect::from_min_size(egui::pos2(x, bar.min.y), vec2(w, 28.0));
|
||||
let resp = ui.interact(r, ui.scope_id().with(("doctab", i)), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, title));
|
||||
let resp = if elided {
|
||||
resp.on_hover_ui(|ui| {
|
||||
let _ = crate::rtl::label(ui, title);
|
||||
})
|
||||
} else {
|
||||
resp
|
||||
};
|
||||
ui.painter().rect_filled(r, 0.0, if is_active { t.panel } else { t.tab_strip });
|
||||
let cr = egui::Rect::from_center_size(egui::pos2(r.min.x + 18.0, r.center().y), vec2(14.0, 14.0));
|
||||
let cresp = ui.interact(cr, ui.scope_id().with(("docclose", i)), Sense::click());
|
||||
cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, format!("{close_label} {title}")));
|
||||
ui.painter().text(
|
||||
cr.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"×",
|
||||
egui::FontId::proportional(14.0),
|
||||
if cresp.hovered() { t.text_strong } else { t.text_dim },
|
||||
);
|
||||
let name_w = galley.size().x;
|
||||
// Clipped too, for a window narrower than the zoom label.
|
||||
let text = ui.painter().with_clip_rect(r);
|
||||
text.galley(egui::pos2(r.min.x + 32.0, r.center().y - galley.size().y / 2.0), galley, Color32::WHITE);
|
||||
text.galley(egui::pos2(r.min.x + 32.0 + name_w, r.center().y - zoom_label.size().y / 2.0), zoom_label, Color32::WHITE);
|
||||
ui.painter().line_segment([r.right_top(), r.right_bottom()], Stroke::new(1.0, t.border));
|
||||
if cresp.clicked() {
|
||||
close = Some(i);
|
||||
} else if resp.clicked() {
|
||||
activate = Some(i);
|
||||
}
|
||||
x += w;
|
||||
}
|
||||
if let Some(i) = close {
|
||||
crate::menus::close_document(app, Some(i));
|
||||
} else if let Some(i) = activate {
|
||||
let _ = app.run("file.activate", json!({"index": i}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the Preflight panel. An open Preflight flyout stays: `window.panel` would close it.
|
||||
fn show_preflight(app: &mut DesignApp) {
|
||||
if app.ui.docking_flyout.as_deref() != Some("preflight")
|
||||
&& let Err(e) = app.run("window.panel", json!({"panel": "preflight"}))
|
||||
{
|
||||
app.status(e);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = egui::Panel::bottom("status_bar")
|
||||
.exact_size(17.0)
|
||||
.frame(egui::Frame::NONE.fill(t.panel).inner_margin(egui::Margin::symmetric(8, 0)))
|
||||
.show(ui, |ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
ui.horizontal_centered(|ui| {
|
||||
let small = egui::FontId::proportional(10.5);
|
||||
if app.session.active().is_none() {
|
||||
ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim));
|
||||
return;
|
||||
}
|
||||
let z = app.view().map(|v| v.zoom).unwrap_or(1.0);
|
||||
crate::menus::menu_button(ui, egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| {
|
||||
for p in [0.05, 0.125, 0.25, 0.5, 0.75, 1.0, 2.0, 4.0, 8.0, 16.0] {
|
||||
if ui.button(format!("{}%", p * 100.0)).clicked() {
|
||||
let _ = app.run("view.zoom", json!({"zoom": p}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
let n = app.session.active().map(|d| d.doc.page_count()).unwrap_or(1);
|
||||
let cur = crate::canvas::current_page(app).unwrap_or(0);
|
||||
if icons::button(ui, "first", 14.0, false, crate::i18n::tr(&app.ui.language, "First Spread")).clicked() {
|
||||
crate::canvas::go_to_page(app, 0);
|
||||
}
|
||||
if icons::button(ui, "prev", 14.0, false, crate::i18n::tr(&app.ui.language, "Previous Spread")).clicked() {
|
||||
crate::canvas::go_to_page(app, cur.saturating_sub(1));
|
||||
}
|
||||
let name = app.session.page_label(cur);
|
||||
let (fr, fresp) = ui.allocate_exact_size(vec2(80.0, 14.0), Sense::click());
|
||||
ui.painter().rect(fr, 0.0, t.input, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
ui.painter().text(fr.left_center() + vec2(5.0, 0.0), egui::Align2::LEFT_CENTER, &name, small.clone(), t.text);
|
||||
ui.painter().text(fr.right_center() - vec2(5.0, 0.0), egui::Align2::RIGHT_CENTER, "▾", small.clone(), t.text);
|
||||
egui::Popup::menu(&fresp).show(|ui| {
|
||||
let names: Vec<String> = (0..n).map(|i| app.session.page_label(i)).collect();
|
||||
egui::ScrollArea::vertical().max_height(300.0).show(ui, |ui| {
|
||||
for (i, nm) in names.iter().enumerate() {
|
||||
if ui.button(nm).clicked() {
|
||||
crate::canvas::go_to_page(app, i);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if icons::button(ui, "next", 14.0, false, crate::i18n::tr(&app.ui.language, "Next Spread")).clicked() {
|
||||
crate::canvas::go_to_page(app, (cur + 1).min(n - 1));
|
||||
}
|
||||
if icons::button(ui, "last", 14.0, false, crate::i18n::tr(&app.ui.language, "Last Spread")).clicked() {
|
||||
crate::canvas::go_to_page(app, n - 1);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "[Basic] (working) ▾")).font(small.clone()));
|
||||
ui.add_space(8.0);
|
||||
// Preflight: overset text is an error.
|
||||
let errors = panels::preflight_errors(app);
|
||||
let (r, dot) = ui.allocate_exact_size(vec2(9.0, 9.0), Sense::click());
|
||||
ui.painter().circle_filled(
|
||||
r.center(),
|
||||
4.0,
|
||||
if errors == 0 { Color32::from_rgb(60, 200, 90) } else { Color32::from_rgb(235, 50, 50) },
|
||||
);
|
||||
let count = if errors == 0 {
|
||||
format!("{} ▾", crate::i18n::tr(&app.ui.language, "No errors"))
|
||||
} else {
|
||||
format!(
|
||||
"{} ▾",
|
||||
crate::i18n::count_label(
|
||||
&app.ui.language,
|
||||
if errors == 1 && !crate::i18n::category_first(&app.ui.language) { "error" } else { "errors" },
|
||||
errors
|
||||
)
|
||||
)
|
||||
};
|
||||
// As in InDesign, the count opens a menu that leads to the Preflight panel.
|
||||
let menu = crate::menus::menu_button(ui, egui::RichText::new(count).font(small.clone()), |ui| {
|
||||
if ui.button(crate::i18n::tr(&app.ui.language, "Preflight Panel…")).clicked() {
|
||||
show_preflight(app);
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
// Double-clicking the light or the count shows the panel straight away (the
|
||||
// second click has closed the menu the first one opened).
|
||||
if dot.double_clicked() || menu.response.double_clicked() {
|
||||
show_preflight(app);
|
||||
}
|
||||
ui.add_space(12.0);
|
||||
ui.label(egui::RichText::new(&app.ui.status).font(small).color(t.text_dim));
|
||||
});
|
||||
});
|
||||
let r = resp.response.rect;
|
||||
ui.painter().line_segment([r.left_top(), r.right_top()], Stroke::new(1.0, t.border));
|
||||
}
|
||||
Reference in New Issue
Block a user