mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-28 20:37:10 -06:00
M0.8: Word-style egui UI — ribbon (all tabs), page canvas, rulers, panes, dialogs, Backstage, control channel; desktop app
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b8dd46ccb5
commit
98bbfbed4e
Generated
+3868
-23
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,7 +1,7 @@
|
||||
[workspace]
|
||||
resolver = "3"
|
||||
members = ["crates/*", "xtask"]
|
||||
default-members = ["crates/*", "xtask"]
|
||||
members = ["crates/*", "apps/*", "xtask"]
|
||||
default-members = ["crates/*", "apps/wordcraft", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "wordcraft"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
default-run = "wordcraft"
|
||||
description = "WordCraft desktop app"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
wordcraft-ui-egui = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
rfd = { workspace = true }
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Loopback JSON-lines control server: one request per line, one reply per line.
|
||||
//! This is the transport the MCP server (`wordcraft mcp --connect`) wraps.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
use wordcraft_ui_egui::ControlRequest;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = channel::<ControlRequest>();
|
||||
let listener = match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("wordcraft: control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
return rx;
|
||||
}
|
||||
};
|
||||
eprintln!("wordcraft: control server listening on 127.0.0.1:{port}");
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || serve(stream, tx, ctx));
|
||||
}
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: Sender<ControlRequest>, ctx: egui::Context) {
|
||||
let Ok(read) = stream.try_clone() else { return };
|
||||
let mut out = stream;
|
||||
for line in BufReader::new(read).lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let reply = match serde_json::from_str::<Value>(&line) {
|
||||
Ok(msg) => {
|
||||
let id = msg.get("id").cloned().unwrap_or(Value::Null);
|
||||
let method = msg.get("method").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let params = msg.get("params").cloned().unwrap_or(json!({}));
|
||||
let (req, rrx) = ControlRequest::new(method, params);
|
||||
if tx.send(req).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
let mut r = rrx.recv_timeout(Duration::from_secs(60)).unwrap_or_else(|_| json!({"ok": false, "error": "timeout"}));
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
r
|
||||
}
|
||||
Err(e) => json!({"ok": false, "error": format!("bad JSON: {e}")}),
|
||||
};
|
||||
if writeln!(out, "{reply}").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! WordCraft desktop app.
|
||||
//!
|
||||
//! Usage: `wordcraft [--control <port>] [--sample] [files…]`
|
||||
//!
|
||||
//! `--control <port>` (or `WORDCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server:
|
||||
//! `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hi"}}}`.
|
||||
//! See `docs/control-protocol.md`.
|
||||
#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod control_server;
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{Services, UiState, WordApp};
|
||||
|
||||
struct App(WordApp);
|
||||
|
||||
impl eframe::App for App {
|
||||
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
self.0.logic(ctx);
|
||||
if self.0.quit_requested {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
|
||||
self.0.raw_input_hook(raw);
|
||||
}
|
||||
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
|
||||
self.0.ui(ui);
|
||||
}
|
||||
fn on_exit(&mut self) {
|
||||
save_prefs(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn prefs_path() -> Option<std::path::PathBuf> {
|
||||
let base = if cfg!(target_os = "macos") {
|
||||
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join("Library/Application Support/WordCraft"))
|
||||
} else if cfg!(windows) {
|
||||
std::env::var_os("APPDATA").map(|a| std::path::PathBuf::from(a).join("WordCraft"))
|
||||
} else {
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.map(|c| c.join("wordcraft"))
|
||||
};
|
||||
base.map(|b| b.join("ui.json"))
|
||||
}
|
||||
|
||||
fn load_prefs(app: &mut WordApp) {
|
||||
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path()
|
||||
&& let Ok(bytes) = std::fs::read(&p)
|
||||
&& let Ok(ui) = serde_json::from_slice::<UiState>(&bytes)
|
||||
{
|
||||
app.ui = ui;
|
||||
app.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn save_prefs(app: &WordApp) {
|
||||
if std::env::var_os("WORDCRAFT_NO_PREFS").is_some() {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = prefs_path() {
|
||||
if let Some(dir) = p.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&app.ui) {
|
||||
let _ = std::fs::write(&p, bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn services() -> Services {
|
||||
Services {
|
||||
pick_open: Some(Box::new(|purpose: &str| {
|
||||
let d = rfd::FileDialog::new();
|
||||
let d = if purpose == "picture" {
|
||||
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
|
||||
} else {
|
||||
d.add_filter("Documents", &["docx", "docm", "dotx", "odt", "rtf", "txt", "md", "html", "htm", "json"])
|
||||
.add_filter("Word document", &["docx"])
|
||||
.add_filter("All files", &["*"])
|
||||
};
|
||||
d.pick_file().map(|p| p.to_string_lossy().to_string())
|
||||
})),
|
||||
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> eframe::Result {
|
||||
let mut control_port: Option<u16> = std::env::var("WORDCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
|
||||
let mut files = Vec::new();
|
||||
let mut sample = false;
|
||||
let mut args = std::env::args().skip(1);
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--control" => control_port = args.next().and_then(|p| p.parse().ok()),
|
||||
"--sample" => sample = true,
|
||||
"--version" => {
|
||||
println!("wordcraft {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
_ => files.push(a),
|
||||
}
|
||||
}
|
||||
let options = eframe::NativeOptions {
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("WordCraft")
|
||||
.with_app_id("ai.storyteller.wordcraft")
|
||||
.with_inner_size([1440.0, 920.0])
|
||||
.with_min_inner_size([760.0, 480.0])
|
||||
.with_drag_and_drop(true)
|
||||
.with_fullsize_content_view(true)
|
||||
.with_titlebar_shown(false)
|
||||
.with_title_shown(false),
|
||||
..Default::default()
|
||||
};
|
||||
eframe::run_native(
|
||||
"WordCraft",
|
||||
options,
|
||||
Box::new(move |cc| {
|
||||
let doc = if sample { wordcraft_engine::sample::sample_document() } else { wordcraft_doc::Document::new() };
|
||||
let mut app = WordApp::new(Session::new(doc), services());
|
||||
load_prefs(&mut app);
|
||||
app.integrated_titlebar = cfg!(target_os = "macos");
|
||||
if let Some(port) = control_port {
|
||||
let rx = control_server::start(port, cc.egui_ctx.clone());
|
||||
app = app.with_control(rx);
|
||||
}
|
||||
for f in files {
|
||||
if let Err(e) = app.run("file.open", serde_json::json!({"path": f})) {
|
||||
eprintln!("wordcraft: {f}: {e}");
|
||||
}
|
||||
}
|
||||
Ok(Box::new(App(app)))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -11,7 +11,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
CommandSpec::new("file.new", "New", "File", new).key("Mod+N").params(r#"{"template"?: "blank|sample|letter|resume|report"}"#).pure(),
|
||||
CommandSpec::new("file.open", "Open", "File", open).key("Mod+O").params(r#"{"path": string}"#).pure(),
|
||||
CommandSpec::new("file.save", "Save", "File", save).key("Mod+S").params(r#"{"path"?: string}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12 / Mod+Shift+S").params(r#"{"path": string}"#).pure(),
|
||||
CommandSpec::new("file.saveAs", "Save As", "File", save_as).key("F12").params(r#"{"path": string}"#).pure(),
|
||||
CommandSpec::new("file.exportPdf", "Export PDF", "File › Export", |s, v| {
|
||||
let path = p::req_str(v, "path")?;
|
||||
let path = if path.to_ascii_lowercase().ends_with(".pdf") { path.to_string() } else { format!("{path}.pdf") };
|
||||
|
||||
@@ -41,13 +41,11 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 + 0.1).min(5.0);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
.key("Mod+Shift+=")
|
||||
.pure(),
|
||||
CommandSpec::new("view.zoomOut", "Zoom Out", "Status Bar", |s, _| {
|
||||
let z = ((s.view.zoom * 10.0).round() / 10.0 - 0.1).max(0.1);
|
||||
zoom(s, &json!({"value": z * 100.0}))
|
||||
})
|
||||
.key("Mod+-")
|
||||
.pure(),
|
||||
CommandSpec::new("view.onePage", "One Page", "View › Zoom", |s, _| zoom(s, &json!({"value": "onePage"}))).pure(),
|
||||
CommandSpec::new("view.multiplePages", "Multiple Pages", "View › Zoom", |s, _| zoom(s, &json!({"value": "multiplePages"}))).pure(),
|
||||
|
||||
@@ -571,7 +571,16 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let fits = x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
// Text after a right/centre tab grows leftwards into the tab's space first.
|
||||
let absorbs = pending_tab.is_some_and(|(tj, stop, _)| {
|
||||
let room = pl.clusters.get(tj).map(|t| t.adv).unwrap_or(0.0);
|
||||
match stop.align {
|
||||
TabAlign::Right | TabAlign::Decimal => room >= c.adv,
|
||||
TabAlign::Center => room >= c.adv / 2.0 && x + c.adv / 2.0 <= right_edge + 0.01,
|
||||
_ => false,
|
||||
}
|
||||
});
|
||||
let fits = absorbs || x + c.adv <= right_edge + 0.01 || c.kind == ClKind::Space || c.kind == ClKind::Marker;
|
||||
if !fits && j > c0 {
|
||||
// Wrap: back up to the last break opportunity on this line.
|
||||
end = LineEnd::Wrap;
|
||||
@@ -632,6 +641,10 @@ fn break_lines(pl: &mut ParaLayout, env: &ParaEnv, mark_style: u16, suffix: Opti
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
if end == LineEnd::Para || end == LineEnd::Wrap {
|
||||
let xc = x;
|
||||
resolve_tab(pl, &mut pending_tab, &mut xs, c0, xc, &mut x);
|
||||
}
|
||||
if j >= n && end != LineEnd::Wrap && !matches!(end, LineEnd::LineBreak | LineEnd::PageBreak | LineEnd::ColumnBreak) {
|
||||
end = LineEnd::Para;
|
||||
}
|
||||
@@ -795,8 +808,27 @@ fn pl_text_at(c: &Cluster) -> bool {
|
||||
c.dot
|
||||
}
|
||||
|
||||
fn resolve_tab(_pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, _xs: &mut [f32], _c0: usize, _x: f32, _xo: &mut f32) {
|
||||
*pending = None;
|
||||
fn resolve_tab(pl: &mut ParaLayout, pending: &mut Option<(usize, TabStop, f32)>, xs: &mut [f32], c0: usize, x: f32, xo: &mut f32) {
|
||||
// Finish a centre/right/decimal tab: its text (possibly empty) ends at `x`.
|
||||
let Some((tj, stop, tx)) = pending.take() else { return };
|
||||
let seg = x - tx - pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
|
||||
let want = match stop.align {
|
||||
TabAlign::Right | TabAlign::Decimal => stop.pos - tx - seg,
|
||||
TabAlign::Center => stop.pos - tx - seg / 2.0,
|
||||
_ => return,
|
||||
}
|
||||
.max(0.0);
|
||||
let old = pl.clusters.get(tj).map(|c| c.adv).unwrap_or(0.0);
|
||||
let delta = want - old;
|
||||
if delta.abs() > 0.001 {
|
||||
if let Some(c) = pl.clusters.get_mut(tj) {
|
||||
c.adv = want;
|
||||
}
|
||||
for v in xs.iter_mut().skip(tj + 1 - c0) {
|
||||
*v += delta;
|
||||
}
|
||||
*xo += delta;
|
||||
}
|
||||
}
|
||||
|
||||
impl ParaLayout {
|
||||
|
||||
@@ -226,3 +226,12 @@ fn layout_is_fast() {
|
||||
eprintln!("3000 paragraphs, {} pages: cold {cold:.1} ms, warm {warm:.1} ms", l.pages.len());
|
||||
assert!(warm < cold);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_center_tab_then_right_tab() {
|
||||
let mut d = Document::from_text("left\t\tright");
|
||||
d.format_paragraphs(&Pos::body(0, 0), &Pos::body(0, 0), &|p| p.style = Some("Header".into())).unwrap();
|
||||
let l = lay(&d);
|
||||
let end = l.caret(&Pos::body(0, 11)).unwrap().x;
|
||||
assert!((end - (72.0 + 468.0)).abs() < 1.0, "{end}");
|
||||
}
|
||||
|
||||
@@ -436,3 +436,12 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the page area (`x`, `y`, `w`×`h` points) at `scale` px/pt — for previews and thumbnails.
|
||||
pub fn render_area(doc: &Document, page: &Page, x: f32, y: f32, w: f32, h: f32, scale: f32, opts: &RenderOptions) -> Rendered {
|
||||
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
|
||||
let pw = ((w * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let ph = ((h * scale).ceil() as u32).clamp(1, MAX_SIDE);
|
||||
let view = Affine::scale(scale as f64) * Affine::translate((-x as f64, -y as f64));
|
||||
render_region(doc, page, pw, ph, view, opts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "wordcraft-ui-egui"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "WordCraft egui front end (Word-style ribbon UI); swappable: nothing below depends on it"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
wordcraft-geom = { workspace = true }
|
||||
wordcraft-doc = { workspace = true }
|
||||
wordcraft-fonts = { workspace = true }
|
||||
wordcraft-layout = { workspace = true }
|
||||
wordcraft-render = { workspace = true }
|
||||
wordcraft-engine = { workspace = true }
|
||||
egui = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,84 @@
|
||||
//! Headless UI screenshots: renders the whole WordCraft window offscreen (wgpu), so it works
|
||||
//! with a locked screen or a hidden window.
|
||||
//!
|
||||
//! `cargo run -p wordcraft-ui-egui --example ui_shot -- script.jsonl`
|
||||
//!
|
||||
//! The script is JSON lines: control-channel requests (`{"method": …, "params": …}`, see
|
||||
//! `docs/control-protocol.md`), `{"shot": "/abs/out.png"}` to save the window as PNG, or
|
||||
//! `{"steps": n}` to run extra frames. The window is 1440×900 pt at 2× and opens the sample
|
||||
//! document unless the first line is `{"empty": true}`.
|
||||
|
||||
use wordcraft_engine::Session;
|
||||
use wordcraft_ui_egui::{ControlRequest, Services, WordApp};
|
||||
|
||||
static READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args().nth(1).expect("usage: ui_shot script.jsonl");
|
||||
let script = std::fs::read_to_string(&path).expect("read script");
|
||||
let (tx, rx) = std::sync::mpsc::channel::<ControlRequest>();
|
||||
let lines: Vec<serde_json::Value> = script.lines().filter(|l| !l.trim().is_empty()).map(|l| serde_json::from_str(l).expect("json line")).collect();
|
||||
let doc = if lines.first().is_some_and(|l| l.get("empty").is_some()) { wordcraft_doc::Document::new() } else { wordcraft_engine::sample::sample_document() };
|
||||
let mut app = WordApp::new(Session::new(doc), Services::default()).with_control(rx);
|
||||
app.integrated_titlebar = true;
|
||||
let w = lines.iter().find_map(|l| l.get("width").and_then(|v| v.as_f64())).unwrap_or(1440.0) as f32;
|
||||
let h = lines.iter().find_map(|l| l.get("height").and_then(|v| v.as_f64())).unwrap_or(900.0) as f32;
|
||||
let mut harness = egui_kittest::Harness::builder()
|
||||
.with_size(egui::vec2(w, h))
|
||||
.with_pixels_per_point(2.0)
|
||||
.with_max_steps(1_000_000)
|
||||
.wgpu()
|
||||
.build_ui_state(
|
||||
|ui, app: &mut WordApp| {
|
||||
if !READY.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
let ctx = ui.ctx().clone();
|
||||
app.logic(&ctx);
|
||||
app.ui(ui);
|
||||
},
|
||||
app,
|
||||
);
|
||||
harness.input_mut().max_texture_side = Some(8192);
|
||||
READY.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
step_n(&mut harness, 6);
|
||||
for l in lines {
|
||||
if let Some(p) = l.get("shot").and_then(|v| v.as_str()) {
|
||||
step_n(&mut harness, 4);
|
||||
match harness.render() {
|
||||
Ok(img) => {
|
||||
img.save(p).expect("save png");
|
||||
println!("{p}");
|
||||
}
|
||||
Err(e) => eprintln!("render failed: {e}"),
|
||||
}
|
||||
} else if let Some(n) = l.get("steps").and_then(|v| v.as_u64()) {
|
||||
step_n(&mut harness, n as usize);
|
||||
} else if let Some(m) = l.get("method").and_then(|v| v.as_str()) {
|
||||
let (req, reply) = ControlRequest::new(m, l.get("params").cloned().unwrap_or_default());
|
||||
tx.send(req).expect("send");
|
||||
for _ in 0..60 {
|
||||
step(&mut harness);
|
||||
if let Ok(r) = reply.try_recv() {
|
||||
let s = r.to_string();
|
||||
println!("{m}: {}", &s[..s.len().min(300)]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
step_n(&mut harness, 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn step(harness: &mut egui_kittest::Harness<'_, WordApp>) {
|
||||
let mut raw = std::mem::take(harness.input_mut());
|
||||
harness.state_mut().raw_input_hook(&mut raw);
|
||||
*harness.input_mut() = raw;
|
||||
harness.step();
|
||||
}
|
||||
|
||||
fn step_n(harness: &mut egui_kittest::Harness<'_, WordApp>, n: usize) {
|
||||
for _ in 0..n {
|
||||
step(harness);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! File tab (Backstage): Home/New, Open, Info, Save As, Print, Export, Options, About.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::theme::{APP_COLOR, Tokens, medium, regular, semibold};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
const PAGES: [(&str, &str); 9] = [
|
||||
("home", "Home"),
|
||||
("new", "New"),
|
||||
("open", "Open"),
|
||||
("info", "Info"),
|
||||
("save", "Save"),
|
||||
("saveAs", "Save As"),
|
||||
("print", "Print"),
|
||||
("export", "Export"),
|
||||
("options", "Options"),
|
||||
];
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::left("backstage_nav").exact_size(200.0).frame(egui::Frame::NONE.fill(APP_COLOR).inner_margin(egui::Margin { left: 0, right: 0, top: 12, bottom: 12 })).show(ui, |ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 40.0), Sense::click());
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 26.0, r.center().y), vec2(18.0, 18.0)), "chevronLeft", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
for (id, label) in PAGES {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 38.0), Sense::click());
|
||||
let active = app.ui.backstage_page == id;
|
||||
if active {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(46));
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, if active { semibold(14.0) } else { medium(14.0) }, egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
match id {
|
||||
"save" => {
|
||||
let _ = app.run("file.save", json!({}));
|
||||
}
|
||||
"saveAs" => app.save_as_dialog(),
|
||||
"open" => {
|
||||
app.ui.backstage_page = id.into();
|
||||
}
|
||||
_ => app.ui.backstage_page = id.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
|
||||
for (label, id) in [("About", "about"), ("Discord community", "discord")] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(200.0, 32.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 0.0, egui::Color32::from_white_alpha(26));
|
||||
}
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, label, regular(13.0), egui::Color32::WHITE);
|
||||
if resp.clicked() {
|
||||
if id == "discord" {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
} else {
|
||||
app.dialog = crate::dialogs::Dialog::open("about", app);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin::symmetric(40, 30))).show(ui, |ui| {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| match app.ui.backstage_page.as_str() {
|
||||
"new" | "home" => new_page(app, ui),
|
||||
"open" => open_page(app, ui),
|
||||
"info" => info_page(app, ui),
|
||||
"print" | "export" => export_page(app, ui),
|
||||
"options" => options_page(app, ui),
|
||||
_ => new_page(app, ui),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn heading(ui: &mut Ui, s: &str) {
|
||||
ui.label(egui::RichText::new(s).font(semibold(26.0)));
|
||||
ui.add_space(16.0);
|
||||
}
|
||||
|
||||
fn template_tile(ui: &mut Ui, app: &mut WordApp, label: &str, template: &str) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.vertical(|ui| {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(150.0, 194.0), Sense::click());
|
||||
ui.painter().rect(r, 2.0, egui::Color32::WHITE, Stroke::new(if resp.hovered() { 2.0 } else { 1.0 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let key = format!("tpl:{template}:{ppp}");
|
||||
let tex = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(egui::Id::new(&key))).or_else(|| {
|
||||
let mut s = wordcraft_engine::Session::new(wordcraft_doc::Document::new());
|
||||
let _ = s.run("file.new", &json!({"template": template}));
|
||||
let l = s.layout();
|
||||
let page = l.pages.first()?;
|
||||
let img = wordcraft_render::render_page(&s.doc, page, 150.0 / page.w * ppp, &Default::default());
|
||||
let px = img.to_straight();
|
||||
let h = ui.ctx().load_texture(&key, egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px), egui::TextureOptions::LINEAR);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
Some(h)
|
||||
});
|
||||
if let Some(h) = tex {
|
||||
ui.painter().image(h.id(), r.shrink(1.0), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
ui.label(egui::RichText::new(label).font(regular(12.5)));
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.new", json!({"template": template}));
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn new_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
let hour = wordcraft_engine::cmd::now_iso().get(11..13).and_then(|h| h.parse::<u32>().ok()).unwrap_or(9);
|
||||
heading(ui, if hour < 12 { "Good morning" } else if hour < 18 { "Good afternoon" } else { "Good evening" });
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(18.0, 0.0);
|
||||
template_tile(ui, app, "Blank document", "blank");
|
||||
template_tile(ui, app, "Studio handbook (sample)", "sample");
|
||||
template_tile(ui, app, "Letter", "letter");
|
||||
template_tile(ui, app, "Résumé", "resume");
|
||||
template_tile(ui, app, "Report", "report");
|
||||
});
|
||||
ui.add_space(28.0);
|
||||
open_list(app, ui);
|
||||
}
|
||||
|
||||
fn open_list(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.label(egui::RichText::new("Recent").font(semibold(16.0)));
|
||||
ui.add_space(6.0);
|
||||
if app.ui.recent.is_empty() {
|
||||
ui.label(egui::RichText::new("Documents you open will show up here.").color(t.text_dim));
|
||||
}
|
||||
for p in app.ui.recent.clone() {
|
||||
let name = std::path::Path::new(&p).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(p.clone());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width().min(700.0), 40.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 18.0, r.center().y), vec2(22.0, 22.0)), "printLayout", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 13.0), Align2::LEFT_CENTER, &name, medium(13.0), t.text);
|
||||
ui.painter().text(pos2(r.min.x + 40.0, r.min.y + 29.0), Align2::LEFT_CENTER, &p, regular(11.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("file.open", json!({"path": p}));
|
||||
app.ui.backstage = false;
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, "Open");
|
||||
if ui.button(egui::RichText::new("📂 Browse…").font(medium(14.0))).clicked() {
|
||||
let _ = app.run("ui.openFileDialog", json!({}));
|
||||
}
|
||||
ui.add_space(18.0);
|
||||
open_list(app, ui);
|
||||
}
|
||||
|
||||
fn info_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, "Info");
|
||||
let info = app.session.run("file.info", &json!({})).unwrap_or_default();
|
||||
ui.columns(2, |cols| {
|
||||
let ui = &mut cols[0];
|
||||
ui.label(egui::RichText::new("Properties").font(semibold(15.0)));
|
||||
let mut props = app.session.doc.core.clone();
|
||||
let mut changed = false;
|
||||
egui::Grid::new("props").num_columns(2).spacing(vec2(12.0, 6.0)).show(ui, |ui| {
|
||||
for (l, v) in [("Title", &mut props.title), ("Subject", &mut props.subject), ("Author", &mut props.creator), ("Keywords", &mut props.keywords), ("Category", &mut props.category)] {
|
||||
ui.label(l);
|
||||
changed |= ui.text_edit_singleline(v).lost_focus();
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
if changed {
|
||||
let _ = app.run("file.properties", json!({"title": props.title, "subject": props.subject, "author": props.creator, "keywords": props.keywords, "category": props.category}));
|
||||
}
|
||||
let ui = &mut cols[1];
|
||||
ui.label(egui::RichText::new("Statistics").font(semibold(15.0)));
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Paragraphs", "paragraphs"), ("Sections", "sections"), ("Comments", "comments")] {
|
||||
ui.label(format!("{l}: {}", info.get(k).map(|v| v.to_string()).unwrap_or_default()));
|
||||
}
|
||||
ui.label(format!("Location: {}", info.get("path").and_then(|v| v.as_str()).unwrap_or("Not saved yet")));
|
||||
});
|
||||
}
|
||||
|
||||
fn export_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, if app.ui.backstage_page == "print" { "Print" } else { "Export" });
|
||||
ui.label("Save a copy in another format. Printing goes through a PDF you can print from any viewer.");
|
||||
ui.add_space(12.0);
|
||||
for (label, ext) in [("PDF document (*.pdf)", "pdf"), ("Word document (*.docx)", "docx"), ("OpenDocument Text (*.odt)", "odt"), ("Rich Text Format (*.rtf)", "rtf"), ("Web page (*.html)", "html"), ("Markdown (*.md)", "md"), ("Plain text (*.txt)", "txt"), ("Page image (*.png)", "png")] {
|
||||
if ui.add(egui::Button::new(egui::RichText::new(label).font(medium(13.5))).min_size(vec2(320.0, 34.0))).clicked() {
|
||||
let name = format!("{}.{ext}", app.title_stem());
|
||||
let picked = app.services.pick_save.as_ref().and_then(|f| f(&name));
|
||||
if let Some(path) = picked {
|
||||
let r = if ext == "png" { app.run("file.exportPng", json!({"path": path})) } else { app.run("file.saveAs", json!({"path": path})) };
|
||||
if r.is_ok() {
|
||||
app.status(format!("Exported {path}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn options_page(app: &mut WordApp, ui: &mut Ui) {
|
||||
heading(ui, "Options");
|
||||
ui.label(egui::RichText::new("General").font(semibold(15.0)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("User name:");
|
||||
let mut n = app.session.author.clone();
|
||||
if ui.text_edit_singleline(&mut n).changed() {
|
||||
app.session.author = n;
|
||||
}
|
||||
});
|
||||
let mut dark = app.ui.dark;
|
||||
if ui.checkbox(&mut dark, "Dark mode").changed() {
|
||||
let _ = app.run("ui.dark", json!({"value": dark}));
|
||||
}
|
||||
ui.checkbox(&mut app.autosave, "AutoSave documents that have been saved");
|
||||
ui.checkbox(&mut app.ui.show_discord, "Show the community button in the title bar");
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Display").font(semibold(15.0)));
|
||||
let mut marks = app.session.view.marks;
|
||||
if ui.checkbox(&mut marks, "Show all formatting marks").changed() {
|
||||
let _ = app.run("view.marks", json!({"value": marks}));
|
||||
}
|
||||
let mut ruler = app.session.view.ruler;
|
||||
if ui.checkbox(&mut ruler, "Show rulers").changed() {
|
||||
let _ = app.run("view.ruler", json!({"value": ruler}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
ui.label(egui::RichText::new("Agents").font(semibold(15.0)));
|
||||
ui.label("Every command is available to scripts and AI agents: run `wordcraft-cli mcp` for an MCP server, or start the app with `--control <port>` for the JSON control channel.");
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
//! The document canvas: pages on a grey background, rendered by the engine into textures (one
|
||||
//! per page, re-rendered only when the page's content changes), with the caret, selection,
|
||||
//! rulers and mouse editing drawn by egui on top.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
use wordcraft_layout::{DocLayout, Page, Placed};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, regular};
|
||||
|
||||
/// Points on screen per document point at 100% (96 px per inch, like a word processor).
|
||||
pub const PX_PER_PT: f32 = 96.0 / 72.0;
|
||||
const GAP: f32 = 18.0;
|
||||
const RULER: f32 = 22.0;
|
||||
|
||||
pub struct CanvasState {
|
||||
textures: HashMap<usize, (u64, TextureHandle)>,
|
||||
pub scroll_to_caret: bool,
|
||||
pub caret_visible_since: f64,
|
||||
dragging: bool,
|
||||
pub last_highlight: String,
|
||||
pub last_font_color: String,
|
||||
pub last_shading: String,
|
||||
pub open_url: Option<String>,
|
||||
/// Screen rects of pages last frame (for the control channel: page ↔ screen coordinates).
|
||||
pub page_rects: Vec<Rect>,
|
||||
/// Effective scale (screen points per document point).
|
||||
pub scale: f32,
|
||||
pub canvas_rect: Option<Rect>,
|
||||
pub focused: bool,
|
||||
pub render_ms: f64,
|
||||
pub ime_preedit: String,
|
||||
pub want_focus: bool,
|
||||
}
|
||||
|
||||
impl Default for CanvasState {
|
||||
fn default() -> Self {
|
||||
CanvasState {
|
||||
textures: HashMap::new(),
|
||||
scroll_to_caret: true,
|
||||
caret_visible_since: 0.0,
|
||||
dragging: false,
|
||||
last_highlight: "yellow".into(),
|
||||
last_font_color: "C00000".into(),
|
||||
last_shading: "FFF2CC".into(),
|
||||
open_url: None,
|
||||
page_rects: Vec::new(),
|
||||
scale: PX_PER_PT,
|
||||
canvas_rect: None,
|
||||
focused: false,
|
||||
render_ms: 0.0,
|
||||
ime_preedit: String::new(),
|
||||
want_focus: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Page positions in content space (points), for the current zoom.
|
||||
pub struct Geometry {
|
||||
pub rects: Vec<Rect>,
|
||||
pub size: egui::Vec2,
|
||||
pub scale: f32,
|
||||
}
|
||||
|
||||
pub fn geometry(app: &WordApp, l: &DocLayout, avail: egui::Vec2) -> Geometry {
|
||||
let v = &app.session.view;
|
||||
let maxw = l.pages.iter().map(|p| p.w).fold(0.0f32, f32::max).max(72.0);
|
||||
let maxh = l.pages.iter().map(|p| p.h.min(20_000.0)).fold(0.0f32, f32::max).max(72.0);
|
||||
let web = v.mode != wordcraft_layout::ViewMode::Print;
|
||||
let mut scale = v.zoom.clamp(0.1, 5.0) * PX_PER_PT;
|
||||
let cols = if v.multi_page || v.read_mode { 2 } else { 1 };
|
||||
match v.fit.as_str() {
|
||||
"pageWidth" => scale = ((avail.x - 2.0 * GAP - 20.0) / maxw).clamp(0.1, 6.0),
|
||||
"onePage" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 2.0 * GAP) / maxw).clamp(0.05, 6.0),
|
||||
"multiplePages" => scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0),
|
||||
_ => {}
|
||||
}
|
||||
if v.read_mode {
|
||||
scale = ((avail.y - 2.0 * GAP) / maxh).min((avail.x - 3.0 * GAP) / (2.0 * maxw)).clamp(0.05, 6.0);
|
||||
}
|
||||
if web {
|
||||
scale = PX_PER_PT * v.zoom.clamp(0.1, 5.0);
|
||||
}
|
||||
let mut rects = Vec::with_capacity(l.pages.len());
|
||||
let row_w = cols as f32 * maxw * scale + (cols as f32 - 1.0) * GAP;
|
||||
let content_w = (row_w + 2.0 * GAP).max(avail.x);
|
||||
let mut y = GAP;
|
||||
for (i, chunk) in l.pages.chunks(cols).enumerate() {
|
||||
let _ = i;
|
||||
let h = chunk.iter().map(|p| p.h.min(1e6) * scale).fold(0.0f32, f32::max);
|
||||
let mut x = (content_w - row_w) / 2.0;
|
||||
for p in chunk {
|
||||
rects.push(Rect::from_min_size(pos2(x, y), vec2(p.w * scale, p.h.min(1e6) * scale)));
|
||||
x += maxw * scale + GAP;
|
||||
}
|
||||
y += h + GAP;
|
||||
}
|
||||
Geometry { rects, size: vec2(content_w, y.max(avail.y)), scale }
|
||||
}
|
||||
|
||||
/// Fingerprint of a page's content for the texture cache.
|
||||
fn page_key(app: &WordApp, page: &Page, scale_px: f32) -> u64 {
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
scale_px.to_bits().hash(&mut h);
|
||||
let v = &app.session.view;
|
||||
(v.marks, v.show_markup).hash(&mut h);
|
||||
let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(id) if Some(id) == page.header_story || Some(id) == page.footer_story);
|
||||
editing_hf.hash(&mut h);
|
||||
format!("{:?}{:?}", app.session.doc.settings.page_color, app.session.doc.settings.watermark).hash(&mut h);
|
||||
(page.w.to_bits(), page.h.to_bits()).hash(&mut h);
|
||||
for list in [&page.items, &page.header, &page.footer] {
|
||||
list.len().hash(&mut h);
|
||||
for it in list.iter() {
|
||||
match it {
|
||||
Placed::Lines { para, l0, l1, x, y, .. } => {
|
||||
(std::sync::Arc::as_ptr(para) as usize, l0, l1, x.to_bits(), y.to_bits()).hash(&mut h);
|
||||
}
|
||||
Placed::Fill { rect, color } => format!("{rect:?}{color:?}").hash(&mut h),
|
||||
Placed::Rule { x0, y0, x1, y1, border } => format!("{x0}{y0}{x1}{y1}{border:?}").hash(&mut h),
|
||||
Placed::Image { rect, media, .. } => format!("{rect:?}{media}").hash(&mut h),
|
||||
Placed::Shape { rect, kind, fill, stroke, .. } => format!("{rect:?}{kind:?}{fill:?}{stroke:?}").hash(&mut h),
|
||||
Placed::Cell { .. } => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.session.doc.media.len().hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn to_screen(origin: Pos2, page_rect: Rect, scale: f32, x: f32, y: f32) -> Pos2 {
|
||||
pos2(origin.x + page_rect.min.x + x * scale, origin.y + page_rect.min.y + y * scale)
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let layout = app.session.layout();
|
||||
let full = ui.available_rect_before_wrap();
|
||||
let show_ruler = app.session.view.ruler && app.session.view.mode == wordcraft_layout::ViewMode::Print && !app.session.view.read_mode;
|
||||
let (hruler, vruler, area) = if show_ruler {
|
||||
let h = Rect::from_min_max(pos2(full.min.x + RULER, full.min.y), pos2(full.max.x, full.min.y + RULER));
|
||||
let v = Rect::from_min_max(pos2(full.min.x, full.min.y + RULER), pos2(full.min.x + RULER, full.max.y));
|
||||
(Some(h), Some(v), Rect::from_min_max(pos2(full.min.x + RULER, full.min.y + RULER), full.max))
|
||||
} else {
|
||||
(None, None, full)
|
||||
};
|
||||
app.canvas.canvas_rect = Some(area);
|
||||
let geo = geometry(app, &layout, area.size() - vec2(14.0, 0.0));
|
||||
app.canvas.scale = geo.scale;
|
||||
let caret = layout.caret_on(&app.session.sel.focus, app.session.page_hint);
|
||||
if let Some(c) = caret {
|
||||
app.session.page_hint = c.page;
|
||||
}
|
||||
let mut scroll_target: Option<Rect> = None;
|
||||
if app.canvas.scroll_to_caret
|
||||
&& let Some(c) = caret
|
||||
&& let Some(pr) = geo.rects.get(c.page)
|
||||
{
|
||||
let r = Rect::from_min_size(pos2(pr.min.x + c.x * geo.scale, pr.min.y + c.top * geo.scale), vec2(2.0, c.height * geo.scale));
|
||||
scroll_target = Some(r.expand2(vec2(40.0, 60.0)));
|
||||
}
|
||||
app.canvas.scroll_to_caret = false;
|
||||
let mut origin = area.min;
|
||||
let mut ui_area = ui.new_child(egui::UiBuilder::new().max_rect(area));
|
||||
let out = egui::ScrollArea::both().id_salt("canvas_scroll").auto_shrink([false, false]).show_viewport(&mut ui_area, |ui, viewport| {
|
||||
origin = ui.min_rect().min - viewport.min.to_vec2();
|
||||
let content = Rect::from_min_size(ui.min_rect().min, geo.size);
|
||||
let resp = ui.allocate_rect(content, Sense::click_and_drag());
|
||||
if let Some(tr) = scroll_target {
|
||||
ui.scroll_to_rect(Rect::from_min_size(ui.min_rect().min + tr.min.to_vec2(), tr.size()), None);
|
||||
}
|
||||
let painter = ui.painter_at(ui.clip_rect());
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let max_tex = ui.ctx().input(|i| i.max_texture_side) as f32;
|
||||
let mut rendered = 0;
|
||||
let t0 = crate::now_ms();
|
||||
let mut rects = Vec::with_capacity(geo.rects.len());
|
||||
let content_origin = ui.min_rect().min;
|
||||
for (i, pr) in geo.rects.iter().enumerate() {
|
||||
let sr = pr.translate(content_origin.to_vec2());
|
||||
rects.push(sr);
|
||||
if !sr.intersects(ui.clip_rect().expand(200.0)) {
|
||||
continue;
|
||||
}
|
||||
let Some(page) = layout.pages.get(i) else { continue };
|
||||
// Shadow and paper.
|
||||
painter.rect_filled(sr.translate(vec2(0.0, 2.0)).expand(1.5), 1.0, t.page_shadow);
|
||||
painter.rect_filled(sr, 0.0, Color32::WHITE);
|
||||
let scale_px = (geo.scale * ppp).min(max_tex / page.w.max(1.0)).min(max_tex / page.h.clamp(1.0, 1e6)).max(0.05);
|
||||
let key = page_key(app, page, scale_px);
|
||||
let fresh = app.canvas.textures.get(&i).is_some_and(|(k, _)| *k == key);
|
||||
if !fresh && (rendered < 2 || app.canvas.textures.get(&i).is_none() && rendered < 4) {
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
opts.display.marks = app.session.view.marks;
|
||||
opts.display.markup = app.session.view.show_markup;
|
||||
let editing_hf = matches!(app.session.sel.focus.story, StoryRef::Part(_));
|
||||
opts.display.dim_header = !editing_hf;
|
||||
opts.display.dim_body = editing_hf;
|
||||
let img = wordcraft_render::render_page(&app.session.doc, page, scale_px, &opts);
|
||||
let px = img.to_straight();
|
||||
let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px);
|
||||
match app.canvas.textures.get_mut(&i) {
|
||||
Some((k, tex)) => {
|
||||
tex.set(ci, egui::TextureOptions::LINEAR);
|
||||
*k = key;
|
||||
}
|
||||
None => {
|
||||
let tex = ui.ctx().load_texture(format!("page{i}"), ci, egui::TextureOptions::LINEAR);
|
||||
app.canvas.textures.insert(i, (key, tex));
|
||||
}
|
||||
}
|
||||
rendered += 1;
|
||||
} else if !fresh {
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
if let Some((_, tex)) = app.canvas.textures.get(&i) {
|
||||
painter.image(tex.id(), sr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
}
|
||||
// Header/footer editing chrome.
|
||||
if let StoryRef::Part(id) = app.session.sel.focus.story {
|
||||
let sect_body = page.body;
|
||||
if page.header_story == Some(id) || page.footer_story == Some(id) {
|
||||
let hy = sr.min.y + sect_body.y * geo.scale - 4.0;
|
||||
let fy = sr.min.y + sect_body.bottom() * geo.scale + 4.0;
|
||||
for (y, label) in [(hy, "Header"), (fy, "Footer")] {
|
||||
dashed(&painter, pos2(sr.min.x, y), pos2(sr.max.x, y), Stroke::new(1.0, t.accent));
|
||||
let tr = Rect::from_min_size(pos2(sr.min.x + 2.0, if label == "Header" { y } else { y - 18.0 }), vec2(52.0, 18.0));
|
||||
painter.rect_filled(tr, 2.0, t.checked);
|
||||
painter.text(tr.center(), egui::Align2::CENTER_CENTER, label, regular(11.0), t.accent_text);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Table gridlines.
|
||||
if app.session.view.gridlines {
|
||||
for it in &page.items {
|
||||
if let Placed::Cell { rect, .. } = it {
|
||||
let r = Rect::from_min_size(pos2(sr.min.x + rect.x * geo.scale, sr.min.y + rect.y * geo.scale), vec2(rect.w * geo.scale, rect.h * geo.scale));
|
||||
painter.rect_stroke(r, 0.0, Stroke::new(0.5, t.blue.linear_multiply(0.6)), egui::StrokeKind::Middle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if rendered > 0 {
|
||||
app.canvas.render_ms = crate::now_ms() - t0;
|
||||
}
|
||||
// Drop textures of pages that no longer exist.
|
||||
let n = layout.pages.len();
|
||||
app.canvas.textures.retain(|k, _| *k < n);
|
||||
app.canvas.page_rects = rects.clone();
|
||||
// Selection.
|
||||
if !app.session.sel.is_collapsed() {
|
||||
let (a, b) = app.session.sel.ordered();
|
||||
for (pi, r) in layout.selection_rects(&app.session.doc, &a, &b, app.session.page_hint) {
|
||||
if let Some(pr) = rects.get(pi) {
|
||||
let sr = Rect::from_min_size(pos2(pr.min.x + r.x * geo.scale, pr.min.y + r.y * geo.scale), vec2(r.w * geo.scale, r.h * geo.scale));
|
||||
painter.rect_filled(sr, 0.0, t.selection);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Caret.
|
||||
let focused = resp.has_focus() || app.canvas.focused;
|
||||
if let Some(c) = layout.caret_on(&app.session.sel.focus, app.session.page_hint)
|
||||
&& let Some(pr) = rects.get(c.page)
|
||||
{
|
||||
let x = pr.min.x + c.x * geo.scale;
|
||||
let y0 = pr.min.y + c.top * geo.scale;
|
||||
let y1 = y0 + c.height * geo.scale;
|
||||
let since = crate::now_ms() - app.canvas.caret_visible_since;
|
||||
let on = (since / 530.0) as u64 % 2 == 0;
|
||||
if app.session.sel.is_collapsed() && on && focused {
|
||||
painter.line_segment([pos2(x.round() + 0.5, y0), pos2(x.round() + 0.5, y1)], Stroke::new(1.5, t.caret));
|
||||
}
|
||||
if focused {
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(530 - (since as u64 % 530)));
|
||||
}
|
||||
// IME candidate window placement.
|
||||
let cr = Rect::from_min_max(pos2(x, y0), pos2(x + 1.0, y1));
|
||||
if focused {
|
||||
ui.ctx().output_mut(|o| {
|
||||
o.ime = Some(egui::output::IMEOutput { rect: cr, cursor_rect: cr, purpose: Default::default(), should_interrupt_composition: false });
|
||||
});
|
||||
}
|
||||
if !app.canvas.ime_preedit.is_empty() {
|
||||
let g = painter.text(pos2(x, y1), egui::Align2::LEFT_BOTTOM, &app.canvas.ime_preedit, regular(c.height * geo.scale * 0.8), t.caret);
|
||||
painter.line_segment([pos2(g.min.x, g.max.y), pos2(g.max.x, g.max.y)], Stroke::new(1.0, t.caret));
|
||||
}
|
||||
}
|
||||
(resp, rects)
|
||||
});
|
||||
let (resp, rects) = out.inner;
|
||||
// Focus: the canvas takes keyboard focus on click and keeps Tab/arrows.
|
||||
if resp.clicked() || resp.drag_started() || app.canvas.want_focus {
|
||||
resp.request_focus();
|
||||
app.canvas.want_focus = false;
|
||||
}
|
||||
if resp.has_focus() {
|
||||
ui.memory_mut(|m| m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true }));
|
||||
}
|
||||
app.canvas.focused = resp.has_focus();
|
||||
mouse(app, ui, &resp, &rects, &layout, geo.scale);
|
||||
if resp.hovered() {
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Text);
|
||||
}
|
||||
if app.canvas.focused {
|
||||
crate::keys::canvas_events(app, ui.ctx());
|
||||
}
|
||||
let _ = origin;
|
||||
if let (Some(h), Some(v)) = (hruler, vruler) {
|
||||
rulers(app, ui, h, v, &rects, &layout, geo.scale);
|
||||
}
|
||||
}
|
||||
|
||||
fn dashed(p: &egui::Painter, a: Pos2, b: Pos2, s: Stroke) {
|
||||
p.extend(egui::Shape::dashed_line(&[a, b], s, 4.0, 3.0));
|
||||
}
|
||||
|
||||
/// Page index and document coordinates under a screen point.
|
||||
pub fn page_at(rects: &[Rect], scale: f32, p: Pos2) -> Option<(usize, f32, f32)> {
|
||||
let mut best: Option<(usize, f32)> = None;
|
||||
for (i, r) in rects.iter().enumerate() {
|
||||
let d = if r.contains(p) {
|
||||
0.0
|
||||
} else {
|
||||
let dx = (r.min.x - p.x).max(p.x - r.max.x).max(0.0);
|
||||
let dy = (r.min.y - p.y).max(p.y - r.max.y).max(0.0);
|
||||
dx + dy * 2.0
|
||||
};
|
||||
if best.is_none_or(|(_, bd)| d < bd) {
|
||||
best = Some((i, d));
|
||||
}
|
||||
}
|
||||
let (i, _) = best?;
|
||||
let r = rects.get(i)?;
|
||||
Some((i, (p.x - r.min.x) / scale, (p.y - r.min.y) / scale))
|
||||
}
|
||||
|
||||
fn mouse(app: &mut WordApp, ui: &Ui, resp: &egui::Response, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||||
let Some(p) = resp.interact_pointer_pos().or_else(|| resp.hover_pos()) else { return };
|
||||
let Some((page, x, y)) = page_at(rects, scale, p) else { return };
|
||||
let mods = ui.input(|i| i.modifiers);
|
||||
let story = app.session.sel.focus.story;
|
||||
// Double-click in the header/footer area edits it; double-click in the body leaves it.
|
||||
if resp.double_clicked() {
|
||||
if let Some((s, header)) = layout.header_footer_at(page, y)
|
||||
&& story == StoryRef::Body
|
||||
{
|
||||
let id = if header { "insert.editHeader" } else { "insert.editFooter" };
|
||||
let _ = app.run(id, json!({}));
|
||||
let _ = s;
|
||||
if let Some(pos) = app.session.layout().hit(page, x, y, app.session.sel.focus.story) {
|
||||
app.session.sel = wordcraft_engine::Selection::caret(pos);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if matches!(story, StoryRef::Part(_)) && layout.header_footer_at(page, y).is_none() {
|
||||
let _ = app.run("insert.closeHeader", json!({}));
|
||||
if let Some(pos) = layout.hit(page, x, y, StoryRef::Body) {
|
||||
app.session.sel = wordcraft_engine::Selection::caret(pos);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let _ = app.run("select.word", json!({}));
|
||||
return;
|
||||
}
|
||||
if resp.triple_clicked() {
|
||||
let _ = app.run("select.paragraph", json!({}));
|
||||
return;
|
||||
}
|
||||
let pressed = ui.input(|i| i.pointer.primary_pressed()) && resp.contains_pointer();
|
||||
if pressed {
|
||||
let Some(pos) = layout.hit(page, x, y, story) else { return };
|
||||
// Ctrl/⌘+click follows a hyperlink.
|
||||
if mods.command
|
||||
&& let Some(link) = app.session.doc.para_at(&pos).map(|pp| pp.props_of_char(pos.off).link.clone()).flatten()
|
||||
{
|
||||
if let Some(name) = link.strip_prefix('#') {
|
||||
let _ = app.run("edit.goto", json!({"bookmark": name}));
|
||||
} else {
|
||||
app.canvas.open_url = Some(link);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let pj = serde_json::to_value(&pos).unwrap_or_default();
|
||||
let _ = app.run("caret.set", json!({"pos": pj, "extend": mods.shift}));
|
||||
if mods.command && !mods.shift {
|
||||
let _ = app.run("select.sentence", json!({}));
|
||||
}
|
||||
app.session.page_hint = page;
|
||||
app.canvas.dragging = true;
|
||||
// Format painter applies on mouse up.
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_down()) {
|
||||
if let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& pos != app.session.sel.focus
|
||||
{
|
||||
app.session.sel.focus = pos;
|
||||
app.session.page_hint = page;
|
||||
app.canvas.caret_visible_since = crate::now_ms();
|
||||
}
|
||||
// Auto-scroll near the edges is handled by egui's scroll area drag.
|
||||
} else if app.canvas.dragging && ui.input(|i| i.pointer.primary_released()) {
|
||||
app.canvas.dragging = false;
|
||||
if app.session.painter.is_some() && !app.session.sel.is_collapsed() {
|
||||
let _ = app.run("edit.pasteFormat", json!({}));
|
||||
}
|
||||
}
|
||||
// Hover a link: show its target.
|
||||
if resp.hovered()
|
||||
&& !app.canvas.dragging
|
||||
&& let Some(pos) = layout.hit(page, x, y, story)
|
||||
&& let Some(link) = app.session.doc.para_at(&pos).and_then(|pp| pp.props_of_char(pos.off).link.clone())
|
||||
{
|
||||
let tip = format!("{link}\n{}+Click to follow link", if cfg!(target_os = "macos") { "⌘" } else { "Ctrl" });
|
||||
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new("link_tip"), egui::PopupAnchor::Pointer).show(|ui| {
|
||||
ui.label(tip);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Horizontal and vertical rulers for the caret's page and paragraph.
|
||||
fn rulers(app: &mut WordApp, ui: &mut Ui, h: Rect, v: Rect, rects: &[Rect], layout: &DocLayout, scale: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(Rect::from_min_max(pos2(v.min.x, h.min.y), pos2(v.max.x, h.max.y)), 0.0, t.canvas);
|
||||
p.rect_filled(h, 0.0, t.canvas);
|
||||
p.rect_filled(v, 0.0, t.canvas);
|
||||
let pi = app.session.page_hint.min(layout.pages.len().saturating_sub(1));
|
||||
let (Some(page), Some(pr)) = (layout.pages.get(pi), rects.get(pi)) else { return };
|
||||
let sect = app.session.doc.sections().get(page.section).map(|(_, s)| (*s).clone()).unwrap_or_default();
|
||||
// Horizontal.
|
||||
let hp = ui.painter_at(h);
|
||||
let x0 = pr.min.x;
|
||||
let bar = Rect::from_min_max(pos2(x0, h.min.y + 4.0), pos2(x0 + page.w * scale, h.max.y - 3.0));
|
||||
hp.rect_filled(bar, 0.0, t.ruler_margin);
|
||||
let text = Rect::from_min_max(pos2(x0 + page.body.x * scale, bar.min.y), pos2(x0 + page.body.right() * scale, bar.max.y));
|
||||
hp.rect_filled(text, 0.0, t.ruler);
|
||||
let unit = 72.0;
|
||||
let origin = page.body.x;
|
||||
let mut k = -((origin / unit).ceil() as i32) * 8;
|
||||
loop {
|
||||
let xpt = origin + k as f32 * unit / 8.0;
|
||||
if xpt > page.w {
|
||||
break;
|
||||
}
|
||||
if xpt >= 0.0 {
|
||||
let sx = x0 + xpt * scale;
|
||||
let (len, label) = if k % 8 == 0 { (0.0, Some(k / 8)) } else if k % 4 == 0 { (5.0, None) } else if k % 2 == 0 { (3.0, None) } else { (1.5, None) };
|
||||
match label {
|
||||
Some(n) if n != 0 => {
|
||||
hp.text(pos2(sx, bar.center().y), egui::Align2::CENTER_CENTER, n.abs().to_string(), regular(9.5), t.ruler_tick);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if scale * unit / 8.0 > 4.0 || k % 2 == 0 {
|
||||
hp.line_segment([pos2(sx, bar.center().y - len / 2.0), pos2(sx, bar.center().y + len / 2.0)], Stroke::new(1.0, t.ruler_tick));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
k += 1;
|
||||
if k > 400 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Indent markers for the caret's paragraph (relative to its column).
|
||||
let col_x = page.body.x;
|
||||
if let Some(para) = app.session.doc.para_at(&app.session.sel.focus) {
|
||||
let rp = app.session.doc.styles.resolve_para(¶.props);
|
||||
let first = x0 + (col_x + rp.indent_left + rp.indent_first) * scale;
|
||||
let left = x0 + (col_x + rp.indent_left) * scale;
|
||||
let right = x0 + (col_x + page.body.w - rp.indent_right) * scale;
|
||||
let c = t.text_dim;
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(first - 4.5, bar.min.y), pos2(first + 4.5, bar.min.y), pos2(first, bar.min.y + 5.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y - 3.0), pos2(left, bar.max.y - 8.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
hp.rect(Rect::from_min_max(pos2(left - 4.5, bar.max.y - 3.0), pos2(left + 4.5, bar.max.y + 1.0)), 0.0, t.ruler, Stroke::new(1.0, c), egui::StrokeKind::Inside);
|
||||
hp.add(egui::Shape::convex_polygon(vec![pos2(right - 4.5, bar.max.y - 1.0), pos2(right + 4.5, bar.max.y - 1.0), pos2(right, bar.max.y - 6.0)], t.ruler, Stroke::new(1.0, c)));
|
||||
for tab in &rp.tabs {
|
||||
let tx = x0 + (col_x + tab.pos) * scale;
|
||||
hp.line_segment([pos2(tx, bar.max.y - 6.0), pos2(tx, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
hp.line_segment([pos2(tx, bar.max.y - 1.0), pos2(tx + 4.0, bar.max.y - 1.0)], Stroke::new(1.5, t.text));
|
||||
}
|
||||
// Dragging the left-indent marker.
|
||||
let id = ui.id().with("ruler_left");
|
||||
let mr = Rect::from_center_size(pos2(left, bar.max.y - 3.0), vec2(12.0, 12.0));
|
||||
let r = ui.interact(mr, id, Sense::drag());
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = ((pp.x - x0) / scale - col_x).clamp(-col_x, page.body.w - 18.0);
|
||||
let snapped = (pt / 4.5).round() * 4.5;
|
||||
let _ = app.run("para.indents", json!({"left": snapped}));
|
||||
}
|
||||
let fr = Rect::from_center_size(pos2(first, bar.min.y + 3.0), vec2(12.0, 10.0));
|
||||
let r = ui.interact(fr, ui.id().with("ruler_first"), Sense::drag());
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = (pp.x - x0) / scale - col_x - rp.indent_left;
|
||||
let _ = app.run("para.indents", json!({"firstLine": (pt / 4.5).round() * 4.5}));
|
||||
}
|
||||
let rr = Rect::from_center_size(pos2(right, bar.max.y - 3.0), vec2(12.0, 12.0));
|
||||
let r = ui.interact(rr, ui.id().with("ruler_right"), Sense::drag());
|
||||
if r.dragged()
|
||||
&& let Some(pp) = r.interact_pointer_pos()
|
||||
{
|
||||
let pt = page.body.w - ((pp.x - x0) / scale - col_x);
|
||||
let _ = app.run("para.indents", json!({"right": (pt / 4.5).round() * 4.5}));
|
||||
}
|
||||
}
|
||||
// Vertical.
|
||||
let vp = ui.painter_at(v);
|
||||
let y0 = pr.min.y;
|
||||
let vbar = Rect::from_min_max(pos2(v.min.x + 4.0, y0), pos2(v.max.x - 3.0, y0 + page.h.min(20_000.0) * scale));
|
||||
vp.rect_filled(vbar, 0.0, t.ruler_margin);
|
||||
vp.rect_filled(Rect::from_min_max(pos2(vbar.min.x, y0 + page.body.y * scale), pos2(vbar.max.x, y0 + page.body.bottom() * scale)), 0.0, t.ruler);
|
||||
let origin = page.body.y;
|
||||
let mut k = -((origin / unit).ceil() as i32) * 8;
|
||||
loop {
|
||||
let ypt = origin + k as f32 * unit / 8.0;
|
||||
if ypt > page.h.min(20_000.0) {
|
||||
break;
|
||||
}
|
||||
if ypt >= 0.0 && k % 8 == 0 && k != 0 {
|
||||
vp.text(pos2(vbar.center().x, y0 + ypt * scale), egui::Align2::CENTER_CENTER, (k / 8).abs().to_string(), regular(9.5), t.ruler_tick);
|
||||
} else if ypt >= 0.0 && k % 4 == 0 {
|
||||
let sy = y0 + ypt * scale;
|
||||
vp.line_segment([pos2(vbar.center().x - 2.5, sy), pos2(vbar.center().x + 2.5, sy)], Stroke::new(1.0, t.ruler_tick));
|
||||
}
|
||||
k += 1;
|
||||
if k > 2000 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = sect;
|
||||
}
|
||||
|
||||
/// Convert a page position (points) to screen coordinates (for agents and tests).
|
||||
pub fn page_to_screen(app: &WordApp, page: usize, x: f32, y: f32) -> Option<Pos2> {
|
||||
let r = app.canvas.page_rects.get(page)?;
|
||||
Some(to_screen(pos2(0.0, 0.0), *r, app.canvas.scale, x, y))
|
||||
}
|
||||
|
||||
/// Caret position on screen.
|
||||
pub fn caret_screen(app: &mut WordApp) -> Option<(Pos2, f32)> {
|
||||
let l = app.session.layout();
|
||||
let c = l.caret_on(&app.session.sel.focus, app.session.page_hint)?;
|
||||
let p = page_to_screen(app, c.page, c.x, c.top)?;
|
||||
Some((p, c.height * app.canvas.scale))
|
||||
}
|
||||
|
||||
pub fn pos_from_screen(app: &mut WordApp, p: Pos2) -> Option<Pos> {
|
||||
let (page, x, y) = page_at(&app.canvas.page_rects, app.canvas.scale, p)?;
|
||||
let story = app.session.sel.focus.story;
|
||||
app.session.layout().hit(page, x, y, story)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Title bar (Quick Access Toolbar, title, search, account) and status bar.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::json;
|
||||
use wordcraft_doc::StoryRef;
|
||||
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
fn qat_button(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, enabled: bool) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 24.0), Sense::click());
|
||||
if resp.hovered() && enabled {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
let c = if enabled { t.icon } else { t.text_disabled };
|
||||
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), icon, c, if enabled { t.accent } else { c });
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let resp = resp.on_hover_text(if sc.is_empty() { tip.to_string() } else { format!("{tip} ({sc})") });
|
||||
if resp.clicked() && enabled {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn title_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let left = if app.integrated_titlebar { 78 } else { 8 };
|
||||
egui::Panel::top("title_bar").exact_size(38.0).frame(egui::Frame::NONE.fill(t.title_bar).inner_margin(egui::Margin { left, right: 10, top: 0, bottom: 0 })).show(ui, |ui| {
|
||||
let full = ui.max_rect();
|
||||
// Drag the window by the title bar (integrated title bar on macOS).
|
||||
let drag = ui.interact(full, ui.id().with("title_drag"), Sense::click_and_drag());
|
||||
if drag.drag_started() {
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::StartDrag);
|
||||
}
|
||||
if drag.double_clicked() {
|
||||
let max = ui.ctx().input(|i| i.viewport().maximized.unwrap_or(false));
|
||||
ui.ctx().send_viewport_cmd(egui::ViewportCommand::Maximized(!max));
|
||||
}
|
||||
let mut qat_end = full.min.x;
|
||||
let mut right_start = full.max.x;
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
// AutoSave toggle (on when the document has a path).
|
||||
let has_path = app.session.path.is_some();
|
||||
ui.label(egui::RichText::new("AutoSave").font(regular(11.5)).color(t.text_dim));
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(30.0, 16.0), Sense::click());
|
||||
let on = has_path && app.canvas_autosave();
|
||||
ui.painter().rect(r, 8.0, if on { t.accent } else { t.input }, Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
let knob = if on { r.max.x - 8.0 } else { r.min.x + 8.0 };
|
||||
ui.painter().circle_filled(pos2(knob, r.center().y), 5.0, if on { egui::Color32::WHITE } else { t.text_dim });
|
||||
if resp.on_hover_text("AutoSave saves every change to the file (needs a saved document)").clicked() {
|
||||
if has_path {
|
||||
app.toggle_autosave();
|
||||
} else {
|
||||
app.save_as_dialog();
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
qat_button(ui, app, "save", "Save", "file.save", true);
|
||||
let can_undo = app.session.can_undo();
|
||||
let can_redo = app.session.can_redo();
|
||||
qat_button(ui, app, "undo", &format!("Undo {}", app.session.undo_label().unwrap_or("")), "edit.undo", can_undo);
|
||||
qat_button(ui, app, "redo", "Redo", "edit.redo", can_redo);
|
||||
qat_button(ui, app, "more", "Customize Quick Access Toolbar", "ui.dialog", true);
|
||||
qat_end = ui.min_rect().max.x;
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Account / community.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click());
|
||||
ui.painter().circle_filled(r.center(), 12.0, t.accent);
|
||||
let initials: String = app.session.author.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect();
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, initials, semibold(10.5), egui::Color32::WHITE);
|
||||
resp.on_hover_text(format!("{} — set your name in File › Options", app.session.author));
|
||||
ui.add_space(8.0);
|
||||
if app.ui.show_discord {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(84.0, 22.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 11.0, if resp.hovered() { egui::Color32::from_rgb(0x47, 0x52, 0xC4) } else { egui::Color32::from_rgb(0x58, 0x65, 0xF2) });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 13.0, r.center().y), vec2(14.0, 14.0)), "discord", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, "Discord", medium(11.5), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Join the ArtCraft community on Discord").clicked() {
|
||||
let _ = app.run("ui.discord", json!({}));
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
// Search ("Tell me").
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, 26.0), Sense::click());
|
||||
ui.painter().rect(r, 6.0, if resp.hovered() { t.input } else { t.ribbon }, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(8.0, 5.0), vec2(16.0, 16.0)), "search", t.text_dim, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, "Search commands and help", regular(12.0), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "commands"}));
|
||||
}
|
||||
right_start = ui.min_rect().min.x;
|
||||
});
|
||||
});
|
||||
// Centred title.
|
||||
let title = format!("{} — {}", app.title_stem(), if app.session.dirty { "Edited" } else if app.session.path.is_some() { "Saved" } else { "Not saved" });
|
||||
let g = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.clone(), semibold(12.5), t.text));
|
||||
let w = g.size().x;
|
||||
let cx = full.center().x;
|
||||
if cx - w / 2.0 > qat_end + 10.0 && cx + w / 2.0 < right_start - 10.0 {
|
||||
ui.painter().galley(pos2(cx - w / 2.0, full.center().y - g.size().y / 2.0), g, t.text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::Panel::bottom("status").exact_size(26.0).frame(egui::Frame::NONE.fill(t.status_bar).inner_margin(egui::Margin { left: 12, right: 12, top: 0, bottom: 0 }).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(14.0, 0.0);
|
||||
let l = app.session.layout();
|
||||
let page = l.caret_on(&app.session.sel.focus, app.session.page_hint).map(|c| c.page + 1).unwrap_or(1);
|
||||
let st = |ui: &mut Ui, s: &str| ui.add(egui::Label::new(egui::RichText::new(s).font(regular(11.5)).color(t.text_dim)).sense(Sense::click()));
|
||||
if st(ui, &format!("Page {page} of {}", l.pages.len())).on_hover_text("Go To (⌘⌥G)").clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "goto"}));
|
||||
}
|
||||
let words = app.cached_word_count();
|
||||
let wtxt = if app.session.sel.is_collapsed() {
|
||||
format!("{words} words")
|
||||
} else {
|
||||
let sw = wordcraft_doc::count_words(&app.session.selected_text());
|
||||
format!("{sw} of {words} words")
|
||||
};
|
||||
if st(ui, &wtxt).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "wordCount"}));
|
||||
}
|
||||
if app.session.sel.focus.story != StoryRef::Body {
|
||||
st(ui, "Editing header/footer");
|
||||
}
|
||||
st(ui, "English (United States)");
|
||||
if app.session.doc.settings.track_changes {
|
||||
st(ui, "Track Changes: On");
|
||||
}
|
||||
if let Some((msg, at)) = &app.status_msg {
|
||||
if crate::now_ms() - at < 6000.0 {
|
||||
ui.label(egui::RichText::new(msg).font(regular(11.5)).color(t.accent_text));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(4.0, 0.0);
|
||||
let z = (app.canvas.scale / crate::canvas::PX_PER_PT * 100.0).round();
|
||||
if ui.add(egui::Label::new(egui::RichText::new(format!("{z}%")).font(regular(11.5)).color(t.text_dim)).sense(Sense::click())).clicked() {
|
||||
let _ = app.run("ui.dialog", json!({"name": "zoom"}));
|
||||
}
|
||||
if small_icon(ui, "plus", "Zoom In") {
|
||||
let _ = app.run("view.zoomIn", json!({}));
|
||||
}
|
||||
// Zoom slider (10%–500%, 100% in the middle).
|
||||
let mut v = app.canvas.scale / crate::canvas::PX_PER_PT;
|
||||
let slider = egui::Slider::new(&mut v, 0.1..=5.0).logarithmic(true).show_value(false);
|
||||
ui.spacing_mut().slider_width = 110.0;
|
||||
if ui.add(slider).changed() {
|
||||
let _ = app.run("view.zoom", json!({"value": (v * 100.0).round()}));
|
||||
}
|
||||
if small_icon(ui, "minus", "Zoom Out") {
|
||||
let _ = app.run("view.zoomOut", json!({}));
|
||||
}
|
||||
ui.add_space(10.0);
|
||||
let mode = app.session.view.mode;
|
||||
let read = app.session.view.read_mode;
|
||||
for (icon, id, on) in [
|
||||
("webLayout", "view.webLayout", mode == wordcraft_layout::ViewMode::Web),
|
||||
("printLayout", "view.printLayout", mode == wordcraft_layout::ViewMode::Print && !read),
|
||||
("readMode", "view.readMode", read),
|
||||
] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 20.0), Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 3.0, t.checked);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
if resp.on_hover_text(id.trim_start_matches("view.")).clicked() {
|
||||
let _ = app.run(id, json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(8.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(56.0, 20.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_min_size(r.min + vec2(2.0, 2.0), vec2(16.0, 16.0)), "focus", t.icon, t.accent);
|
||||
ui.painter().text(pos2(r.min.x + 22.0, r.center().y), Align2::LEFT_CENTER, "Focus", regular(11.5), t.text_dim);
|
||||
if resp.clicked() {
|
||||
let _ = app.run("view.focus", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn small_icon(ui: &mut Ui, icon: &str, tip: &str) -> bool {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), r.shrink(3.0), icon, t.icon, t.accent);
|
||||
resp.on_hover_text(tip).clicked()
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
pub fn canvas_autosave(&self) -> bool {
|
||||
self.autosave
|
||||
}
|
||||
pub fn toggle_autosave(&mut self) {
|
||||
self.autosave = !self.autosave;
|
||||
}
|
||||
/// Word count, recomputed only when the document changes.
|
||||
pub fn cached_word_count(&mut self) -> usize {
|
||||
let rev = self.session.rev();
|
||||
if self.word_count.0 != rev {
|
||||
self.word_count = (rev, self.session.doc.word_count());
|
||||
}
|
||||
self.word_count.1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Programmatic control of the running app (agents, tests, MCP bridge).
|
||||
//!
|
||||
//! Methods (JSON lines over the host's transport, see `docs/control-protocol.md`):
|
||||
//! - `engine.execute {command, params}`: run any command (engine or `ui.*`)
|
||||
//! - `engine.commands`: every command with its enablement
|
||||
//! - `document.inspect`: document structure; `ui.inspect`: UI state, canvas geometry, perf
|
||||
//! - `ui.click {x, y, button?, count?, shift?, cmd?}`, `ui.move {x, y}`, `ui.drag {x, y, toX, toY}`:
|
||||
//! real pointer input in screen points (reaches every widget)
|
||||
//! - `ui.clickText {page, x, y}`: click at a page position (points from the page's top-left)
|
||||
//! - `ui.key {key, shift?, alt?, cmd?}`, `ui.text {text}`: keyboard input through egui
|
||||
//! - `ui.screenshot {path?}`: PNG of the window; `ui.render {path, page?, scale?}`: page PNG
|
||||
//! - `ui.parity`: feature catalog parity
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::WordApp;
|
||||
|
||||
pub struct ControlRequest {
|
||||
pub method: String,
|
||||
pub params: Value,
|
||||
pub reply: Sender<Value>,
|
||||
}
|
||||
|
||||
impl ControlRequest {
|
||||
pub fn new(method: impl Into<String>, params: Value) -> (Self, std::sync::mpsc::Receiver<Value>) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
(Self { method: method.into(), params, reply: tx }, rx)
|
||||
}
|
||||
}
|
||||
|
||||
pub enum Outcome {
|
||||
Done(Value),
|
||||
Screenshot { path: Option<String> },
|
||||
}
|
||||
|
||||
fn ok(v: Value) -> Outcome {
|
||||
Outcome::Done(json!({"ok": true, "result": v}))
|
||||
}
|
||||
fn err(e: impl std::fmt::Display) -> Outcome {
|
||||
Outcome::Done(json!({"ok": false, "error": e.to_string()}))
|
||||
}
|
||||
|
||||
pub fn inspect(app: &mut WordApp, ctx: &egui::Context) -> Value {
|
||||
let r = ctx.content_rect();
|
||||
let caret = crate::canvas::caret_screen(app).map(|(p, h)| json!({"x": p.x, "y": p.y, "h": h}));
|
||||
json!({
|
||||
"ui": app.ui,
|
||||
"view": app.session.view,
|
||||
"dialog": app.dialog.as_ref().map(|d| serde_json::to_value(d).unwrap_or_default()),
|
||||
"window": [r.width(), r.height()],
|
||||
"canvasRect": app.canvas.canvas_rect.map(|c| [c.left(), c.top(), c.width(), c.height()]),
|
||||
"pages": app.canvas.page_rects.iter().map(|r| [r.left(), r.top(), r.width(), r.height()]).collect::<Vec<_>>(),
|
||||
"scale": app.canvas.scale,
|
||||
"caret": caret,
|
||||
"focused": app.canvas.focused,
|
||||
"title": app.title_stem(),
|
||||
"dirty": app.session.dirty,
|
||||
"perf": {"frameMs": app.frame_ms, "renderMs": app.canvas.render_ms, "layoutMs": app.session.layout().ms},
|
||||
})
|
||||
}
|
||||
|
||||
fn key_from(name: &str) -> Option<egui::Key> {
|
||||
egui::Key::from_name(name).or(match name.to_ascii_lowercase().as_str() {
|
||||
"enter" | "return" => Some(egui::Key::Enter),
|
||||
"esc" | "escape" => Some(egui::Key::Escape),
|
||||
"delete" => Some(egui::Key::Delete),
|
||||
"backspace" => Some(egui::Key::Backspace),
|
||||
"left" => Some(egui::Key::ArrowLeft),
|
||||
"right" => Some(egui::Key::ArrowRight),
|
||||
"up" => Some(egui::Key::ArrowUp),
|
||||
"down" => Some(egui::Key::ArrowDown),
|
||||
"space" => Some(egui::Key::Space),
|
||||
"tab" => Some(egui::Key::Tab),
|
||||
"home" => Some(egui::Key::Home),
|
||||
"end" => Some(egui::Key::End),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn mods(p: &Value) -> egui::Modifiers {
|
||||
let b = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
let cmd = b("cmd") || b("command") || b("ctrl");
|
||||
egui::Modifiers { alt: b("alt"), ctrl: cmd && !cfg!(target_os = "macos"), shift: b("shift"), mac_cmd: cmd && cfg!(target_os = "macos"), command: cmd }
|
||||
}
|
||||
|
||||
fn click_events(app: &mut WordApp, pos: egui::Pos2, button: egui::PointerButton, count: u64, m: egui::Modifiers) {
|
||||
app.synthetic.push(egui::Event::PointerMoved(pos));
|
||||
for _ in 0..count.clamp(1, 3) {
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: true, modifiers: m });
|
||||
app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m });
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle(app: &mut WordApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome {
|
||||
let p = &req.params;
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
let f = |k: &str| p.get(k).and_then(Value::as_f64).map(|v| v as f32);
|
||||
match req.method.as_str() {
|
||||
"engine.execute" | "command" => {
|
||||
let Some(id) = s("command").or(s("id")) else { return err("missing `command`") };
|
||||
let params = p.get("params").cloned().filter(|v| !v.is_null()).unwrap_or(json!({}));
|
||||
match app.run(id, params) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"engine.commands" => {
|
||||
let reg = app.session.registry.clone();
|
||||
ok(Value::Array(
|
||||
reg.all()
|
||||
.iter()
|
||||
.map(|c| json!({"id": c.id, "label": c.label, "location": c.location, "shortcut": c.shortcut, "params": c.params, "enabled": (c.enabled)(&app.session).is_none()}))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
"document.inspect" => match app.session.run("document.inspect", p) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
},
|
||||
"ui.inspect" => ok(inspect(app, ctx)),
|
||||
"ui.parity" => ok(wordcraft_engine::catalog::parity(&app.session.registry)),
|
||||
"ui.click" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
let button = match s("button") {
|
||||
Some("right") => egui::PointerButton::Secondary,
|
||||
_ => egui::PointerButton::Primary,
|
||||
};
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
click_events(app, egui::pos2(x, y), button, count, mods(p));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.clickText" => {
|
||||
let page = p.get("page").and_then(Value::as_u64).unwrap_or(0) as usize;
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
let Some(pos) = crate::canvas::page_to_screen(app, page, x, y) else { return err("page not on screen") };
|
||||
let count = p.get("count").and_then(Value::as_u64).unwrap_or(1);
|
||||
click_events(app, pos, egui::PointerButton::Primary, count, mods(p));
|
||||
ok(json!({"screen": [pos.x, pos.y]}))
|
||||
}
|
||||
"ui.move" => {
|
||||
let (Some(x), Some(y)) = (f("x"), f("y")) else { return err("missing x/y") };
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.drag" => {
|
||||
let (Some(x), Some(y), Some(tx), Some(ty)) = (f("x"), f("y"), f("toX"), f("toY")) else { return err("missing x/y/toX/toY") };
|
||||
let m = mods(p);
|
||||
let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(8).clamp(1, 100);
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x, y)));
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(x, y), button: egui::PointerButton::Primary, pressed: true, modifiers: m });
|
||||
for i in 1..=steps {
|
||||
let k = i as f32 / steps as f32;
|
||||
app.synthetic.push(egui::Event::PointerMoved(egui::pos2(x + (tx - x) * k, y + (ty - y) * k)));
|
||||
}
|
||||
app.synthetic.push(egui::Event::PointerButton { pos: egui::pos2(tx, ty), button: egui::PointerButton::Primary, pressed: false, modifiers: m });
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.key" => {
|
||||
let Some(k) = s("key").and_then(key_from) else { return err("unknown key") };
|
||||
let m = mods(p);
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m });
|
||||
app.synthetic.push(egui::Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers: m });
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.text" => {
|
||||
let Some(t) = s("text") else { return err("missing text") };
|
||||
app.synthetic.push(egui::Event::Text(t.to_string()));
|
||||
ok(json!({"queued": true}))
|
||||
}
|
||||
"ui.screenshot" => Outcome::Screenshot { path: s("path").map(str::to_string) },
|
||||
"ui.render" => {
|
||||
let path = s("path").unwrap_or("/tmp/wordcraft-page.png");
|
||||
let page = p.get("page").and_then(Value::as_u64).unwrap_or(1);
|
||||
match app.session.run("file.exportPng", &json!({"path": path, "page": page, "scale": f("scale").unwrap_or(2.0)})) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
"ui.resize" => {
|
||||
let (Some(w), Some(h)) = (f("width"), f("height")) else { return err("missing width/height") };
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(w, h)));
|
||||
ok(json!({}))
|
||||
}
|
||||
"ui.focus" => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
app.canvas.want_focus = true;
|
||||
ok(json!({}))
|
||||
}
|
||||
"app.quit" => {
|
||||
app.quit_requested = true;
|
||||
ok(json!({}))
|
||||
}
|
||||
other => {
|
||||
// Anything else: a command id.
|
||||
match app.run(other, p.clone()) {
|
||||
Ok(v) => ok(v),
|
||||
Err(e) => err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_screenshot(image: &egui::ColorImage, path: Option<&str>) -> Value {
|
||||
let path = path.unwrap_or("/tmp/wordcraft-screenshot.png");
|
||||
let [w, h] = image.size;
|
||||
let bytes: Vec<u8> = image.pixels.iter().flat_map(|c| c.to_array()).collect();
|
||||
let rendered = wordcraft_render::Rendered { width: w as u32, height: h as u32, pixels: bytes };
|
||||
let png = rendered.to_png();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Err(e) = std::fs::write(path, &png) {
|
||||
return json!({"ok": false, "error": format!("{path}: {e}")});
|
||||
}
|
||||
}
|
||||
json!({"ok": true, "result": {"path": path, "width": w, "height": h}})
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark,
|
||||
//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About. Every dialog ends by
|
||||
//! running a command, so agents get the same result without the dialog.
|
||||
|
||||
use egui::{Sense, Ui, vec2};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, semibold};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(tag = "dialog", rename_all = "camelCase")]
|
||||
pub enum Dialog {
|
||||
Font { font: String, size: String, bold: bool, italic: bool, underline: bool, strike: bool, sup: bool, sub: bool, small_caps: bool, caps: bool, hidden: bool, color: String, spacing: f32 },
|
||||
Paragraph { align: String, left: f32, right: f32, first: f32, before: f32, after: f32, line: f32, keep_next: bool, keep_lines: bool, page_break: bool, widow: bool },
|
||||
Find { query: String, replace: String, match_case: bool, whole_word: bool, regex: bool, replace_mode: bool, message: String },
|
||||
Goto { page: String },
|
||||
InsertTable { rows: u32, cols: u32 },
|
||||
PageSetup { top: f32, bottom: f32, left: f32, right: f32, landscape: bool },
|
||||
Link { url: String, text: String },
|
||||
Bookmark { name: String },
|
||||
WordCount { stats: Value },
|
||||
Zoom { percent: f32 },
|
||||
Watermark { text: String, diagonal: bool },
|
||||
NewStyle { name: String, based_on: String },
|
||||
ModifyStyle { id: String, name: String, font: String, size: f32, bold: bool, italic: bool, color: String, before: f32, after: f32 },
|
||||
Commands { query: String },
|
||||
About,
|
||||
}
|
||||
|
||||
impl Dialog {
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
Dialog::Font { .. } => "font",
|
||||
Dialog::Paragraph { .. } => "paragraph",
|
||||
Dialog::Find { replace_mode: false, .. } => "find",
|
||||
Dialog::Find { .. } => "replace",
|
||||
Dialog::Goto { .. } => "goto",
|
||||
Dialog::InsertTable { .. } => "insertTable",
|
||||
Dialog::PageSetup { .. } => "pageSetup",
|
||||
Dialog::Link { .. } => "link",
|
||||
Dialog::Bookmark { .. } => "bookmark",
|
||||
Dialog::WordCount { .. } => "wordCount",
|
||||
Dialog::Zoom { .. } => "zoom",
|
||||
Dialog::Watermark { .. } => "watermark",
|
||||
Dialog::NewStyle { .. } => "newStyle",
|
||||
Dialog::ModifyStyle { .. } => "modifyStyle",
|
||||
Dialog::Commands { .. } => "commands",
|
||||
Dialog::About => "about",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(name: &str, app: &mut WordApp) -> Option<Dialog> {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let s = |k: &str| st.get(k).and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
Some(match name {
|
||||
"font" => Dialog::Font {
|
||||
font: s("font"),
|
||||
size: st.get("size").and_then(Value::as_f64).map(|v| v.to_string()).unwrap_or_default(),
|
||||
bold: b("bold"),
|
||||
italic: b("italic"),
|
||||
underline: b("underline"),
|
||||
strike: b("strike"),
|
||||
sup: b("superscript"),
|
||||
sub: b("subscript"),
|
||||
small_caps: false,
|
||||
caps: false,
|
||||
hidden: false,
|
||||
color: s("color"),
|
||||
spacing: 0.0,
|
||||
},
|
||||
"paragraph" => {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let rp = rp?;
|
||||
let line = match rp.line_spacing {
|
||||
wordcraft_doc::props::LineSpacing::Multiple(m) => m,
|
||||
_ => 1.0,
|
||||
};
|
||||
Dialog::Paragraph {
|
||||
align: format!("{:?}", rp.align).to_lowercase(),
|
||||
left: rp.indent_left / 72.0,
|
||||
right: rp.indent_right / 72.0,
|
||||
first: rp.indent_first / 72.0,
|
||||
before: rp.space_before,
|
||||
after: rp.space_after,
|
||||
line,
|
||||
keep_next: rp.keep_next,
|
||||
keep_lines: rp.keep_lines,
|
||||
page_break: rp.page_break_before,
|
||||
widow: rp.widow_control,
|
||||
}
|
||||
}
|
||||
"find" | "replace" => Dialog::Find {
|
||||
query: if app.session.sel.is_collapsed() { app.session.find.query.clone() } else { app.session.selected_text() },
|
||||
replace: app.session.find.replace.clone(),
|
||||
match_case: app.session.find.match_case,
|
||||
whole_word: app.session.find.whole_word,
|
||||
regex: app.session.find.regex,
|
||||
replace_mode: name == "replace",
|
||||
message: String::new(),
|
||||
},
|
||||
"goto" => Dialog::Goto { page: String::new() },
|
||||
"insertTable" => Dialog::InsertTable { rows: 2, cols: 5 },
|
||||
"pageSetup" => {
|
||||
let sp = wordcraft_engine::cmd::page::sect(&app.session);
|
||||
Dialog::PageSetup { top: sp.margin_top / 72.0, bottom: sp.margin_bottom / 72.0, left: sp.margin_left / 72.0, right: sp.margin_right / 72.0, landscape: sp.landscape }
|
||||
}
|
||||
"link" => Dialog::Link { url: "https://".into(), text: app.session.selected_text() },
|
||||
"bookmark" => Dialog::Bookmark { name: String::new() },
|
||||
"wordCount" => Dialog::WordCount { stats: app.session.run("review.wordCount", &json!({})).unwrap_or_default() },
|
||||
"zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() },
|
||||
"watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true },
|
||||
"newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() },
|
||||
"commands" => Dialog::Commands { query: String::new() },
|
||||
"about" => Dialog::About,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn modify_style(app: &WordApp, id: &str) -> Option<Dialog> {
|
||||
let st = app.session.doc.styles.get(id)?;
|
||||
let rc = app.session.doc.styles.resolve_char(if st.kind == wordcraft_doc::StyleKind::Paragraph { Some(id) } else { None }, &wordcraft_doc::CharProps { style: if st.kind == wordcraft_doc::StyleKind::Character { Some(id.into()) } else { None }, ..Default::default() });
|
||||
let rp = app.session.doc.styles.resolve_para(&wordcraft_doc::ParaProps { style: Some(id.into()), ..Default::default() });
|
||||
Some(Dialog::ModifyStyle {
|
||||
id: id.into(),
|
||||
name: st.name.clone(),
|
||||
font: rc.font,
|
||||
size: rc.size,
|
||||
bold: rc.bold,
|
||||
italic: rc.italic,
|
||||
color: match rc.color {
|
||||
wordcraft_doc::TextColor::Rgb(c) => c.hex(),
|
||||
wordcraft_doc::TextColor::Auto => "auto".into(),
|
||||
},
|
||||
before: rp.space_before,
|
||||
after: rp.space_after,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Grid picker for Insert › Table (hover to size, click to insert).
|
||||
pub fn table_grid_picker(ui: &mut Ui, app: &mut WordApp) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let id = egui::Id::new("table_picker_hover");
|
||||
let hover: (usize, usize) = ui.data(|d| d.get_temp(id)).unwrap_or((0, 0));
|
||||
ui.label(if hover.0 > 0 { format!("{}x{} Table", hover.1, hover.0) } else { "Insert Table".to_string() });
|
||||
let mut new_hover = (0, 0);
|
||||
let mut clicked = None;
|
||||
egui::Grid::new("tgp").spacing(vec2(2.0, 2.0)).show(ui, |ui| {
|
||||
for r in 1..=8 {
|
||||
for c in 1..=10 {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
|
||||
let on = r <= hover.0 && c <= hover.1;
|
||||
ui.painter().rect(rect, 1.0, if on { t.checked } else { t.input }, egui::Stroke::new(1.0, if on { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
if resp.hovered() {
|
||||
new_hover = (r, c);
|
||||
}
|
||||
if resp.clicked() {
|
||||
clicked = Some((r, c));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
ui.data_mut(|d| d.insert_temp(id, new_hover));
|
||||
if let Some((r, c)) = clicked {
|
||||
let _ = app.run("insert.table", json!({"rows": r, "cols": c}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let Some(mut d) = app.dialog.take() else { return };
|
||||
let mut open = true;
|
||||
let mut close = false;
|
||||
let title = match &d {
|
||||
Dialog::Font { .. } => "Font",
|
||||
Dialog::Paragraph { .. } => "Paragraph",
|
||||
Dialog::Find { replace_mode: false, .. } => "Find",
|
||||
Dialog::Find { .. } => "Find and Replace",
|
||||
Dialog::Goto { .. } => "Go To",
|
||||
Dialog::InsertTable { .. } => "Insert Table",
|
||||
Dialog::PageSetup { .. } => "Page Setup",
|
||||
Dialog::Link { .. } => "Insert Hyperlink",
|
||||
Dialog::Bookmark { .. } => "Bookmark",
|
||||
Dialog::WordCount { .. } => "Word Count",
|
||||
Dialog::Zoom { .. } => "Zoom",
|
||||
Dialog::Watermark { .. } => "Custom Watermark",
|
||||
Dialog::NewStyle { .. } => "Create New Style",
|
||||
Dialog::ModifyStyle { .. } => "Modify Style",
|
||||
Dialog::Commands { .. } => "Search Commands",
|
||||
Dialog::About => "About WordCraft",
|
||||
};
|
||||
egui::Window::new(title).collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, vec2(0.0, -40.0)).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_min_width(340.0);
|
||||
close = body(app, ui, &mut d);
|
||||
});
|
||||
if open && !close {
|
||||
app.dialog = Some(d);
|
||||
} else {
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn buttons(ui: &mut Ui, ok: &str) -> (bool, bool) {
|
||||
let mut r = (false, false);
|
||||
ui.add_space(8.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Cancel").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
r.1 = true;
|
||||
}
|
||||
let okb = ui.add(egui::Button::new(egui::RichText::new(ok).color(egui::Color32::WHITE)).fill(crate::theme::APP_COLOR));
|
||||
if okb.clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
r.0 = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
r
|
||||
}
|
||||
|
||||
/// Returns true to close.
|
||||
fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
match d {
|
||||
Dialog::Font { font, size, bold, italic, underline, strike, sup, sub, small_caps, caps, hidden, color, spacing } => {
|
||||
egui::Grid::new("fontdlg").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| {
|
||||
ui.label("Font:");
|
||||
ui.text_edit_singleline(font);
|
||||
ui.end_row();
|
||||
ui.label("Size:");
|
||||
ui.text_edit_singleline(size);
|
||||
ui.end_row();
|
||||
ui.label("Font color:");
|
||||
ui.text_edit_singleline(color);
|
||||
ui.end_row();
|
||||
ui.label("Spacing (pt):");
|
||||
ui.add(egui::DragValue::new(spacing).speed(0.1).range(-20.0..=20.0));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.separator();
|
||||
ui.label(egui::RichText::new("Effects").font(semibold(12.5)));
|
||||
egui::Grid::new("fx").num_columns(2).show(ui, |ui| {
|
||||
ui.checkbox(bold, "Bold");
|
||||
ui.checkbox(italic, "Italic");
|
||||
ui.end_row();
|
||||
ui.checkbox(underline, "Underline");
|
||||
ui.checkbox(strike, "Strikethrough");
|
||||
ui.end_row();
|
||||
ui.checkbox(sup, "Superscript");
|
||||
ui.checkbox(sub, "Subscript");
|
||||
ui.end_row();
|
||||
ui.checkbox(small_caps, "Small caps");
|
||||
ui.checkbox(caps, "All caps");
|
||||
ui.end_row();
|
||||
ui.checkbox(hidden, "Hidden");
|
||||
ui.end_row();
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let mut props = json!({
|
||||
"bold": *bold, "italic": *italic, "strike": *strike,
|
||||
"underline": if *underline { "single" } else { "none" },
|
||||
"vertAlign": if *sup { "superscript" } else if *sub { "subscript" } else { "baseline" },
|
||||
"smallCaps": *small_caps, "caps": *caps, "hidden": *hidden, "spacing": *spacing,
|
||||
});
|
||||
if !font.trim().is_empty() {
|
||||
props["font"] = json!(font.trim());
|
||||
}
|
||||
if let Ok(s) = size.trim().parse::<f64>() {
|
||||
props["size"] = json!(s);
|
||||
}
|
||||
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
|
||||
props["color"] = json!({"Rgb": [c.0, c.1, c.2]});
|
||||
}
|
||||
let _ = app.run("format.set", json!({"props": props}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Paragraph { align, left, right, first, before, after, line, keep_next, keep_lines, page_break, widow } => {
|
||||
ui.label(egui::RichText::new("General").font(semibold(12.5)));
|
||||
egui::ComboBox::from_label("Alignment").selected_text(align.clone()).show_ui(ui, |ui| {
|
||||
for a in ["left", "center", "right", "justify"] {
|
||||
ui.selectable_value(align, a.to_string(), a);
|
||||
}
|
||||
});
|
||||
ui.label(egui::RichText::new("Indentation").font(semibold(12.5)));
|
||||
egui::Grid::new("ind").num_columns(4).show(ui, |ui| {
|
||||
ui.label("Left:");
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label("Right:");
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label("First line:");
|
||||
ui.add(egui::DragValue::new(first).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new("Spacing").font(semibold(12.5)));
|
||||
egui::Grid::new("sp").num_columns(4).show(ui, |ui| {
|
||||
ui.label("Before:");
|
||||
ui.add(egui::DragValue::new(before).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.label("Line spacing:");
|
||||
ui.add(egui::DragValue::new(line).speed(0.05).range(0.5..=5.0));
|
||||
ui.end_row();
|
||||
ui.label("After:");
|
||||
ui.add(egui::DragValue::new(after).speed(1.0).range(0.0..=1584.0).suffix(" pt"));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new("Line and Page Breaks").font(semibold(12.5)));
|
||||
ui.checkbox(widow, "Widow/Orphan control");
|
||||
ui.checkbox(keep_next, "Keep with next");
|
||||
ui.checkbox(keep_lines, "Keep lines together");
|
||||
ui.checkbox(page_break, "Page break before");
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("para.align", json!({"value": align}));
|
||||
let _ = app.run("para.set", json!({"props": {
|
||||
"indentLeft": *left * 72.0, "indentRight": *right * 72.0, "indentFirst": *first * 72.0,
|
||||
"spaceBefore": *before, "spaceAfter": *after, "lineSpacing": {"rule": "multiple", "value": *line},
|
||||
"keepNext": *keep_next, "keepLines": *keep_lines, "pageBreakBefore": *page_break, "widowControl": *widow,
|
||||
}}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Find { query, replace, match_case, whole_word, regex, replace_mode, message } => {
|
||||
ui.horizontal(|ui| {
|
||||
if ui.selectable_label(!*replace_mode, "Find").clicked() {
|
||||
*replace_mode = false;
|
||||
}
|
||||
if ui.selectable_label(*replace_mode, "Replace").clicked() {
|
||||
*replace_mode = true;
|
||||
}
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Find what:");
|
||||
ui.add(egui::TextEdit::singleline(query).desired_width(240.0));
|
||||
});
|
||||
if *replace_mode {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Replace with:");
|
||||
ui.add(egui::TextEdit::singleline(replace).desired_width(228.0));
|
||||
});
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(match_case, "Match case");
|
||||
ui.checkbox(whole_word, "Whole words");
|
||||
ui.checkbox(regex, "Wildcards (regex)");
|
||||
});
|
||||
if !message.is_empty() {
|
||||
ui.label(egui::RichText::new(message.as_str()).weak());
|
||||
}
|
||||
let opts = json!({"text": query, "with": replace, "matchCase": *match_case, "wholeWord": *whole_word, "regex": *regex});
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
if *replace_mode {
|
||||
if ui.button("Replace All").clicked() {
|
||||
match app.run("edit.replaceAll", opts.clone()) {
|
||||
Ok(r) => *message = format!("All done. We made {} replacements.", r["replaced"]),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
if ui.button("Replace").clicked() {
|
||||
let _ = app.run("edit.replace", opts.clone());
|
||||
}
|
||||
}
|
||||
if ui.button("Find Next").clicked() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
let _ = app.session.run("edit.find", &opts);
|
||||
match app.run("edit.findNext", json!({})) {
|
||||
Ok(r) => *message = format!("Match {} of {}", r["index"].as_u64().unwrap_or(0) + 1, r["count"]),
|
||||
Err(e) => *message = e,
|
||||
}
|
||||
}
|
||||
if ui.button("Close").clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
close
|
||||
}
|
||||
Dialog::Goto { page } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Enter page number:");
|
||||
ui.text_edit_singleline(page);
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "Go To");
|
||||
if ok && let Ok(n) = page.trim().parse::<u64>() {
|
||||
let _ = app.run("edit.goto", json!({"page": n}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::InsertTable { rows, cols } => {
|
||||
egui::Grid::new("it").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Number of columns:");
|
||||
ui.add(egui::DragValue::new(cols).range(1..=63));
|
||||
ui.end_row();
|
||||
ui.label("Number of rows:");
|
||||
ui.add(egui::DragValue::new(rows).range(1..=1000));
|
||||
ui.end_row();
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("insert.table", json!({"rows": *rows, "cols": *cols}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::PageSetup { top, bottom, left, right, landscape } => {
|
||||
ui.label(egui::RichText::new("Margins").font(semibold(12.5)));
|
||||
egui::Grid::new("ps").num_columns(4).show(ui, |ui| {
|
||||
ui.label("Top:");
|
||||
ui.add(egui::DragValue::new(top).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label("Bottom:");
|
||||
ui.add(egui::DragValue::new(bottom).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
ui.label("Left:");
|
||||
ui.add(egui::DragValue::new(left).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.label("Right:");
|
||||
ui.add(egui::DragValue::new(right).speed(0.05).suffix("\"").max_decimals(2));
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new("Orientation").font(semibold(12.5)));
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(landscape, false, "Portrait");
|
||||
ui.radio_value(landscape, true, "Landscape");
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("layout.orientation", json!({"value": if *landscape { "landscape" } else { "portrait" }}));
|
||||
let _ = app.run("layout.margins", json!({"top": *top * 72.0, "bottom": *bottom * 72.0, "left": *left * 72.0, "right": *right * 72.0}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Link { url, text } => {
|
||||
egui::Grid::new("lnk").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Text to display:");
|
||||
ui.text_edit_singleline(text);
|
||||
ui.end_row();
|
||||
ui.label("Address:");
|
||||
ui.text_edit_singleline(url);
|
||||
ui.end_row();
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("insert.link", json!({"url": url, "text": text}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Bookmark { name } => {
|
||||
ui.label("Bookmark name:");
|
||||
ui.text_edit_singleline(name);
|
||||
let marks = app.session.doc.bookmarks();
|
||||
for (n, _) in &marks {
|
||||
if ui.selectable_label(false, n).clicked() {
|
||||
let _ = app.run("edit.goto", json!({"bookmark": n}));
|
||||
}
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, "Add");
|
||||
if ok {
|
||||
let _ = app.run("insert.bookmark", json!({"name": name}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::WordCount { stats } => {
|
||||
egui::Grid::new("wc").num_columns(2).spacing(vec2(30.0, 6.0)).show(ui, |ui| {
|
||||
for (l, k) in [("Pages", "pages"), ("Words", "words"), ("Characters (no spaces)", "characters"), ("Characters (with spaces)", "charactersWithSpaces"), ("Paragraphs", "paragraphs"), ("Lines", "lines")] {
|
||||
ui.label(l);
|
||||
ui.label(stats.get(k).map(|v| v.to_string()).unwrap_or_default());
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "Close");
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Zoom { percent } => {
|
||||
ui.horizontal(|ui| {
|
||||
for p in [200.0, 100.0, 75.0] {
|
||||
ui.radio_value(percent, p, format!("{p}%"));
|
||||
}
|
||||
});
|
||||
ui.add(egui::Slider::new(percent, 10.0..=500.0).suffix("%"));
|
||||
let mut fit = None;
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Page width").clicked() {
|
||||
fit = Some("pageWidth");
|
||||
}
|
||||
if ui.button("Whole page").clicked() {
|
||||
fit = Some("onePage");
|
||||
}
|
||||
if ui.button("Many pages").clicked() {
|
||||
fit = Some("multiplePages");
|
||||
}
|
||||
});
|
||||
if let Some(f) = fit {
|
||||
let _ = app.run("view.zoom", json!({"value": f}));
|
||||
return true;
|
||||
}
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("view.zoom", json!({"value": *percent}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Watermark { text, diagonal } => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.label("Text:");
|
||||
ui.text_edit_singleline(text);
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.radio_value(diagonal, true, "Diagonal");
|
||||
ui.radio_value(diagonal, false, "Horizontal");
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("design.watermark", json!({"text": text, "diagonal": *diagonal}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::NewStyle { name, based_on } => {
|
||||
egui::Grid::new("ns").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
ui.label("Style based on:");
|
||||
ui.text_edit_singleline(based_on);
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(egui::RichText::new("The new style takes the formatting of the current paragraph.").small().weak());
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let _ = app.run("styles.create", json!({"name": name, "basedOn": based_on}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::ModifyStyle { id, name, font, size, bold, italic, color, before, after } => {
|
||||
egui::Grid::new("ms").num_columns(2).show(ui, |ui| {
|
||||
ui.label("Name:");
|
||||
ui.text_edit_singleline(name);
|
||||
ui.end_row();
|
||||
ui.label("Font:");
|
||||
ui.text_edit_singleline(font);
|
||||
ui.end_row();
|
||||
ui.label("Size:");
|
||||
ui.add(egui::DragValue::new(size).range(1.0..=1638.0).speed(0.5));
|
||||
ui.end_row();
|
||||
ui.label("Color:");
|
||||
ui.text_edit_singleline(color);
|
||||
ui.end_row();
|
||||
ui.label("Space before/after:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(before).range(0.0..=1584.0));
|
||||
ui.add(egui::DragValue::new(after).range(0.0..=1584.0));
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.horizontal(|ui| {
|
||||
ui.checkbox(bold, "Bold");
|
||||
ui.checkbox(italic, "Italic");
|
||||
});
|
||||
let (ok, cancel) = buttons(ui, "OK");
|
||||
if ok {
|
||||
let mut chr = json!({"font": font, "size": *size, "bold": *bold, "italic": *italic});
|
||||
if let Some(c) = wordcraft_doc::Rgb::parse(color) {
|
||||
chr["color"] = json!({"Rgb": [c.0, c.1, c.2]});
|
||||
}
|
||||
let _ = app.run("styles.modify", json!({"style": id, "name": name, "chr": chr, "para": {"spaceBefore": *before, "spaceAfter": *after}}));
|
||||
}
|
||||
ok || cancel
|
||||
}
|
||||
Dialog::Commands { query } => {
|
||||
let r = ui.add(egui::TextEdit::singleline(query).hint_text("Type a command, e.g. \"insert table\"").desired_width(380.0));
|
||||
r.request_focus();
|
||||
let q = query.to_lowercase();
|
||||
let reg = app.session.registry.clone();
|
||||
let mut hits: Vec<&wordcraft_engine::CommandSpec> = reg.all().iter().filter(|c| !q.is_empty() && (c.label.to_lowercase().contains(&q) || c.id.to_lowercase().contains(&q) || c.location.to_lowercase().contains(&q))).collect();
|
||||
hits.truncate(14);
|
||||
let mut close = false;
|
||||
for c in hits {
|
||||
let sc = crate::widgets::shortcut_text(app, c.id);
|
||||
let label = format!("{} — {}", c.label, c.location);
|
||||
if ui.add(egui::Button::new(label).shortcut_text(sc).min_size(vec2(380.0, 0.0))).clicked() {
|
||||
let _ = app.run(c.id, json!({}));
|
||||
close = true;
|
||||
}
|
||||
}
|
||||
close || ui.input(|i| i.key_pressed(egui::Key::Escape))
|
||||
}
|
||||
Dialog::About => {
|
||||
ui.label(egui::RichText::new("WordCraft").font(semibold(22.0)));
|
||||
ui.label(format!("Version {}", env!("CARGO_PKG_VERSION")));
|
||||
ui.label("A free, open-source word processor written from scratch in Rust.\nPart of the Crafting Apps from the ArtCraft team.");
|
||||
ui.add_space(6.0);
|
||||
ui.hyperlink_to("getartcraft.com/apps/wordcraft", "https://getartcraft.com/apps/wordcraft");
|
||||
ui.hyperlink_to("Join us on Discord: discord.gg/artcraft", "https://discord.gg/artcraft");
|
||||
ui.hyperlink_to("Source code: github.com/storytold/wordcraft", "https://github.com/storytold/wordcraft");
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("MIT OR Apache-2.0. Copyright (c) 2026 ArtCraft Team and the WordCraft contributors.").small().weak());
|
||||
let (ok, cancel) = buttons(ui, "Close");
|
||||
ok || cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,175 @@
|
||||
//! Keyboard: typing, editing keys and command shortcuts (from the registry's `shortcut` fields).
|
||||
|
||||
use egui::{Key, Modifiers};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::WordApp;
|
||||
|
||||
/// The registry's name for a key.
|
||||
pub fn key_name(k: Key) -> Option<&'static str> {
|
||||
Some(match k {
|
||||
Key::ArrowLeft => "Left",
|
||||
Key::ArrowRight => "Right",
|
||||
Key::ArrowUp => "Up",
|
||||
Key::ArrowDown => "Down",
|
||||
Key::Home => "Home",
|
||||
Key::End => "End",
|
||||
Key::PageUp => "PageUp",
|
||||
Key::PageDown => "PageDown",
|
||||
Key::Enter => "Enter",
|
||||
Key::Tab => "Tab",
|
||||
Key::Backspace => "Backspace",
|
||||
Key::Delete => "Delete",
|
||||
Key::Escape => "Escape",
|
||||
Key::Space => "Space",
|
||||
Key::Equals | Key::Plus => "=",
|
||||
Key::Minus => "-",
|
||||
Key::Period => ".",
|
||||
Key::Comma => ",",
|
||||
Key::OpenBracket => "[",
|
||||
Key::CloseBracket => "]",
|
||||
Key::Num0 => "0",
|
||||
Key::Num1 => "1",
|
||||
Key::Num2 => "2",
|
||||
Key::Num3 => "3",
|
||||
Key::Num5 => "5",
|
||||
Key::Num8 => "8",
|
||||
Key::Num9 => "9",
|
||||
Key::F3 => "F3",
|
||||
Key::F5 => "F5",
|
||||
Key::F7 => "F7",
|
||||
Key::F9 => "F9",
|
||||
Key::F12 => "F12",
|
||||
Key::A => "A",
|
||||
Key::B => "B",
|
||||
Key::C => "C",
|
||||
Key::D => "D",
|
||||
Key::E => "E",
|
||||
Key::F => "F",
|
||||
Key::G => "G",
|
||||
Key::H => "H",
|
||||
Key::I => "I",
|
||||
Key::J => "J",
|
||||
Key::K => "K",
|
||||
Key::L => "L",
|
||||
Key::M => "M",
|
||||
Key::N => "N",
|
||||
Key::O => "O",
|
||||
Key::P => "P",
|
||||
Key::R => "R",
|
||||
Key::S => "S",
|
||||
Key::T => "T",
|
||||
Key::U => "U",
|
||||
Key::V => "V",
|
||||
Key::W => "W",
|
||||
Key::X => "X",
|
||||
Key::Y => "Y",
|
||||
Key::Z => "Z",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn combo(m: Modifiers, key: &str, with_shift: bool) -> String {
|
||||
let mut s = String::new();
|
||||
if m.command {
|
||||
s.push_str("Mod+");
|
||||
}
|
||||
if m.ctrl && !m.command {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.mac_cmd && m.ctrl {
|
||||
s.push_str("Ctrl+");
|
||||
}
|
||||
if m.alt {
|
||||
s.push_str("Alt+");
|
||||
}
|
||||
if m.shift && with_shift {
|
||||
s.push_str("Shift+");
|
||||
}
|
||||
s.push_str(key);
|
||||
s
|
||||
}
|
||||
|
||||
/// Find and run the command bound to this key. Returns true if handled.
|
||||
fn dispatch(app: &mut WordApp, key: Key, m: Modifiers) -> bool {
|
||||
let Some(name) = key_name(key) else { return false };
|
||||
let reg = app.session.registry.clone();
|
||||
if let Some(spec) = reg.by_shortcut(&combo(m, name, true)) {
|
||||
let _ = app.run(spec.id, json!({}));
|
||||
return true;
|
||||
}
|
||||
// Shift extends caret movement.
|
||||
if m.shift
|
||||
&& let Some(spec) = reg.by_shortcut(&combo(m, name, false))
|
||||
&& spec.id.starts_with("caret.")
|
||||
{
|
||||
let _ = app.run(spec.id, json!({"extend": true}));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Events for the focused canvas: text, editing keys, clipboard, IME.
|
||||
pub fn canvas_events(app: &mut WordApp, ctx: &egui::Context) {
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
for e in events {
|
||||
match e {
|
||||
egui::Event::Text(t) => {
|
||||
let m = ctx.input(|i| i.modifiers);
|
||||
if m.command || (m.ctrl && !cfg!(target_os = "macos")) {
|
||||
continue;
|
||||
}
|
||||
if t.chars().all(|c| !c.is_control()) && !t.is_empty() {
|
||||
let _ = app.run("text.insert", json!({"text": t}));
|
||||
}
|
||||
}
|
||||
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) => app.canvas.ime_preedit = text,
|
||||
egui::Event::Ime(egui::ImeEvent::Commit(text)) => {
|
||||
app.canvas.ime_preedit.clear();
|
||||
if !text.is_empty() {
|
||||
let _ = app.run("text.insert", json!({"text": text}));
|
||||
}
|
||||
}
|
||||
egui::Event::Paste(t) => {
|
||||
let id = if ctx.input(|i| i.modifiers.shift && i.modifiers.alt) { "edit.pasteText" } else { "edit.paste" };
|
||||
let _ = app.run(id, json!({"text": t}));
|
||||
}
|
||||
egui::Event::Copy | egui::Event::Cut => {
|
||||
let id = if matches!(e, egui::Event::Cut) { "edit.cut" } else { "edit.copy" };
|
||||
if let Ok(r) = app.run(id, json!({}))
|
||||
&& let Some(t) = r.get("text").and_then(Value::as_str)
|
||||
&& !t.is_empty()
|
||||
{
|
||||
ctx.copy_text(t.to_string());
|
||||
}
|
||||
}
|
||||
egui::Event::Key { key, pressed: true, modifiers, .. } => {
|
||||
if key == Key::Escape && app.session.painter.is_some() {
|
||||
app.session.painter = None;
|
||||
continue;
|
||||
}
|
||||
if key == Key::Escape && matches!(app.session.sel.focus.story, wordcraft_doc::StoryRef::Part(_)) && app.session.sel.is_collapsed() {
|
||||
let _ = app.run("insert.closeHeader", json!({}));
|
||||
continue;
|
||||
}
|
||||
dispatch(app, key, modifiers);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Command shortcuts when the canvas isn't focused but no text field is either.
|
||||
pub fn global_shortcuts(app: &mut WordApp, ctx: &egui::Context) {
|
||||
if app.canvas.focused || ctx.egui_wants_keyboard_input() {
|
||||
return;
|
||||
}
|
||||
let events = ctx.input(|i| i.events.clone());
|
||||
for e in events {
|
||||
if let egui::Event::Key { key, pressed: true, modifiers, .. } = e
|
||||
&& (modifiers.command || matches!(key, Key::F3 | Key::F5 | Key::F7 | Key::F9 | Key::F12))
|
||||
{
|
||||
dispatch(app, key, modifiers);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
//! WordCraft's egui front end: a Word-style window (title bar with Quick Access Toolbar, ribbon,
|
||||
//! rulers, page canvas, panes, status bar, Backstage, dialogs) over `wordcraft-engine`.
|
||||
//!
|
||||
//! The UI is thin: it reads `Session` state and acts through [`WordApp::run`] (command ids), so
|
||||
//! the menus, ribbon, shortcuts, control channel and MCP all reach the same behaviour.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod backstage;
|
||||
pub mod canvas;
|
||||
pub mod chrome;
|
||||
pub mod control;
|
||||
pub mod dialogs;
|
||||
pub mod icons;
|
||||
pub mod keys;
|
||||
pub mod panes;
|
||||
pub mod previews;
|
||||
pub mod ribbon;
|
||||
pub mod theme;
|
||||
pub mod widgets;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_engine::Session;
|
||||
|
||||
pub use control::ControlRequest;
|
||||
|
||||
/// Platform services injected by the host (file dialogs, file I/O).
|
||||
#[derive(Default)]
|
||||
pub struct Services {
|
||||
/// Pick a file to open; the argument says what for (`document`, `picture`).
|
||||
pub pick_open: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
/// Pick a path to save to, given a suggested name.
|
||||
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
|
||||
}
|
||||
|
||||
/// UI state that persists between runs (and that agents can read and set).
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct UiState {
|
||||
pub tab: String,
|
||||
pub backstage: bool,
|
||||
pub backstage_page: String,
|
||||
pub ribbon_collapsed: bool,
|
||||
pub recent: Vec<String>,
|
||||
pub dark: bool,
|
||||
pub nav_tab: String,
|
||||
pub show_discord: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
UiState {
|
||||
tab: "Home".into(),
|
||||
backstage: false,
|
||||
backstage_page: "home".into(),
|
||||
ribbon_collapsed: false,
|
||||
recent: Vec::new(),
|
||||
dark: false,
|
||||
nav_tab: "headings".into(),
|
||||
show_discord: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The application.
|
||||
pub struct WordApp {
|
||||
pub session: Session,
|
||||
pub ui: UiState,
|
||||
pub services: Services,
|
||||
pub canvas: canvas::CanvasState,
|
||||
pub dialog: Option<dialogs::Dialog>,
|
||||
pub status_msg: Option<(String, f64)>,
|
||||
pub previews: previews::Previews,
|
||||
/// macOS: the window has no title bar; leave room for the traffic lights.
|
||||
pub integrated_titlebar: bool,
|
||||
control_rx: Option<std::sync::mpsc::Receiver<ControlRequest>>,
|
||||
shot_token: u64,
|
||||
queued_shots: Vec<(u64, f64, u32)>,
|
||||
pending_shots: Vec<(u64, Option<String>, std::sync::mpsc::Sender<Value>, f64)>,
|
||||
pub(crate) synthetic: Vec<egui::Event>,
|
||||
styled: bool,
|
||||
fonts_frames: u32,
|
||||
applied_dark: Option<bool>,
|
||||
pub frame_ms: f64,
|
||||
pub quit_requested: bool,
|
||||
pub autosave: bool,
|
||||
pub word_count: (u64, usize),
|
||||
last_autosave: f64,
|
||||
}
|
||||
|
||||
impl WordApp {
|
||||
pub fn new(session: Session, services: Services) -> Self {
|
||||
WordApp {
|
||||
session,
|
||||
ui: UiState::default(),
|
||||
services,
|
||||
canvas: canvas::CanvasState::default(),
|
||||
dialog: None,
|
||||
status_msg: None,
|
||||
previews: previews::Previews::default(),
|
||||
integrated_titlebar: false,
|
||||
control_rx: None,
|
||||
shot_token: 0,
|
||||
queued_shots: Vec::new(),
|
||||
pending_shots: Vec::new(),
|
||||
synthetic: Vec::new(),
|
||||
styled: false,
|
||||
fonts_frames: 0,
|
||||
applied_dark: None,
|
||||
frame_ms: 0.0,
|
||||
quit_requested: false,
|
||||
autosave: true,
|
||||
word_count: (0, 0),
|
||||
last_autosave: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_control(mut self, rx: std::sync::mpsc::Receiver<ControlRequest>) -> Self {
|
||||
self.control_rx = Some(rx);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run a command; UI-level commands (`ui.*`) are handled here, the rest by the engine.
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
if let Some(r) = self.ui_command(id, ¶ms) {
|
||||
return r;
|
||||
}
|
||||
let r = self.session.run(id, ¶ms).map_err(|e| e.to_string());
|
||||
self.after_command(id);
|
||||
if let Err(e) = &r {
|
||||
self.status(e.clone());
|
||||
}
|
||||
r
|
||||
}
|
||||
|
||||
fn after_command(&mut self, id: &str) {
|
||||
self.canvas.caret_visible_since = now_ms();
|
||||
if !id.starts_with("view.") && !id.starts_with("document.") && !id.starts_with("format.state") {
|
||||
self.canvas.scroll_to_caret = true;
|
||||
}
|
||||
for req in std::mem::take(&mut self.session.ui_requests) {
|
||||
self.handle_request(&req);
|
||||
}
|
||||
if let Some(p) = self.session.path.as_ref().map(|p| p.to_string_lossy().to_string()) {
|
||||
if self.ui.recent.first() != Some(&p) {
|
||||
self.ui.recent.retain(|r| *r != p);
|
||||
self.ui.recent.insert(0, p);
|
||||
self.ui.recent.truncate(12);
|
||||
}
|
||||
}
|
||||
if !self.session.status.is_empty() {
|
||||
let s = std::mem::take(&mut self.session.status);
|
||||
self.status(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// Requests commands make of the UI (open a dialog…).
|
||||
fn handle_request(&mut self, req: &Value) {
|
||||
if let Some(what) = req.get("open").and_then(Value::as_str) {
|
||||
match what {
|
||||
"openFile" => self.open_dialog(),
|
||||
"saveAs" => self.save_as_dialog(),
|
||||
"insertPicture" => self.pick_picture(),
|
||||
"print" => {
|
||||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "print".into();
|
||||
}
|
||||
"options" => {
|
||||
self.ui.backstage = true;
|
||||
self.ui.backstage_page = "options".into();
|
||||
}
|
||||
other => self.dialog = dialogs::Dialog::open(other, self),
|
||||
}
|
||||
}
|
||||
if req.get("close").is_some() {
|
||||
self.quit_requested = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands that live in the UI layer.
|
||||
fn ui_command(&mut self, id: &str, p: &Value) -> Option<Result<Value, String>> {
|
||||
let s = |k: &str| p.get(k).and_then(Value::as_str);
|
||||
Some(Ok(match id {
|
||||
"ui.tab" => {
|
||||
let tab = s("tab").unwrap_or("Home").to_string();
|
||||
if tab == "File" {
|
||||
self.ui.backstage = true;
|
||||
} else {
|
||||
self.ui.tab = tab;
|
||||
self.ui.backstage = false;
|
||||
}
|
||||
json!({"tab": self.ui.tab})
|
||||
}
|
||||
"ui.backstage" => {
|
||||
self.ui.backstage = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.backstage);
|
||||
if let Some(pg) = s("page") {
|
||||
self.ui.backstage_page = pg.into();
|
||||
}
|
||||
json!({"backstage": self.ui.backstage})
|
||||
}
|
||||
"ui.dialog" => {
|
||||
self.dialog = dialogs::Dialog::open(s("name").unwrap_or(""), self);
|
||||
json!({"dialog": self.dialog.as_ref().map(|d| d.name())})
|
||||
}
|
||||
"ui.closeDialog" => {
|
||||
self.dialog = None;
|
||||
json!({})
|
||||
}
|
||||
"ui.collapseRibbon" => {
|
||||
self.ui.ribbon_collapsed = !self.ui.ribbon_collapsed;
|
||||
json!({"collapsed": self.ui.ribbon_collapsed})
|
||||
}
|
||||
"ui.dark" => {
|
||||
self.ui.dark = p.get("value").and_then(Value::as_bool).unwrap_or(!self.ui.dark);
|
||||
json!({"dark": self.ui.dark})
|
||||
}
|
||||
"ui.openFileDialog" => {
|
||||
self.open_dialog();
|
||||
json!({})
|
||||
}
|
||||
"ui.discord" => {
|
||||
self.canvas.open_url = Some("https://discord.gg/artcraft".into());
|
||||
json!({})
|
||||
}
|
||||
_ => return None,
|
||||
}))
|
||||
}
|
||||
|
||||
pub fn status(&mut self, s: impl Into<String>) {
|
||||
self.status_msg = Some((s.into(), now_ms()));
|
||||
}
|
||||
|
||||
fn open_dialog(&mut self) {
|
||||
let picked = self.services.pick_open.as_ref().and_then(|f| f("document"));
|
||||
if let Some(path) = picked {
|
||||
let _ = self.run("file.open", json!({"path": path}));
|
||||
self.ui.backstage = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_as_dialog(&mut self) {
|
||||
let name = self.title_stem() + ".docx";
|
||||
let picked = self.services.pick_save.as_ref().and_then(|f| f(&name));
|
||||
if let Some(path) = picked {
|
||||
let _ = self.run("file.save", json!({"path": path}));
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_picture(&mut self) {
|
||||
let picked = self.services.pick_open.as_ref().and_then(|f| f("picture"));
|
||||
if let Some(path) = picked {
|
||||
let _ = self.run("insert.picture", json!({"path": path}));
|
||||
}
|
||||
}
|
||||
|
||||
/// Document title for the title bar.
|
||||
pub fn title_stem(&self) -> String {
|
||||
match &self.session.path {
|
||||
Some(p) => p.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| "Document1".into()),
|
||||
None => {
|
||||
if self.session.doc.core.title.is_empty() {
|
||||
"Document1".into()
|
||||
} else {
|
||||
self.session.doc.core.title.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-frame logic before layout: control requests, screenshots, shortcuts, file drops.
|
||||
pub fn logic(&mut self, ctx: &egui::Context) {
|
||||
if !self.styled {
|
||||
theme::install_fonts(ctx);
|
||||
self.styled = true;
|
||||
}
|
||||
let dark = self.ui.dark || self.session.view.dark_mode;
|
||||
if self.applied_dark != Some(dark) {
|
||||
theme::apply(ctx, &if dark { theme::Tokens::dark() } else { theme::Tokens::light() });
|
||||
self.applied_dark = Some(dark);
|
||||
}
|
||||
self.drain_control(ctx);
|
||||
// AutoSave: write a saved document a couple of seconds after the last change.
|
||||
let now = now_ms();
|
||||
if self.autosave && self.session.dirty && self.session.path.is_some() && now - self.last_autosave > 2500.0 && now - self.canvas.caret_visible_since > 1500.0 {
|
||||
self.last_autosave = now;
|
||||
let ext = self.session.path.as_ref().and_then(|p| p.extension()).map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default();
|
||||
if ext == "docx" || ext == "json" || ext == "odt" || ext == "rtf" {
|
||||
let _ = self.session.run("file.save", &json!({}));
|
||||
}
|
||||
}
|
||||
if self.autosave && self.session.dirty && self.session.path.is_some() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(1000));
|
||||
}
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
if !self.synthetic.is_empty() {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
{
|
||||
let path = f.path().to_string_lossy().to_string();
|
||||
let lp = path.to_ascii_lowercase();
|
||||
let img = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"].iter().any(|e| lp.ends_with(e));
|
||||
let _ = if img { self.run("insert.picture", json!({"path": path})) } else { self.run("file.open", json!({"path": path})) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject synthetic input events from the control channel (one pointer event per frame).
|
||||
pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) {
|
||||
if self.synthetic.is_empty() {
|
||||
return;
|
||||
}
|
||||
let n = match self.synthetic.first() {
|
||||
Some(egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. }) => 1,
|
||||
_ => self.synthetic.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(self.synthetic.len(), |i| i + 1),
|
||||
};
|
||||
if let Some(egui::Event::PointerMoved(p) | egui::Event::PointerButton { pos: p, .. }) = self.synthetic.first() {
|
||||
raw.events.push(egui::Event::PointerMoved(*p));
|
||||
}
|
||||
let n = n.min(self.synthetic.len());
|
||||
raw.events.extend(self.synthetic.drain(..n));
|
||||
}
|
||||
|
||||
/// Lay out the window.
|
||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||
let t0 = now_ms();
|
||||
let ctx = ui.ctx().clone();
|
||||
// Fonts installed by `logic` take effect on the next frame.
|
||||
if self.fonts_frames < 2 {
|
||||
self.fonts_frames += 1;
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
let t = theme::Tokens::get(&ctx);
|
||||
if self.ui.backstage {
|
||||
backstage::show(self, ui);
|
||||
} else {
|
||||
chrome::title_bar(self, ui);
|
||||
ribbon::show(self, ui);
|
||||
chrome::status_bar(self, ui);
|
||||
panes::show(self, ui);
|
||||
egui::CentralPanel::default().frame(egui::Frame::NONE.fill(t.canvas)).show(ui, |ui| {
|
||||
canvas::show(self, ui);
|
||||
});
|
||||
}
|
||||
dialogs::show(self, &ctx);
|
||||
keys::global_shortcuts(self, &ctx);
|
||||
if let Some(url) = self.canvas.open_url.take() {
|
||||
ctx.open_url(egui::OpenUrl::new_tab(url));
|
||||
}
|
||||
let title = format!("{}{} - WordCraft", self.title_stem(), if self.session.dirty { " •" } else { "" });
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Title(title));
|
||||
self.frame_ms = now_ms() - t0;
|
||||
}
|
||||
|
||||
fn drain_control(&mut self, ctx: &egui::Context) {
|
||||
let Some(rx) = self.control_rx.take() else { return };
|
||||
while let Ok(req) = rx.try_recv() {
|
||||
let reply = req.reply.clone();
|
||||
match control::handle(self, ctx, &req) {
|
||||
control::Outcome::Done(v) => {
|
||||
let _ = reply.send(v);
|
||||
}
|
||||
control::Outcome::Screenshot { path } => {
|
||||
self.shot_token += 1;
|
||||
let token = self.shot_token;
|
||||
self.queued_shots.push((token, now_ms() + 120.0, 0));
|
||||
self.pending_shots.push((token, path, reply, now_ms() + 8000.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.control_rx = Some(rx);
|
||||
}
|
||||
|
||||
fn issue_screenshots(&mut self, ctx: &egui::Context) {
|
||||
let now = now_ms();
|
||||
self.queued_shots.retain_mut(|(token, at, frames)| {
|
||||
*frames += 1;
|
||||
if now >= *at && *frames >= 3 {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot(egui::UserData::new(*token)));
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
if !self.queued_shots.is_empty() || !self.pending_shots.is_empty() {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(16));
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_screenshots(&mut self, ctx: &egui::Context) {
|
||||
if self.pending_shots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let events: Vec<_> = ctx.input(|i| {
|
||||
i.raw
|
||||
.events
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
egui::Event::Screenshot { user_data, image, .. } => {
|
||||
let token = user_data.data.as_ref().and_then(|d| d.downcast_ref::<u64>()).copied()?;
|
||||
Some((token, image.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
for (token, image) in events {
|
||||
if let Some(i) = self.pending_shots.iter().position(|(t, ..)| *t == token) {
|
||||
let (_, path, reply, _) = self.pending_shots.remove(i);
|
||||
let _ = reply.send(control::save_screenshot(&image, path.as_deref()));
|
||||
}
|
||||
}
|
||||
let now = now_ms();
|
||||
self.pending_shots.retain(|(_, _, reply, deadline)| {
|
||||
if now < *deadline {
|
||||
return true;
|
||||
}
|
||||
let _ = reply.send(json!({"ok": false, "error": "no frame was presented (window hidden?); use ui.render"}));
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn now_ms() -> f64 {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs_f64() * 1000.0).unwrap_or(0.0)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! Side panes: Navigation (headings, pages, search results), Styles, Comments.
|
||||
|
||||
use egui::{Stroke, Ui, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::{Pos, StoryRef};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, regular, semibold};
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if app.session.view.nav_pane {
|
||||
egui::Panel::left("nav_pane").default_size(260.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| nav(app, ui));
|
||||
}
|
||||
if app.session.view.styles_pane {
|
||||
egui::Panel::right("styles_pane").default_size(250.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| styles(app, ui));
|
||||
}
|
||||
if app.session.view.comments_pane {
|
||||
egui::Panel::right("comments_pane").default_size(290.0).resizable(true).frame(egui::Frame::NONE.fill(t.panel).inner_margin(10).stroke(Stroke::new(1.0, t.border))).show(ui, |ui| comments(app, ui));
|
||||
}
|
||||
}
|
||||
|
||||
fn header(ui: &mut Ui, title: &str) -> bool {
|
||||
let mut close = false;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(title).font(semibold(15.0)));
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.small_button("✕").clicked() {
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
close
|
||||
}
|
||||
|
||||
fn nav(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Navigation") {
|
||||
let _ = app.run("view.navigationPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
let qid = egui::Id::new("nav_query");
|
||||
let mut q = ui.data(|d| d.get_temp::<String>(qid)).unwrap_or_default();
|
||||
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search document").desired_width(f32::INFINITY));
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(qid, q.clone()));
|
||||
if !q.is_empty() {
|
||||
let _ = app.session.run("edit.find", &json!({"text": q}));
|
||||
} else {
|
||||
app.session.find.results.clear();
|
||||
}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
for (k, l) in [("headings", "Headings"), ("pages", "Pages"), ("results", "Results")] {
|
||||
if ui.selectable_label(app.ui.nav_tab == k, l).clicked() {
|
||||
app.ui.nav_tab = k.into();
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| match app.ui.nav_tab.as_str() {
|
||||
"pages" => {
|
||||
let l = app.session.layout();
|
||||
for (i, p) in l.pages.iter().enumerate() {
|
||||
let label = format!("Page {}", p.number);
|
||||
if ui.selectable_label(app.session.page_hint == i, label).clicked() {
|
||||
let _ = app.run("edit.goto", json!({"page": i + 1}));
|
||||
}
|
||||
}
|
||||
}
|
||||
"results" => {
|
||||
let results = app.session.find.results.clone();
|
||||
ui.label(egui::RichText::new(format!("{} results", results.len())).small().weak());
|
||||
for (a, b) in results.iter().take(500) {
|
||||
let Some(p) = app.session.doc.para_at(a) else { continue };
|
||||
let from = p.clamp(a.off.saturating_sub(30));
|
||||
let to = p.clamp((b.off + 40).min(p.len()));
|
||||
let pre = p.text.get(from..a.off).unwrap_or("");
|
||||
let hit = p.text.get(a.off..b.off).unwrap_or("");
|
||||
let post = p.text.get(b.off..to).unwrap_or("");
|
||||
let mut job = egui::text::LayoutJob::default();
|
||||
let fmt = |c| egui::TextFormat { font_id: regular(12.0), color: c, ..Default::default() };
|
||||
job.append(pre, 0.0, fmt(ui.visuals().text_color()));
|
||||
job.append(hit, 0.0, egui::TextFormat { font_id: semibold(12.0), background: egui::Color32::from_rgb(0xFF, 0xF1, 0x76), color: egui::Color32::BLACK, ..Default::default() });
|
||||
job.append(post, 0.0, fmt(ui.visuals().text_color()));
|
||||
job.wrap.max_width = ui.available_width();
|
||||
if ui.add(egui::Button::new(job).frame(false)).clicked() {
|
||||
let _ = app.run("select.range", json!({"anchor": a, "focus": b}));
|
||||
}
|
||||
ui.separator();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let caret = app.session.sel.focus.clone();
|
||||
let mut current: Option<usize> = None;
|
||||
let mut items = Vec::new();
|
||||
for path in app.session.doc.para_paths(StoryRef::Body) {
|
||||
let Some(p) = app.session.doc.para(StoryRef::Body, &path) else { continue };
|
||||
let rp = app.session.doc.styles.resolve_para(&p.props);
|
||||
if let Some(lv) = rp.outline_level {
|
||||
let txt = p.plain_text().trim().to_string();
|
||||
if txt.is_empty() || rp.style.starts_with("TOC") {
|
||||
continue;
|
||||
}
|
||||
if path <= caret.path {
|
||||
current = Some(items.len());
|
||||
}
|
||||
items.push((path, lv, txt));
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
ui.label(egui::RichText::new("Create an interactive outline of your document.\n\nIt's a great way to keep track of where you are or quickly move your content around.\n\nTo get started, go to the Home tab and apply Heading styles to the headings in your document.").weak());
|
||||
}
|
||||
for (i, (path, lv, txt)) in items.into_iter().enumerate() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(lv as f32 * 14.0);
|
||||
let r = ui.selectable_label(current == Some(i), egui::RichText::new(txt).font(if lv == 0 { semibold(12.5) } else { regular(12.5) }));
|
||||
if r.clicked() {
|
||||
let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn styles(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Styles") {
|
||||
let _ = app.run("view.stylesPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let cur = st.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
|
||||
ui.label(egui::RichText::new(format!("Current style: {}", st.get("styleName").and_then(Value::as_str).unwrap_or(""))).small());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("New Style…").clicked() {
|
||||
app.dialog = crate::dialogs::Dialog::open("newStyle", app);
|
||||
}
|
||||
if ui.button("Update to Match").clicked() {
|
||||
let _ = app.run("styles.updateToMatch", json!({}));
|
||||
}
|
||||
if ui.button("Clear").clicked() {
|
||||
let _ = app.run("format.clear", json!({}));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
let mut list: Vec<(String, String, bool)> = app.session.doc.styles.styles.iter().filter(|s| !s.hidden && s.kind != wordcraft_doc::StyleKind::Table).map(|s| (s.id.clone(), s.name.clone(), s.kind == wordcraft_doc::StyleKind::Character)).collect();
|
||||
list.sort_by_key(|s| s.1.to_lowercase());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for (id, name, is_char) in list {
|
||||
ui.horizontal(|ui| {
|
||||
let r = ui.selectable_label(id == cur, &name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(egui::RichText::new(if is_char { "a" } else { "¶" }).weak());
|
||||
});
|
||||
if r.clicked() {
|
||||
let _ = app.run("para.style", json!({"style": id}));
|
||||
app.canvas.want_focus = true;
|
||||
}
|
||||
r.context_menu(|ui| {
|
||||
if ui.button("Modify…").clicked() {
|
||||
app.dialog = crate::dialogs::Dialog::modify_style(app, &id);
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Update to Match Selection").clicked() {
|
||||
let _ = app.run("styles.updateToMatch", json!({"style": id}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.button("Delete").clicked() {
|
||||
let _ = app.run("styles.delete", json!({"style": id}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn comments(app: &mut WordApp, ui: &mut Ui) {
|
||||
if header(ui, "Comments") {
|
||||
let _ = app.run("view.commentsPane", json!({"value": false}));
|
||||
return;
|
||||
}
|
||||
if ui.button("➕ New comment").clicked() {
|
||||
let _ = app.run("review.newComment", json!({"text": ""}));
|
||||
}
|
||||
ui.separator();
|
||||
let list = app.session.run("review.comments", &json!({})).unwrap_or_default();
|
||||
let t = Tokens::get(ui.ctx());
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
for c in list.as_array().cloned().unwrap_or_default() {
|
||||
let id = c.get("id").and_then(Value::as_u64).unwrap_or(0);
|
||||
let reply = c.get("parent").is_some_and(|p| !p.is_null());
|
||||
egui::Frame::NONE.fill(t.input).stroke(Stroke::new(1.0, t.border)).corner_radius(6).inner_margin(8).show(ui, |ui| {
|
||||
ui.set_width(ui.available_width());
|
||||
if reply {
|
||||
ui.add_space(0.0);
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new(c.get("author").and_then(Value::as_str).unwrap_or("")).font(semibold(12.0)));
|
||||
ui.label(egui::RichText::new(c.get("date").and_then(Value::as_str).unwrap_or("").get(..10).unwrap_or("")).small().weak());
|
||||
if c.get("resolved").and_then(Value::as_bool).unwrap_or(false) {
|
||||
ui.label(egui::RichText::new("Resolved").small().color(t.green));
|
||||
}
|
||||
});
|
||||
// Editable comment text (writes back to the comment's story).
|
||||
let part = app.session.doc.comments.get(&(id as u32)).map(|x| x.part);
|
||||
if let Some(part) = part {
|
||||
let key = egui::Id::new(("comment_edit", id));
|
||||
let mut text = ui.data(|d| d.get_temp::<String>(key)).unwrap_or_else(|| app.session.doc.plain_text(StoryRef::Part(part)));
|
||||
let r = ui.add(egui::TextEdit::multiline(&mut text).desired_rows(1).desired_width(f32::INFINITY).hint_text("Add a comment"));
|
||||
if r.changed() {
|
||||
ui.data_mut(|d| d.insert_temp(key, text.clone()));
|
||||
}
|
||||
if r.lost_focus() {
|
||||
let blocks = text.split('\n').map(|l| wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(l, Default::default()))).collect();
|
||||
let _ = app.session.doc.set_story(StoryRef::Part(part), blocks);
|
||||
app.session.touch();
|
||||
ui.data_mut(|d| d.remove::<String>(key));
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.small_button("Go to").clicked()
|
||||
&& let Some(a) = c.get("anchor").filter(|a| !a.is_null())
|
||||
{
|
||||
let _ = app.run("caret.set", json!({"pos": a}));
|
||||
}
|
||||
if ui.small_button("Resolve").clicked() {
|
||||
let _ = app.run("review.resolveComment", json!({"id": id}));
|
||||
}
|
||||
if ui.small_button("Delete").clicked() {
|
||||
let _ = app.run("review.deleteComment", json!({"id": id}));
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
});
|
||||
let _ = vec2(0.0, 0.0);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Rendered previews: the Styles gallery, font menu entries, table and style-set tiles. Each is a
|
||||
//! tiny document laid out and rasterised by the real engine, cached as a texture.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Rect, Response, Sense, Stroke, TextureHandle, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::CharProps;
|
||||
use wordcraft_doc::{Block, Document, Paragraph, Table, para_block};
|
||||
|
||||
use crate::WordApp;
|
||||
use crate::theme::{Tokens, regular};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Previews {
|
||||
tex: HashMap<String, TextureHandle>,
|
||||
families: Option<Vec<String>>,
|
||||
styles_hash: (u64, u64),
|
||||
/// Previews rendered this frame (rendering is spread over frames).
|
||||
budget: std::cell::Cell<u32>,
|
||||
frame: u64,
|
||||
}
|
||||
|
||||
impl Previews {
|
||||
pub fn families(&mut self) -> Vec<String> {
|
||||
if self.families.is_none() {
|
||||
let mut f: Vec<String> = wordcraft_fonts::FontDb::global().families().into_iter().filter(|f| !f.starts_with('.')).collect();
|
||||
for w in [wordcraft_doc::styles::BODY_FONT, wordcraft_doc::styles::HEADING_FONT] {
|
||||
if !f.iter().any(|x| x == w) {
|
||||
f.push(w.to_string());
|
||||
}
|
||||
}
|
||||
f.sort_by_key(|a| a.to_lowercase());
|
||||
f.dedup();
|
||||
self.families = Some(f);
|
||||
}
|
||||
self.families.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A closure drawing a font-menu entry in its own face.
|
||||
pub fn font_preview_fn(&mut self) -> Box<dyn Fn(&mut Ui, &str) -> Response> {
|
||||
Box::new(|ui: &mut Ui, name: &str| {
|
||||
let key = format!("font:{name}");
|
||||
let tex: Option<TextureHandle> = ui.ctx().data(|d| d.get_temp::<TextureHandle>(egui::Id::new(&key)));
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(260.0, 24.0), Sense::click());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let tex = tex.or_else(|| {
|
||||
let n = ui.ctx().data_mut(|d| {
|
||||
let c = d.get_temp_mut_or_default::<(f64, u32)>(egui::Id::new("font_preview_budget"));
|
||||
let now = ui.input(|i| i.time);
|
||||
if c.0 != now {
|
||||
*c = (now, 0);
|
||||
}
|
||||
c.1 += 1;
|
||||
c.1
|
||||
});
|
||||
if n > 12 {
|
||||
ui.ctx().request_repaint();
|
||||
return None;
|
||||
}
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let img = snippet(&Document::new(), Paragraph::with_text(name, CharProps { font: Some(name.to_string()), size: Some(13.0), ..Default::default() }), 240.0, 20.0, ppp, 2.0)?;
|
||||
let h = ui.ctx().load_texture(&key, img, egui::TextureOptions::LINEAR);
|
||||
ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new(&key), h.clone()));
|
||||
Some(h)
|
||||
});
|
||||
match tex {
|
||||
Some(h) => {
|
||||
let sz = h.size_vec2() / ui.ctx().pixels_per_point();
|
||||
ui.painter().image(h.id(), Rect::from_min_size(pos2(r.min.x + 6.0, r.center().y - sz.y / 2.0), sz), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), egui::Color32::WHITE);
|
||||
}
|
||||
None => {
|
||||
ui.painter().text(pos2(r.min.x + 8.0, r.center().y), egui::Align2::LEFT_CENTER, name, regular(12.0), t.text);
|
||||
}
|
||||
}
|
||||
resp
|
||||
})
|
||||
}
|
||||
|
||||
fn get_or(&mut self, ctx: &egui::Context, key: &str, make: impl FnOnce() -> Option<egui::ColorImage>) -> Option<TextureHandle> {
|
||||
if let Some(t) = self.tex.get(key) {
|
||||
return Some(t.clone());
|
||||
}
|
||||
let frame = ctx.cumulative_frame_nr();
|
||||
if frame != self.frame {
|
||||
self.frame = frame;
|
||||
self.budget.set(0);
|
||||
}
|
||||
if self.budget.get() > 8 {
|
||||
ctx.request_repaint();
|
||||
return None;
|
||||
}
|
||||
self.budget.set(self.budget.get() + 1);
|
||||
let img = make()?;
|
||||
let h = ctx.load_texture(key, img, egui::TextureOptions::LINEAR);
|
||||
if self.tex.len() > 400 {
|
||||
self.tex.clear();
|
||||
}
|
||||
self.tex.insert(key.to_string(), h.clone());
|
||||
Some(h)
|
||||
}
|
||||
|
||||
/// Hash of the style sheet (recomputed only when the document revision changes).
|
||||
fn styles_key(&mut self, app_doc: &Document, rev: u64) -> u64 {
|
||||
if self.styles_hash.0 != rev {
|
||||
let s = serde_json::to_string(&(&app_doc.styles, &app_doc.settings.theme_colors)).unwrap_or_default();
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
s.hash(&mut h);
|
||||
self.styles_hash = (rev, h.finish());
|
||||
}
|
||||
self.styles_hash.1
|
||||
}
|
||||
}
|
||||
|
||||
/// Lay out `para` (with `base`'s styles) in a `w`×`h` pt box and rasterise it.
|
||||
pub fn snippet(base: &Document, para: Paragraph, w: f32, h: f32, ppp: f32, margin: f32) -> Option<egui::ColorImage> {
|
||||
snippet_blocks(base, vec![Block::Para(para)], w, h, ppp, margin, None)
|
||||
}
|
||||
|
||||
fn snippet_blocks(base: &Document, blocks: Vec<Block>, w: f32, h: f32, ppp: f32, margin: f32, paper: Option<wordcraft_doc::Rgb>) -> Option<egui::ColorImage> {
|
||||
let mut d = Document::new();
|
||||
d.styles = base.styles.clone();
|
||||
d.numbering = base.numbering.clone();
|
||||
d.body = blocks.into_iter().map(Arc::new).collect();
|
||||
d.last_section.page_w = w + 2.0 * margin;
|
||||
d.last_section.page_h = 2000.0;
|
||||
d.last_section.margin_left = margin;
|
||||
d.last_section.margin_right = margin;
|
||||
d.last_section.margin_top = margin;
|
||||
d.last_section.margin_bottom = 0.0;
|
||||
let l = wordcraft_layout::layout(&d, &mut wordcraft_layout::LayoutCache::new(), &Default::default());
|
||||
let page = l.pages.first()?;
|
||||
let mut opts = wordcraft_render::RenderOptions::default();
|
||||
if let Some(p) = paper {
|
||||
opts.paper = p;
|
||||
}
|
||||
let img = wordcraft_render::render_area(&d, page, 0.0, 0.0, w + 2.0 * margin, h + margin, ppp, &opts);
|
||||
let px = img.to_straight();
|
||||
Some(egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &px))
|
||||
}
|
||||
|
||||
/// The Home tab's Styles gallery.
|
||||
pub fn style_gallery(app: &mut WordApp, ui: &mut Ui, state: &Value) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let current = state.get("style").and_then(Value::as_str).unwrap_or("Normal").to_string();
|
||||
let styles: Vec<(String, String)> = app.session.doc.styles.gallery().iter().map(|s| (s.id.clone(), s.name.clone())).collect();
|
||||
let rev = app.session.rev();
|
||||
let skey = app.previews.styles_key(&app.session.doc, rev);
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let start = ui.data(|d| d.get_temp::<usize>(egui::Id::new("gallery_start"))).unwrap_or(0).min(styles.len().saturating_sub(1));
|
||||
let visible = 6usize;
|
||||
let (area, _) = ui.allocate_exact_size(vec2(visible as f32 * 76.0 + 18.0, crate::widgets::CONTENT_H), Sense::hover());
|
||||
ui.painter().rect(area.shrink(1.0), 4.0, t.input, Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
for (k, (id, name)) in styles.iter().skip(start).take(visible).enumerate() {
|
||||
let r = Rect::from_min_size(pos2(area.min.x + 3.0 + k as f32 * 76.0, area.min.y + 3.0), vec2(74.0, area.height() - 6.0));
|
||||
let resp = ui.interact(r, ui.id().with(("style_tile", id)), Sense::click());
|
||||
let active = *id == current;
|
||||
if active {
|
||||
ui.painter().rect(r, 3.0, t.checked, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let key = format!("style:{id}:{skey}:{ppp}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let mut p = Paragraph::with_text("AaBbCcDd", CharProps::default()).styled(id);
|
||||
p.props.space_before = Some(0.0);
|
||||
p.props.align = Some(wordcraft_doc::Align::Left);
|
||||
p.props.indent_left = Some(0.0);
|
||||
p.props.borders = None;
|
||||
p.props.shading = None;
|
||||
snippet(doc, p, 220.0, 30.0, ppp, 1.0)
|
||||
});
|
||||
if let Some(h) = tex {
|
||||
let sz = h.size_vec2() / ppp;
|
||||
let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 4.0), sz.min(vec2(68.0, 32.0)));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
|
||||
ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE);
|
||||
}
|
||||
ui.painter().text(pos2(r.center().x, r.max.y - 9.0), egui::Align2::CENTER_CENTER, name, regular(10.5), t.text);
|
||||
if resp.on_hover_text(name).clicked() {
|
||||
let _ = app.run("para.style", json!({"style": id}));
|
||||
}
|
||||
}
|
||||
// Scroll arrows and "more".
|
||||
let ar = Rect::from_min_max(pos2(area.max.x - 16.0, area.min.y + 2.0), pos2(area.max.x - 2.0, area.max.y - 2.0));
|
||||
let thirds = ar.height() / 3.0;
|
||||
for (i, (icon, delta)) in [("chevronUp", -1i32), ("chevronDown", 1), ("more", 0)].iter().enumerate() {
|
||||
let br = Rect::from_min_size(pos2(ar.min.x, ar.min.y + thirds * i as f32), vec2(ar.width(), thirds));
|
||||
let resp = ui.interact(br, ui.id().with(("gal_arrow", i)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(br, 2.0, t.hover);
|
||||
}
|
||||
crate::icons::paint(ui.painter(), br.shrink(2.0), icon, t.icon, t.accent);
|
||||
if resp.clicked() {
|
||||
if *delta == 0 {
|
||||
let _ = app.run("view.stylesPane", json!({"value": true}));
|
||||
} else {
|
||||
let n = if *delta < 0 { start.saturating_sub(visible) } else { (start + visible).min(styles.len().saturating_sub(1)) };
|
||||
ui.data_mut(|d| d.insert_temp(egui::Id::new("gallery_start"), n));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Design tab: style set tiles.
|
||||
pub fn style_set_gallery(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
for name in ["default", "basic", "lines", "shaded", "casual", "centered", "minimalist"] {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(56.0, crate::widgets::CONTENT_H), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let key = format!("set:{name}:{ppp}");
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let mut s = wordcraft_engine::Session::new(Document::new());
|
||||
let _ = s.run("design.styleSet", &json!({"name": name}));
|
||||
let blocks = vec![
|
||||
Block::Para(Paragraph::with_text("Title", CharProps::default()).styled("Title")),
|
||||
Block::Para(Paragraph::with_text("Heading 1", CharProps::default()).styled("Heading1")),
|
||||
Block::Para(Paragraph::with_text("Body text in a short paragraph to show spacing.", CharProps::default())),
|
||||
];
|
||||
snippet_blocks(&s.doc, blocks, 120.0, 110.0, ppp * 0.42, 6.0, None)
|
||||
});
|
||||
if let Some(h) = tex {
|
||||
let sz = h.size_vec2() / ppp;
|
||||
let ir = Rect::from_min_size(pos2(r.min.x + 3.0, r.min.y + 3.0), sz.min(vec2(50.0, 60.0)));
|
||||
ui.painter().rect_stroke(ir, 0.0, Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
|
||||
ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE);
|
||||
}
|
||||
if resp.on_hover_text(format!("Style set: {name}")).clicked() {
|
||||
let _ = app.run("design.styleSet", json!({"name": name}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Table Design: a tile previewing a table style.
|
||||
pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let ppp = ui.ctx().pixels_per_point();
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(58.0, 46.0), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 3.0, t.hover);
|
||||
}
|
||||
let key = format!("tstyle:{style}:{ppp}");
|
||||
let doc = &app.session.doc;
|
||||
let tex = app.previews.get_or(ui.ctx(), &key, || {
|
||||
let mut tb = Table::new(5, 4, 100.0);
|
||||
tb.props.style = Some(style.to_string());
|
||||
for row in &mut tb.rows {
|
||||
for c in &mut row.cells {
|
||||
c.blocks = vec![para_block(Paragraph::with_text(" ", CharProps { size: Some(5.0), ..Default::default() }))];
|
||||
}
|
||||
}
|
||||
snippet_blocks(doc, vec![Block::Table(tb), Block::Para(Paragraph::new())], 104.0, 70.0, ppp * 0.48, 4.0, None)
|
||||
});
|
||||
if let Some(h) = tex {
|
||||
let sz = h.size_vec2() / ppp;
|
||||
let ir = Rect::from_center_size(r.center(), sz.min(vec2(54.0, 42.0)));
|
||||
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2((ir.width() / sz.x).min(1.0), (ir.height() / sz.y).min(1.0)));
|
||||
ui.painter().image(h.id(), ir, uv, egui::Color32::WHITE);
|
||||
}
|
||||
resp
|
||||
}
|
||||
@@ -0,0 +1,946 @@
|
||||
//! The ribbon: tab strip and every tab's groups. Each control runs a command by id.
|
||||
|
||||
use egui::{Align2, Rect, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::theme::{Tokens, medium, regular, semibold};
|
||||
use crate::widgets::{CONTENT_H, LABEL_H, big, color_grid, combo, group, menu_button, small, split};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
pub const TABS: [&str; 11] = ["File", "Home", "Insert", "Draw", "Design", "Layout", "References", "Mailings", "Review", "View", "Help"];
|
||||
|
||||
fn in_table(app: &WordApp) -> bool {
|
||||
app.session.sel.focus.path.cell().is_some()
|
||||
}
|
||||
|
||||
pub fn show(app: &mut WordApp, ui: &mut Ui) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
// Tab strip.
|
||||
egui::Panel::top("tabs").exact_size(30.0).frame(egui::Frame::NONE.fill(t.tab_strip).inner_margin(egui::Margin { left: 8, right: 10, top: 0, bottom: 0 })).show(ui, |ui| {
|
||||
ui.horizontal_centered(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
|
||||
let mut tabs: Vec<&str> = TABS.to_vec();
|
||||
if in_table(app) {
|
||||
tabs.push("Table Design");
|
||||
tabs.push("Table Layout");
|
||||
}
|
||||
for tab in tabs {
|
||||
let contextual = tab.starts_with("Table ");
|
||||
let w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(tab.to_string(), medium(12.5), t.text).size().x) + 18.0;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, 30.0), Sense::click());
|
||||
let active = app.ui.tab == tab && !app.ui.backstage;
|
||||
if resp.hovered() && !active {
|
||||
ui.painter().rect_filled(r.shrink2(vec2(0.0, 4.0)), 4.0, t.hover);
|
||||
}
|
||||
let color = if contextual { t.accent_text } else if active { t.accent_text } else { t.text };
|
||||
ui.painter().text(r.center(), Align2::CENTER_CENTER, tab, if active { semibold(12.5) } else { medium(12.5) }, color);
|
||||
if active {
|
||||
let u = Rect::from_center_size(pos2(r.center().x, r.max.y - 2.0), vec2(w - 16.0, 3.0));
|
||||
ui.painter().rect_filled(u, 2.0, t.accent);
|
||||
}
|
||||
if resp.clicked() {
|
||||
let _ = app.run("ui.tab", json!({"tab": tab}));
|
||||
}
|
||||
if resp.double_clicked() && tab != "File" {
|
||||
let _ = app.run("ui.collapseRibbon", json!({}));
|
||||
}
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
// Share, Editing mode, Comments.
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(74.0, 24.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.accent_text } else { t.accent });
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(14.0, 14.0)), "share", egui::Color32::WHITE, egui::Color32::WHITE);
|
||||
ui.painter().text(pos2(r.min.x + 26.0, r.center().y), Align2::LEFT_CENTER, "Share", medium(12.0), egui::Color32::WHITE);
|
||||
if resp.on_hover_text("Export a copy to share (PDF, Word document)").clicked() {
|
||||
let _ = app.run("ui.backstage", json!({"value": true, "page": "export"}));
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
let track = app.session.doc.settings.track_changes;
|
||||
ui.menu_button(egui::RichText::new(if track { "✎ Reviewing ▾" } else { "✎ Editing ▾" }).font(regular(12.0)), |ui| {
|
||||
if ui.selectable_label(!track, "Editing — edit the document directly").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": false}));
|
||||
ui.close();
|
||||
}
|
||||
if ui.selectable_label(track, "Reviewing — edits become suggestions").clicked() {
|
||||
let _ = app.run("review.trackChanges", json!({"value": true}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
if ui.button(egui::RichText::new("💬 Comments").font(regular(12.0))).clicked() {
|
||||
let _ = app.run("view.commentsPane", json!({}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if app.ui.ribbon_collapsed {
|
||||
return;
|
||||
}
|
||||
egui::Panel::top("ribbon")
|
||||
.exact_size(CONTENT_H + LABEL_H + 10.0)
|
||||
.frame(egui::Frame::NONE.fill(t.ribbon).inner_margin(egui::Margin { left: 8, right: 8, top: 4, bottom: 4 }).stroke(Stroke::new(1.0, t.border)))
|
||||
.show(ui, |ui| {
|
||||
egui::ScrollArea::horizontal().scroll_bar_visibility(egui::scroll_area::ScrollBarVisibility::AlwaysHidden).show(ui, |ui| {
|
||||
ui.horizontal_top(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
|
||||
match app.ui.tab.as_str() {
|
||||
"Home" => home(app, ui),
|
||||
"Insert" => insert(app, ui),
|
||||
"Draw" => draw(app, ui),
|
||||
"Design" => design(app, ui),
|
||||
"Layout" => layout(app, ui),
|
||||
"References" => references(app, ui),
|
||||
"Mailings" => mailings(app, ui),
|
||||
"Review" => review(app, ui),
|
||||
"View" => view(app, ui),
|
||||
"Help" => help(app, ui),
|
||||
"Table Design" => table_design(app, ui),
|
||||
"Table Layout" => table_layout(app, ui),
|
||||
_ => home(app, ui),
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn stack(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
fn mi(ui: &mut Ui, app: &mut WordApp, label: &str, id: &str, params: Value) {
|
||||
let sc = crate::widgets::shortcut_text(app, id);
|
||||
let enabled = crate::widgets::enabled(app, id);
|
||||
let resp = ui.add_enabled(enabled, egui::Button::new(label).shortcut_text(sc).min_size(vec2(200.0, 0.0)));
|
||||
if resp.clicked() {
|
||||
let _ = app.run(id, params);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
|
||||
fn home(app: &mut WordApp, ui: &mut Ui) {
|
||||
let st = app.session.run("format.state", &json!({})).unwrap_or_default();
|
||||
let flag = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
|
||||
group(ui, "Clipboard", None, app, |ui, app| {
|
||||
menu_button(ui, app, "paste", Some("Paste"), "Paste (⌘V)", true, |ui, app| {
|
||||
mi(ui, app, "Paste", "edit.paste", json!({}));
|
||||
mi(ui, app, "Keep Text Only", "edit.pasteText", json!({}));
|
||||
mi(ui, app, "Merge Formatting", "edit.pasteMerge", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "cut", Some("Cut"), "Cut", "edit.cut", json!({}), false);
|
||||
let r = small(ui, app, "copy", Some("Copy"), "Copy", "edit.copy", json!({}), false);
|
||||
if r.clicked() {
|
||||
ui.ctx().copy_text(app.session.clipboard_text.clone());
|
||||
}
|
||||
let active = app.session.painter.is_some();
|
||||
small(ui, app, "painter", Some("Format"), "Format Painter", "edit.formatPainter", json!({}), active);
|
||||
});
|
||||
});
|
||||
group(ui, "Font", Some("format.fontDialog"), app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
crate::widgets::row(ui, |ui| {
|
||||
let font = st.get("font").and_then(Value::as_str).unwrap_or("").to_string();
|
||||
let fams = app.previews.families();
|
||||
let prev = app.previews.font_preview_fn();
|
||||
if let Some(f) = combo(ui, "font", 150.0, &font, &fams, Some(&*prev)) {
|
||||
let _ = app.run("format.font", json!({"name": f}));
|
||||
}
|
||||
let size = st.get("size").and_then(Value::as_f64).map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { format!("{s}") }).unwrap_or_default();
|
||||
let sizes: Vec<String> = wordcraft_engine::cmd::format::SIZES.iter().map(|s| if s.fract() == 0.0 { format!("{s:.0}") } else { s.to_string() }).collect();
|
||||
if let Some(v) = combo(ui, "size", 52.0, &size, &sizes, None)
|
||||
&& let Ok(x) = v.trim().parse::<f64>()
|
||||
{
|
||||
let _ = app.run("format.size", json!({"size": x}));
|
||||
}
|
||||
ui.add_space(3.0);
|
||||
small(ui, app, "grow", None, "Increase Font Size", "format.growFont", json!({}), false);
|
||||
small(ui, app, "shrink", None, "Decrease Font Size", "format.shrinkFont", json!({}), false);
|
||||
menu_button(ui, app, "case", None, "Change Case", false, |ui, app| {
|
||||
mi(ui, app, "Sentence case.", "format.changeCase", json!({"mode": "sentence"}));
|
||||
mi(ui, app, "lowercase", "format.changeCase", json!({"mode": "lower"}));
|
||||
mi(ui, app, "UPPERCASE", "format.changeCase", json!({"mode": "upper"}));
|
||||
mi(ui, app, "Capitalize Each Word", "format.changeCase", json!({"mode": "title"}));
|
||||
mi(ui, app, "tOGGLE cASE", "format.changeCase", json!({"mode": "toggle"}));
|
||||
});
|
||||
small(ui, app, "clear", None, "Clear All Formatting", "format.clear", json!({}), false);
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
crate::widgets::row(ui, |ui| {
|
||||
small(ui, app, "bold", None, "Bold", "format.bold", json!({}), flag("bold"));
|
||||
small(ui, app, "italic", None, "Italic", "format.italic", json!({}), flag("italic"));
|
||||
split(ui, app, "underline", "Underline", "format.underline", json!({}), flag("underline"), None, |ui, app| {
|
||||
for (l, s) in [("Single", "single"), ("Double", "double"), ("Thick", "thick"), ("Dotted", "dotted"), ("Dashed", "dash"), ("Dot-dash", "dotDash"), ("Wave", "wave"), ("Words only", "words")] {
|
||||
mi(ui, app, l, "format.underline", json!({"style": s}));
|
||||
}
|
||||
});
|
||||
small(ui, app, "strike", None, "Strikethrough", "format.strikethrough", json!({}), flag("strike"));
|
||||
small(ui, app, "subscript", None, "Subscript", "format.subscript", json!({}), flag("subscript"));
|
||||
small(ui, app, "superscript", None, "Superscript", "format.superscript", json!({}), flag("superscript"));
|
||||
ui.add_space(4.0);
|
||||
menu_button(ui, app, "effects", None, "Text Effects and Typography", false, |ui, app| {
|
||||
mi(ui, app, "Outline", "format.outline", json!({}));
|
||||
mi(ui, app, "Shadow", "format.shadow", json!({}));
|
||||
mi(ui, app, "Small Caps", "format.smallCaps", json!({}));
|
||||
mi(ui, app, "All Caps", "format.allCaps", json!({}));
|
||||
mi(ui, app, "Double Strikethrough", "format.doubleStrikethrough", json!({}));
|
||||
});
|
||||
let hl = app.canvas.last_highlight.clone();
|
||||
split(ui, app, "highlight", "Text Highlight Color", "format.highlight", json!({"color": hl}), false, None, |ui, app| {
|
||||
let grid = wordcraft_doc::props::Highlight::ALL;
|
||||
egui::Grid::new("hl").spacing(vec2(3.0, 3.0)).show(ui, |ui| {
|
||||
for (i, h) in grid.iter().enumerate().skip(1) {
|
||||
let c = h.rgb().unwrap_or(wordcraft_doc::Rgb::WHITE);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 2.0, egui::Color32::from_rgb(c.0, c.1, c.2));
|
||||
if resp.on_hover_text(h.name()).clicked() {
|
||||
app.canvas.last_highlight = h.ooxml().to_string();
|
||||
let _ = app.run("format.highlight", json!({"color": h.ooxml()}));
|
||||
ui.close();
|
||||
}
|
||||
if i % 5 == 0 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
mi(ui, app, "No Color", "format.highlight", json!({"color": "none"}));
|
||||
});
|
||||
let fc = app.canvas.last_font_color.clone();
|
||||
let sw = wordcraft_doc::Rgb::parse(&fc).map(crate::theme::c32);
|
||||
split(ui, app, "fontcolor", "Font Color", "format.color", json!({"color": fc}), false, sw, |ui, app| {
|
||||
mi(ui, app, "Automatic", "format.color", json!({"color": "auto"}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_font_color = hex.clone();
|
||||
let _ = app.run("format.color", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let align = rp.as_ref().map(|r| r.align).unwrap_or_default();
|
||||
stack(ui, |ui| {
|
||||
crate::widgets::row(ui, |ui| {
|
||||
split(ui, app, "bullets", "Bullets", "para.bullets", json!({}), false, None, |ui, app| {
|
||||
ui.label(egui::RichText::new("Bullet Library").small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
for c in ["•", "○", "▪", "◆", "➢", "✓", "–"] {
|
||||
if ui.button(egui::RichText::new(c).size(16.0)).clicked() {
|
||||
let _ = app.run("para.bullets", json!({"kind": c}));
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
mi(ui, app, "None", "para.bullets", json!({"off": true}));
|
||||
});
|
||||
split(ui, app, "numbering", "Numbering", "para.numbering", json!({}), false, None, |ui, app| {
|
||||
for (l, k) in [("1. 2. 3.", "numbered"), ("1) 2) 3)", "numberedParen"), ("I. II. III.", "outline"), ("A. B. C.", "upperLetter"), ("a) b) c)", "lowerLetter"), ("i. ii. iii.", "lowerRoman")] {
|
||||
mi(ui, app, l, "para.numbering", json!({"kind": k}));
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Restart at 1", "para.restartNumbering", json!({}));
|
||||
mi(ui, app, "None", "para.numbering", json!({"off": true}));
|
||||
});
|
||||
split(ui, app, "multilevel", "Multilevel List", "para.multilevel", json!({}), false, None, |ui, app| {
|
||||
mi(ui, app, "1. 1.1. 1.1.1.", "para.multilevel", json!({"kind": "legal"}));
|
||||
mi(ui, app, "I. A. 1. a.", "para.multilevel", json!({"kind": "outline"}));
|
||||
ui.separator();
|
||||
for lv in 0..5u64 {
|
||||
mi(ui, app, &format!("Change to Level {}", lv + 1), "para.listLevel", json!({"level": lv}));
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
small(ui, app, "outdent", None, "Decrease Indent", "para.outdent", json!({}), false);
|
||||
small(ui, app, "indent", None, "Increase Indent", "para.indent", json!({}), false);
|
||||
ui.add_space(4.0);
|
||||
small(ui, app, "sort", None, "Sort", "para.sort", json!({}), false);
|
||||
let marks = app.session.view.marks;
|
||||
small(ui, app, "pilcrow", None, "Show/Hide ¶", "view.marks", json!({}), marks);
|
||||
});
|
||||
ui.add_space(3.0);
|
||||
crate::widgets::row(ui, |ui| {
|
||||
use wordcraft_doc::Align as A;
|
||||
small(ui, app, "alignLeft", None, "Align Left", "para.alignLeft", json!({}), align == A::Left);
|
||||
small(ui, app, "alignCenter", None, "Center", "para.alignCenter", json!({}), align == A::Center);
|
||||
small(ui, app, "alignRight", None, "Align Right", "para.alignRight", json!({}), align == A::Right);
|
||||
small(ui, app, "justify", None, "Justify", "para.justify", json!({}), align == A::Justify);
|
||||
ui.add_space(4.0);
|
||||
menu_button(ui, app, "lineSpacing", None, "Line and Paragraph Spacing", false, |ui, app| {
|
||||
for v in [1.0, 1.15, 1.5, 2.0, 2.5, 3.0] {
|
||||
mi(ui, app, &format!("{v}"), "para.lineSpacing", json!({"value": v}));
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Add Space Before Paragraph", "para.addSpaceBefore", json!({}));
|
||||
mi(ui, app, "Remove Space After Paragraph", "para.removeSpaceAfter", json!({}));
|
||||
mi(ui, app, "Line Spacing Options…", "para.dialog", json!({}));
|
||||
});
|
||||
split(ui, app, "shading", "Shading", "para.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "para.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_shading = hex.clone();
|
||||
let _ = app.run("para.shading", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
split(ui, app, "borders", "Borders", "para.borders", json!({"kind": "bottom"}), false, None, |ui, app| {
|
||||
for (l, k) in [("Bottom Border", "bottom"), ("Top Border", "top"), ("Left Border", "left"), ("Right Border", "right"), ("No Border", "none"), ("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside")] {
|
||||
mi(ui, app, l, "para.borders", json!({"kind": k}));
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Horizontal Line", "insert.horizontalLine", json!({}));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Styles", Some("styles.pane"), app, |ui, app| {
|
||||
crate::previews::style_gallery(app, ui, &st);
|
||||
});
|
||||
group(ui, "Editing", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
if small(ui, app, "find", Some("Find"), "Find", "ui.dialog", json!({"name": "find"}), false).clicked() {}
|
||||
small(ui, app, "replace", Some("Replace"), "Replace", "ui.dialog", json!({"name": "replace"}), false);
|
||||
menu_button(ui, app, "select", Some("Select"), "Select", false, |ui, app| {
|
||||
mi(ui, app, "Select All", "select.all", json!({}));
|
||||
mi(ui, app, "Select Paragraph", "select.paragraph", json!({}));
|
||||
mi(ui, app, "Select Sentence", "select.sentence", json!({}));
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Voice", None, app, |ui, app| {
|
||||
big(ui, app, "dictate", "Dictate", "tools.dictate", json!({}), false);
|
||||
});
|
||||
group(ui, "Editor", None, app, |ui, app| {
|
||||
big(ui, app, "editor", "Editor", "review.spelling", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn insert(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Pages", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "coverPage", Some("Cover Page"), "Cover Page", false, |ui, app| {
|
||||
mi(ui, app, "Studio cover", "insert.coverPage", json!({}));
|
||||
});
|
||||
small(ui, app, "blankPage", Some("Blank Page"), "Blank Page", "insert.blankPage", json!({}), false);
|
||||
small(ui, app, "pageBreak", Some("Page Break"), "Page Break", "insert.pageBreak", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Tables", None, app, |ui, app| {
|
||||
menu_button(ui, app, "table", Some("Table"), "Add a Table", true, |ui, app| {
|
||||
crate::dialogs::table_grid_picker(ui, app);
|
||||
ui.separator();
|
||||
mi(ui, app, "Insert Table…", "ui.dialog", json!({"name": "insertTable"}));
|
||||
mi(ui, app, "Convert Text to Table…", "table.fromText", json!({}));
|
||||
ui.menu_button("Quick Tables", |ui| {
|
||||
mi(ui, app, "Tabular List", "table.quick", json!({"kind": "tabular"}));
|
||||
mi(ui, app, "Matrix", "table.quick", json!({"kind": "matrix"}));
|
||||
mi(ui, app, "Calendar", "table.quick", json!({"kind": "calendar"}));
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Illustrations", None, app, |ui, app| {
|
||||
big(ui, app, "picture", "Pictures", "insert.picture", json!({}), false);
|
||||
menu_button(ui, app, "shapes", Some("Shapes"), "Shapes", true, |ui, app| {
|
||||
for (l, k) in [("Rectangle", "rectangle"), ("Rounded Rectangle", "roundedRectangle"), ("Oval", "ellipse"), ("Triangle", "triangle"), ("Diamond", "diamond"), ("Line", "line"), ("Arrow", "arrow"), ("Star", "star"), ("Heart", "heart")] {
|
||||
mi(ui, app, l, "insert.shape", json!({"kind": k}));
|
||||
}
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "icons", Some("Icons"), "Icons", "insert.icon", json!({}), false);
|
||||
small(ui, app, "smartArt", Some("SmartArt"), "SmartArt", "insert.smartArt", json!({}), false);
|
||||
small(ui, app, "chart", Some("Chart"), "Chart", "insert.chart", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Links", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "link", Some("Link"), "Link", "ui.dialog", json!({"name": "link"}), false);
|
||||
small(ui, app, "bookmark", Some("Bookmark"), "Bookmark", "ui.dialog", json!({"name": "bookmark"}), false);
|
||||
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Comments", None, app, |ui, app| {
|
||||
big(ui, app, "newComment", "Comment", "review.newComment", json!({}), false);
|
||||
});
|
||||
group(ui, "Header & Footer", None, app, |ui, app| {
|
||||
menu_button(ui, app, "header", Some("Header"), "Header", true, |ui, app| {
|
||||
mi(ui, app, "Blank", "insert.header", json!({"preset": "blank"}));
|
||||
mi(ui, app, "Blank (Three Columns)", "insert.header", json!({"preset": "blankThree"}));
|
||||
mi(ui, app, "Document Title", "insert.header", json!({"preset": "title"}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Edit Header", "insert.editHeader", json!({}));
|
||||
mi(ui, app, "Remove Header", "insert.removeHeader", json!({}));
|
||||
});
|
||||
menu_button(ui, app, "footer", Some("Footer"), "Footer", true, |ui, app| {
|
||||
mi(ui, app, "Blank", "insert.footer", json!({"preset": "blank"}));
|
||||
mi(ui, app, "Blank (Three Columns)", "insert.footer", json!({"preset": "blankThree"}));
|
||||
mi(ui, app, "Page Number", "insert.footer", json!({"preset": "pageNumber"}));
|
||||
ui.separator();
|
||||
mi(ui, app, "Edit Footer", "insert.editFooter", json!({}));
|
||||
mi(ui, app, "Remove Footer", "insert.removeFooter", json!({}));
|
||||
});
|
||||
menu_button(ui, app, "pageNumber", Some("Page\nNumber"), "Page Number", true, |ui, app| {
|
||||
mi(ui, app, "Top of Page", "insert.pageNumber", json!({"position": "top", "align": "right"}));
|
||||
mi(ui, app, "Bottom of Page", "insert.pageNumber", json!({"position": "bottom", "align": "center"}));
|
||||
mi(ui, app, "Page X of Y", "insert.pageNumber", json!({"position": "bottom", "format": "x of y"}));
|
||||
mi(ui, app, "Current Position", "insert.pageNumber", json!({"position": "current"}));
|
||||
mi(ui, app, "Format Page Numbers…", "layout.pageNumberFormat", json!({}));
|
||||
});
|
||||
});
|
||||
group(ui, "Text", None, app, |ui, app| {
|
||||
big(ui, app, "textBox", "Text\nBox", "insert.textBox", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "quickParts", Some("Quick Parts"), "Quick Parts", "insert.quickParts", json!({}), false);
|
||||
small(ui, app, "wordArt", Some("WordArt"), "WordArt", "insert.wordArt", json!({}), false);
|
||||
small(ui, app, "dropCap", Some("Drop Cap"), "Drop Cap", "insert.dropCap", json!({}), false);
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "signature", None, "Signature Line", "insert.signatureLine", json!({}), false);
|
||||
small(ui, app, "dateTime", None, "Date & Time", "insert.dateTime", json!({}), false);
|
||||
small(ui, app, "object", None, "Object", "insert.object", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Symbols", None, app, |ui, app| {
|
||||
big(ui, app, "equation", "Equation", "insert.equation", json!({}), false);
|
||||
menu_button(ui, app, "symbol", Some("Symbol"), "Symbol", true, |ui, app| {
|
||||
egui::Grid::new("syms").show(ui, |ui| {
|
||||
for (i, c) in ["©", "®", "™", "§", "¶", "€", "£", "¥", "°", "±", "≠", "≤", "≥", "÷", "×", "∞", "µ", "α", "β", "π", "Ω", "∑", "√", "→", "←", "✓", "★", "♥", "—", "…"].iter().enumerate() {
|
||||
if ui.button(egui::RichText::new(*c).size(16.0)).clicked() {
|
||||
let _ = app.run("insert.symbol", json!({"char": c}));
|
||||
ui.close();
|
||||
}
|
||||
if i % 6 == 5 {
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn draw(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Drawing Tools", None, app, |ui, app| {
|
||||
big(ui, app, "select", "Select", "draw.select", json!({}), false);
|
||||
big(ui, app, "lasso", "Lasso", "draw.lasso", json!({}), false);
|
||||
big(ui, app, "eraser", "Eraser", "draw.eraser", json!({}), false);
|
||||
big(ui, app, "pen", "Pen", "draw.pen", json!({}), false);
|
||||
big(ui, app, "pencil", "Pencil", "draw.pencil", json!({}), false);
|
||||
big(ui, app, "highlight", "Highlighter", "draw.highlighter", json!({}), false);
|
||||
});
|
||||
group(ui, "Convert", None, app, |ui, app| {
|
||||
big(ui, app, "inkToShape", "Ink to\nShape", "draw.inkToShape", json!({}), false);
|
||||
big(ui, app, "inkToMath", "Ink to\nMath", "draw.inkToMath", json!({}), false);
|
||||
});
|
||||
group(ui, "Insert", None, app, |ui, app| {
|
||||
big(ui, app, "canvas", "Drawing\nCanvas", "insert.canvas", json!({}), false);
|
||||
});
|
||||
group(ui, "Replay", None, app, |ui, app| {
|
||||
big(ui, app, "replay", "Ink\nReplay", "draw.replay", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn design(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Themes", None, app, |ui, app| {
|
||||
menu_button(ui, app, "themes", Some("Themes"), "Themes", true, |ui, app| {
|
||||
for (name, ..) in wordcraft_engine::cmd::design::THEMES {
|
||||
mi(ui, app, name, "design.theme", json!({"name": name}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Document Formatting", None, app, |ui, app| {
|
||||
crate::previews::style_set_gallery(app, ui);
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "colors", Some("Colors"), "Theme Colors", false, |ui, app| {
|
||||
for (name, ..) in wordcraft_engine::cmd::design::THEMES {
|
||||
mi(ui, app, name, "design.themeColors", json!({"name": name}));
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "fonts", Some("Fonts"), "Theme Fonts", false, |ui, app| {
|
||||
for (name, h, b, _) in wordcraft_engine::cmd::design::THEMES {
|
||||
mi(ui, app, &format!("{name}: {h} / {b}"), "design.themeFonts", json!({"heading": h, "body": b}));
|
||||
}
|
||||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "paraSpacing", Some("Paragraph Spacing"), "Paragraph Spacing", false, |ui, app| {
|
||||
for (l, v) in [("Default", "default"), ("No Paragraph Space", "none"), ("Compact", "compact"), ("Tight", "tight"), ("Open", "open"), ("Relaxed", "relaxed"), ("Double", "double")] {
|
||||
mi(ui, app, l, "design.paragraphSpacing", json!({"value": v}));
|
||||
}
|
||||
});
|
||||
small(ui, app, "effectsDesign", Some("Effects"), "Effects", "design.effects", json!({}), false);
|
||||
small(ui, app, "setDefault", Some("Set as Default"), "Set as Default", "design.setDefault", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Page Background", None, app, |ui, app| {
|
||||
menu_button(ui, app, "watermark", Some("Watermark"), "Watermark", true, |ui, app| {
|
||||
for w in ["DRAFT", "CONFIDENTIAL", "DO NOT COPY", "SAMPLE", "ASAP", "URGENT"] {
|
||||
mi(ui, app, w, "design.watermark", json!({"text": w}));
|
||||
}
|
||||
ui.separator();
|
||||
mi(ui, app, "Custom Watermark…", "ui.dialog", json!({"name": "watermark"}));
|
||||
mi(ui, app, "Remove Watermark", "design.watermark", json!({"remove": true}));
|
||||
});
|
||||
menu_button(ui, app, "pageColor", Some("Page\nColor"), "Page Color", true, |ui, app| {
|
||||
mi(ui, app, "No Color", "design.pageColor", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
let _ = app.run("design.pageColor", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "pageBorders", Some("Page\nBorders"), "Page Borders", true, |ui, app| {
|
||||
mi(ui, app, "Box", "design.pageBorders", json!({"kind": "box"}));
|
||||
mi(ui, app, "None", "design.pageBorders", json!({"kind": "none"}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Page Setup", Some("ui.dialog"), app, |ui, app| {
|
||||
menu_button(ui, app, "margins", Some("Margins"), "Margins", true, |ui, app| {
|
||||
for (l, k) in [("Normal 1\" all", "normal"), ("Narrow 0.5\" all", "narrow"), ("Moderate", "moderate"), ("Wide", "wide"), ("Mirrored", "mirrored"), ("Office 2003 Default", "office2003")] {
|
||||
mi(ui, app, l, "layout.margins", json!({"preset": k}));
|
||||
}
|
||||
mi(ui, app, "Custom Margins…", "ui.dialog", json!({"name": "pageSetup"}));
|
||||
});
|
||||
menu_button(ui, app, "orientation", Some("Orientation"), "Orientation", true, |ui, app| {
|
||||
mi(ui, app, "Portrait", "layout.orientation", json!({"value": "portrait"}));
|
||||
mi(ui, app, "Landscape", "layout.orientation", json!({"value": "landscape"}));
|
||||
});
|
||||
menu_button(ui, app, "size", Some("Size"), "Size", true, |ui, app| {
|
||||
for (n, w, h) in wordcraft_geom::PAPER_SIZES {
|
||||
mi(ui, app, &format!("{n} {:.2}\" × {:.2}\"", w / 72.0, h / 72.0), "layout.size", json!({"name": n}));
|
||||
}
|
||||
});
|
||||
menu_button(ui, app, "columns", Some("Columns"), "Columns", true, |ui, app| {
|
||||
mi(ui, app, "One", "layout.columns", json!({"count": 1}));
|
||||
mi(ui, app, "Two", "layout.columns", json!({"count": 2}));
|
||||
mi(ui, app, "Three", "layout.columns", json!({"count": 3}));
|
||||
mi(ui, app, "Left", "layout.columns", json!({"preset": "left"}));
|
||||
mi(ui, app, "Right", "layout.columns", json!({"preset": "right"}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "breaks", Some("Breaks"), "Breaks", false, |ui, app| {
|
||||
ui.label(egui::RichText::new("Page Breaks").strong());
|
||||
mi(ui, app, "Page", "layout.break", json!({"kind": "page"}));
|
||||
mi(ui, app, "Column", "layout.break", json!({"kind": "column"}));
|
||||
mi(ui, app, "Text Wrapping", "layout.break", json!({"kind": "textWrapping"}));
|
||||
ui.label(egui::RichText::new("Section Breaks").strong());
|
||||
mi(ui, app, "Next Page", "layout.break", json!({"kind": "nextPage"}));
|
||||
mi(ui, app, "Continuous", "layout.break", json!({"kind": "continuous"}));
|
||||
mi(ui, app, "Even Page", "layout.break", json!({"kind": "evenPage"}));
|
||||
mi(ui, app, "Odd Page", "layout.break", json!({"kind": "oddPage"}));
|
||||
});
|
||||
menu_button(ui, app, "lineNumbers", Some("Line Numbers"), "Line Numbers", false, |ui, app| {
|
||||
for (l, v) in [("None", "none"), ("Continuous", "continuous"), ("Restart Each Page", "restartPage"), ("Restart Each Section", "restartSection")] {
|
||||
mi(ui, app, l, "layout.lineNumbers", json!({"value": v}));
|
||||
}
|
||||
});
|
||||
let hy = app.session.doc.settings.auto_hyphenation;
|
||||
menu_button(ui, app, "hyphenation", Some("Hyphenation"), "Hyphenation", false, |ui, app| {
|
||||
mi(ui, app, if hy { "✓ Automatic" } else { "Automatic" }, "layout.hyphenation", json!({"value": true}));
|
||||
mi(ui, app, if hy { "None" } else { "✓ None" }, "layout.hyphenation", json!({"value": false}));
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Paragraph", Some("para.dialog"), app, |ui, app| {
|
||||
let rp = app.session.doc.para_at(&app.session.sel.focus).map(|p| app.session.doc.styles.resolve_para(&p.props));
|
||||
let Some(rp) = rp else { return };
|
||||
egui::Grid::new("layout_para").spacing(vec2(6.0, 4.0)).show(ui, |ui| {
|
||||
ui.label(egui::RichText::new("Indent").small().strong());
|
||||
ui.label("");
|
||||
ui.label(egui::RichText::new("Spacing").small().strong());
|
||||
ui.end_row();
|
||||
let mut l = rp.indent_left / 72.0;
|
||||
let mut b = rp.space_before;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Left:").small());
|
||||
if ui.add(egui::DragValue::new(&mut l).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() {
|
||||
let _ = app.run("para.indents", json!({"left": l * 72.0}));
|
||||
}
|
||||
});
|
||||
ui.label("");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Before:").small());
|
||||
if ui.add(egui::DragValue::new(&mut b).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() {
|
||||
let _ = app.run("para.spacing", json!({"before": b}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
let mut r = rp.indent_right / 72.0;
|
||||
let mut a = rp.space_after;
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("Right:").small());
|
||||
if ui.add(egui::DragValue::new(&mut r).speed(0.05).range(-11.0..=22.0).suffix("\"").max_decimals(2)).changed() {
|
||||
let _ = app.run("para.indents", json!({"right": r * 72.0}));
|
||||
}
|
||||
});
|
||||
ui.label("");
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(egui::RichText::new("After:").small());
|
||||
if ui.add(egui::DragValue::new(&mut a).speed(1.0).range(0.0..=1584.0).suffix(" pt")).changed() {
|
||||
let _ = app.run("para.spacing", json!({"after": a}));
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
});
|
||||
group(ui, "Arrange", None, app, |ui, app| {
|
||||
big(ui, app, "position", "Position", "arrange.position", json!({}), false);
|
||||
big(ui, app, "wrapText", "Wrap\nText", "arrange.wrap", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "bringForward", Some("Bring Forward"), "Bring Forward", "arrange.bringForward", json!({}), false);
|
||||
small(ui, app, "sendBackward", Some("Send Backward"), "Send Backward", "arrange.sendBackward", json!({}), false);
|
||||
small(ui, app, "selectionPane", Some("Selection Pane"), "Selection Pane", "arrange.selectionPane", json!({}), false);
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "align", None, "Align", "arrange.align", json!({}), false);
|
||||
small(ui, app, "group", None, "Group", "arrange.group", json!({}), false);
|
||||
small(ui, app, "rotate", None, "Rotate", "arrange.rotate", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn references(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Table of Contents", None, app, |ui, app| {
|
||||
menu_button(ui, app, "toc", Some("Table of\nContents"), "Table of Contents", true, |ui, app| {
|
||||
mi(ui, app, "Automatic Table 1 (3 levels)", "references.toc", json!({"levels": 3}));
|
||||
mi(ui, app, "Automatic Table 2 (2 levels)", "references.toc", json!({"levels": 2, "title": "Table of Contents"}));
|
||||
mi(ui, app, "Remove Table of Contents", "references.removeToc", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "addText", Some("Add Text"), "Add Text", false, |ui, app| {
|
||||
mi(ui, app, "Do Not Show in TOC", "references.addText", json!({"level": 0}));
|
||||
for l in 1..=3u64 {
|
||||
mi(ui, app, &format!("Level {l}"), "references.addText", json!({"level": l}));
|
||||
}
|
||||
});
|
||||
small(ui, app, "updateTable", Some("Update Table"), "Update Table", "references.updateToc", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Footnotes", None, app, |ui, app| {
|
||||
big(ui, app, "footnote", "Insert\nFootnote", "references.footnote", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "endnote", Some("Insert Endnote"), "Insert Endnote", "references.endnote", json!({}), false);
|
||||
small(ui, app, "nextFootnote", Some("Next Footnote"), "Next Footnote", "references.nextFootnote", json!({}), false);
|
||||
small(ui, app, "showNotes", Some("Show Notes"), "Show Notes", "references.notes", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Research", None, app, |ui, app| {
|
||||
big(ui, app, "researcher", "Researcher", "references.researcher", json!({}), false);
|
||||
});
|
||||
group(ui, "Citations & Bibliography", None, app, |ui, app| {
|
||||
big(ui, app, "citation", "Insert\nCitation", "references.citation", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "sources", Some("Manage Sources"), "Manage Sources", "references.sources", json!({}), false);
|
||||
small(ui, app, "styles", Some("Style: APA"), "Citation Style", "references.citationStyle", json!({}), false);
|
||||
small(ui, app, "bibliography", Some("Bibliography"), "Bibliography", "references.bibliography", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Captions", None, app, |ui, app| {
|
||||
big(ui, app, "caption", "Insert\nCaption", "references.caption", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "tableOfFigures", Some("Insert Table of Figures"), "Insert Table of Figures", "references.tableOfFigures", json!({}), false);
|
||||
small(ui, app, "update", Some("Update Table"), "Update Table of Figures", "references.updateFigures", json!({}), false);
|
||||
small(ui, app, "crossRef", Some("Cross-reference"), "Cross-reference", "insert.crossReference", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Index", None, app, |ui, app| {
|
||||
big(ui, app, "markEntry", "Mark\nEntry", "references.markEntry", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "index", Some("Insert Index"), "Insert Index", "references.index", json!({}), false);
|
||||
small(ui, app, "update", Some("Update Index"), "Update Index", "references.updateIndex", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Table of Authorities", None, app, |ui, app| {
|
||||
big(ui, app, "markCitation", "Mark\nCitation", "references.markCitation", json!({}), false);
|
||||
small(ui, app, "tableOfAuthorities", None, "Insert Table of Authorities", "references.tableOfAuthorities", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn mailings(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Create", None, app, |ui, app| {
|
||||
big(ui, app, "envelope", "Envelopes", "mailings.envelopes", json!({}), false);
|
||||
big(ui, app, "labels", "Labels", "mailings.labels", json!({}), false);
|
||||
});
|
||||
group(ui, "Start Mail Merge", None, app, |ui, app| {
|
||||
big(ui, app, "mailMerge", "Start Mail\nMerge", "mailings.start", json!({}), false);
|
||||
big(ui, app, "recipients", "Select\nRecipients", "mailings.recipients", json!({}), false);
|
||||
big(ui, app, "editRecipients", "Edit\nRecipient List", "mailings.editRecipients", json!({}), false);
|
||||
});
|
||||
group(ui, "Write & Insert Fields", None, app, |ui, app| {
|
||||
big(ui, app, "highlightFields", "Highlight\nMerge Fields", "mailings.highlightFields", json!({}), false);
|
||||
big(ui, app, "addressBlock", "Address\nBlock", "mailings.addressBlock", json!({}), false);
|
||||
big(ui, app, "greetingLine", "Greeting\nLine", "mailings.greetingLine", json!({}), false);
|
||||
big(ui, app, "mergeField", "Insert Merge\nField", "mailings.insertField", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "rules", Some("Rules"), "Rules", "mailings.rules", json!({}), false);
|
||||
small(ui, app, "matchFields", Some("Match Fields"), "Match Fields", "mailings.matchFields", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Preview Results", None, app, |ui, app| {
|
||||
big(ui, app, "preview", "Preview\nResults", "mailings.preview", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
crate::widgets::row(ui, |ui| {
|
||||
small(ui, app, "previous", None, "Previous Record", "mailings.previous", json!({}), false);
|
||||
small(ui, app, "next", None, "Next Record", "mailings.next", json!({}), false);
|
||||
});
|
||||
small(ui, app, "find", Some("Find Recipient"), "Find Recipient", "mailings.findRecipient", json!({}), false);
|
||||
small(ui, app, "checkErrors", Some("Check for Errors"), "Check for Errors", "mailings.checkErrors", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Finish", None, app, |ui, app| {
|
||||
big(ui, app, "finish", "Finish &\nMerge", "mailings.finish", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn review(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Proofing", None, app, |ui, app| {
|
||||
big(ui, app, "spelling", "Spelling &\nGrammar", "review.spelling", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "thesaurus", Some("Thesaurus"), "Thesaurus", "review.thesaurus", json!({}), false);
|
||||
small(ui, app, "wordCount", Some("Word Count"), "Word Count", "ui.dialog", json!({"name": "wordCount"}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Speech", None, app, |ui, app| {
|
||||
big(ui, app, "readAloud", "Read\nAloud", "review.readAloud", json!({}), false);
|
||||
});
|
||||
group(ui, "Accessibility", None, app, |ui, app| {
|
||||
big(ui, app, "accessibility", "Check\nAccessibility", "file.accessibility", json!({}), false);
|
||||
});
|
||||
group(ui, "Language", None, app, |ui, app| {
|
||||
big(ui, app, "translate", "Translate", "review.translate", json!({}), false);
|
||||
big(ui, app, "language", "Language", "review.language", json!({}), false);
|
||||
});
|
||||
group(ui, "Comments", None, app, |ui, app| {
|
||||
big(ui, app, "newComment", "New\nComment", "review.newComment", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "deleteComment", Some("Delete"), "Delete Comment", "review.deleteComment", json!({}), false);
|
||||
small(ui, app, "prevComment", Some("Previous"), "Previous Comment", "review.previousComment", json!({}), false);
|
||||
small(ui, app, "nextComment", Some("Next"), "Next Comment", "review.nextComment", json!({}), false);
|
||||
});
|
||||
let shown = app.session.view.comments_pane;
|
||||
big(ui, app, "showComments", "Show\nComments", "view.commentsPane", json!({"value": !shown}), false);
|
||||
});
|
||||
group(ui, "Tracking", None, app, |ui, app| {
|
||||
let on = app.session.doc.settings.track_changes;
|
||||
let r = big(ui, app, "trackChanges", if on { "Track\nChanges ✓" } else { "Track\nChanges" }, "review.trackChanges", json!({}), false);
|
||||
let _ = r;
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "markup", Some(if app.session.view.show_markup { "All Markup" } else { "No Markup" }), "Display for Review", false, |ui, app| {
|
||||
mi(ui, app, "All Markup", "review.markup", json!({"value": "all"}));
|
||||
mi(ui, app, "No Markup", "review.markup", json!({"value": "noMarkup"}));
|
||||
});
|
||||
small(ui, app, "reviewingPane", Some("Reviewing Pane"), "Reviewing Pane", "review.changes", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Changes", None, app, |ui, app| {
|
||||
menu_button(ui, app, "accept", Some("Accept"), "Accept", true, |ui, app| {
|
||||
mi(ui, app, "Accept This Change", "review.accept", json!({}));
|
||||
mi(ui, app, "Accept All Changes", "review.acceptAll", json!({}));
|
||||
});
|
||||
menu_button(ui, app, "reject", Some("Reject"), "Reject", true, |ui, app| {
|
||||
mi(ui, app, "Reject Change", "review.reject", json!({}));
|
||||
mi(ui, app, "Reject All Changes", "review.rejectAll", json!({}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "prevChange", Some("Previous"), "Previous Change", "review.previousChange", json!({}), false);
|
||||
small(ui, app, "nextChange", Some("Next"), "Next Change", "review.nextChange", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Compare", None, app, |ui, app| {
|
||||
big(ui, app, "compare", "Compare", "review.compare", json!({}), false);
|
||||
});
|
||||
group(ui, "Protect", None, app, |ui, app| {
|
||||
big(ui, app, "blockAuthors", "Block\nAuthors", "review.blockAuthors", json!({}), false);
|
||||
big(ui, app, "restrict", "Restrict\nEditing", "review.restrict", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn view(app: &mut WordApp, ui: &mut Ui) {
|
||||
let v = app.session.view.clone();
|
||||
group(ui, "Views", None, app, |ui, app| {
|
||||
big(ui, app, "readMode", "Read\nMode", "view.readMode", json!({}), false);
|
||||
big(ui, app, "printLayout", "Print\nLayout", "view.printLayout", json!({}), false);
|
||||
big(ui, app, "webLayout", "Web\nLayout", "view.webLayout", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "outline", Some("Outline"), "Outline", "view.outline", json!({}), false);
|
||||
small(ui, app, "draft", Some("Draft"), "Draft", "view.draft", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Immersive", None, app, |ui, app| {
|
||||
big(ui, app, "focus", "Focus", "view.focus", json!({}), false);
|
||||
big(ui, app, "immersive", "Immersive\nReader", "view.immersive", json!({}), false);
|
||||
});
|
||||
group(ui, "Page Movement", None, app, |ui, app| {
|
||||
big(ui, app, "vertical", "Vertical", "view.vertical", json!({}), false);
|
||||
big(ui, app, "sideToSide", "Side\nto Side", "view.sideToSide", json!({}), false);
|
||||
});
|
||||
group(ui, "Show", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
let mut r = v.ruler;
|
||||
if ui.checkbox(&mut r, "Ruler").changed() {
|
||||
let _ = app.run("view.ruler", json!({"value": r}));
|
||||
}
|
||||
let mut g = v.gridlines;
|
||||
if ui.checkbox(&mut g, "Gridlines").changed() {
|
||||
let _ = app.run("view.gridlines", json!({"value": g}));
|
||||
}
|
||||
let mut n = v.nav_pane;
|
||||
if ui.checkbox(&mut n, "Navigation Pane").changed() {
|
||||
let _ = app.run("view.navigationPane", json!({"value": n}));
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Zoom", None, app, |ui, app| {
|
||||
big(ui, app, "zoom", "Zoom", "ui.dialog", json!({"name": "zoom"}), false);
|
||||
big(ui, app, "zoom100", "100%", "view.zoom100", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "onePage", Some("One Page"), "One Page", "view.onePage", json!({}), v.fit == "onePage");
|
||||
small(ui, app, "multiplePages", Some("Multiple Pages"), "Multiple Pages", "view.multiplePages", json!({}), v.multi_page);
|
||||
small(ui, app, "pageWidth", Some("Page Width"), "Page Width", "view.pageWidth", json!({}), v.fit == "pageWidth");
|
||||
});
|
||||
});
|
||||
group(ui, "Dark Mode", None, app, |ui, app| {
|
||||
big(ui, app, "darkMode", "Switch\nModes", "view.darkMode", json!({}), false);
|
||||
});
|
||||
group(ui, "Window", None, app, |ui, app| {
|
||||
big(ui, app, "newWindow", "New\nWindow", "view.newWindow", json!({}), false);
|
||||
big(ui, app, "arrangeAll", "Arrange\nAll", "view.arrangeAll", json!({}), false);
|
||||
big(ui, app, "split", "Split", "view.split", json!({}), false);
|
||||
});
|
||||
group(ui, "Macros", None, app, |ui, app| {
|
||||
big(ui, app, "macros", "Macros", "tools.macros", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn help(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Help", None, app, |ui, app| {
|
||||
big(ui, app, "help", "Help", "ui.dialog", json!({"name": "about"}), false);
|
||||
big(ui, app, "discord", "Community\nDiscord", "ui.discord", json!({}), false);
|
||||
});
|
||||
group(ui, "Agents", None, app, |ui, app| {
|
||||
big(ui, app, "macros", "Commands", "ui.dialog", json!({"name": "commands"}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
let look = app.session.sel.focus.path.cell().and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look));
|
||||
group(ui, "Table Style Options", None, app, |ui, app| {
|
||||
let Some(l) = look else { return };
|
||||
egui::Grid::new("look").show(ui, |ui| {
|
||||
for (row, items) in [[("Header Row", "headerRow", l.header_row), ("First Column", "firstColumn", l.first_column)], [("Total Row", "totalRow", l.total_row), ("Last Column", "lastColumn", l.last_column)], [("Banded Rows", "bandedRows", l.banded_rows), ("Banded Columns", "bandedColumns", l.banded_columns)]].iter().enumerate() {
|
||||
let _ = row;
|
||||
for (label, key, val) in items {
|
||||
let mut v = *val;
|
||||
if ui.checkbox(&mut v, *label).changed() {
|
||||
let _ = app.run("table.look", json!({ *key: v }));
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(String, String)> = app.session.doc.styles.styles.iter().filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden).map(|s| (s.id.clone(), s.name.clone())).collect();
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| {
|
||||
mi(ui, app, "No Color", "table.shading", json!({"color": null}));
|
||||
let theme = app.session.doc.settings.theme_colors.clone();
|
||||
if let Some(hex) = color_grid(ui, &theme) {
|
||||
app.canvas.last_shading = hex.clone();
|
||||
let _ = app.run("table.shading", json!({"color": hex}));
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
group(ui, "Borders", None, app, |ui, app| {
|
||||
menu_button(ui, app, "borders", Some("Borders"), "Borders", true, |ui, app| {
|
||||
for (l, k) in [("All Borders", "all"), ("Outside Borders", "outside"), ("Inside Borders", "inside"), ("No Border", "none"), ("Top Border", "top"), ("Bottom Border", "bottom")] {
|
||||
mi(ui, app, l, "table.borders", json!({"kind": k}));
|
||||
}
|
||||
});
|
||||
big(ui, app, "borderPainter", "Border\nPainter", "table.borderPainter", json!({}), false);
|
||||
});
|
||||
}
|
||||
|
||||
fn table_layout(app: &mut WordApp, ui: &mut Ui) {
|
||||
group(ui, "Table", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
menu_button(ui, app, "selectTable", Some("Select"), "Select", false, |ui, app| {
|
||||
mi(ui, app, "Select Cell", "table.selectCell", json!({}));
|
||||
mi(ui, app, "Select Row", "table.selectRow", json!({}));
|
||||
mi(ui, app, "Select Table", "table.selectTable", json!({}));
|
||||
});
|
||||
let g = app.session.view.gridlines;
|
||||
small(ui, app, "gridlines", Some("View Gridlines"), "View Gridlines", "view.gridlines", json!({}), g);
|
||||
small(ui, app, "properties", Some("Properties"), "Table Properties", "table.properties", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Rows & Columns", None, app, |ui, app| {
|
||||
menu_button(ui, app, "deleteTable", Some("Delete"), "Delete", true, |ui, app| {
|
||||
mi(ui, app, "Delete Cells", "table.deleteCells", json!({}));
|
||||
mi(ui, app, "Delete Columns", "table.deleteColumn", json!({}));
|
||||
mi(ui, app, "Delete Rows", "table.deleteRow", json!({}));
|
||||
mi(ui, app, "Delete Table", "table.deleteTable", json!({}));
|
||||
});
|
||||
big(ui, app, "insertAbove", "Insert\nAbove", "table.insertRowAbove", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "insertBelow", Some("Insert Below"), "Insert Below", "table.insertRowBelow", json!({}), false);
|
||||
small(ui, app, "insertLeft", Some("Insert Left"), "Insert Left", "table.insertColumnLeft", json!({}), false);
|
||||
small(ui, app, "insertRight", Some("Insert Right"), "Insert Right", "table.insertColumnRight", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Merge", None, app, |ui, app| {
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "merge", Some("Merge Cells"), "Merge Cells", "table.merge", json!({}), false);
|
||||
small(ui, app, "splitCells", Some("Split Cells"), "Split Cells", "table.split", json!({}), false);
|
||||
small(ui, app, "splitTable", Some("Split Table"), "Split Table", "table.splitTable", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Cell Size", None, app, |ui, app| {
|
||||
menu_button(ui, app, "autofit", Some("AutoFit"), "AutoFit", true, |ui, app| {
|
||||
mi(ui, app, "AutoFit Contents", "table.autofit", json!({"mode": "contents"}));
|
||||
mi(ui, app, "AutoFit Window", "table.autofit", json!({"mode": "window"}));
|
||||
mi(ui, app, "Fixed Column Width", "table.autofit", json!({"mode": "fixed"}));
|
||||
});
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "distributeRows", Some("Distribute Rows"), "Distribute Rows", "table.distributeRows", json!({}), false);
|
||||
small(ui, app, "distributeCols", Some("Distribute Columns"), "Distribute Columns", "table.distributeColumns", json!({}), false);
|
||||
});
|
||||
});
|
||||
group(ui, "Alignment", None, app, |ui, app| {
|
||||
egui::Grid::new("cellalign").spacing(vec2(1.0, 1.0)).show(ui, |ui| {
|
||||
for row in [["topLeft", "topCenter", "topRight"], ["centerLeft", "center", "centerRight"], ["bottomLeft", "bottomCenter", "bottomRight"]] {
|
||||
for v in row {
|
||||
small(ui, app, "cellAlign", None, v, "table.cellAlign", json!({"value": v}), false);
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
big(ui, app, "textDirection", "Text\nDirection", "table.textDirection", json!({}), false);
|
||||
big(ui, app, "cellMargins", "Cell\nMargins", "table.cellMargins", json!({}), false);
|
||||
});
|
||||
group(ui, "Data", None, app, |ui, app| {
|
||||
big(ui, app, "sort", "Sort", "table.sort", json!({}), false);
|
||||
stack(ui, |ui| {
|
||||
small(ui, app, "repeatHeader", Some("Repeat Header Rows"), "Repeat Header Rows", "table.repeatHeader", json!({}), false);
|
||||
small(ui, app, "toText", Some("Convert to Text"), "Convert to Text", "table.toText", json!({}), false);
|
||||
small(ui, app, "formula", Some("Formula"), "Formula", "table.formula", json!({}), false);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Design tokens (light and dark), UI fonts and the egui style.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use egui::{Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Stroke, Visuals};
|
||||
|
||||
/// WordCraft's app colour (and its darker ink for text on light backgrounds).
|
||||
pub const APP_COLOR: Color32 = Color32::from_rgb(0x3B, 0x5B, 0xDB);
|
||||
pub const APP_INK: Color32 = Color32::from_rgb(0x2B, 0x47, 0xB5);
|
||||
|
||||
/// Every colour the UI uses; widgets never hard-code colours.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Tokens {
|
||||
pub dark: bool,
|
||||
pub title_bar: Color32,
|
||||
pub ribbon: Color32,
|
||||
pub tab_strip: Color32,
|
||||
pub canvas: Color32,
|
||||
pub panel: Color32,
|
||||
pub border: Color32,
|
||||
pub border_strong: Color32,
|
||||
pub text: Color32,
|
||||
pub text_dim: Color32,
|
||||
pub text_disabled: Color32,
|
||||
pub icon: Color32,
|
||||
pub hover: Color32,
|
||||
pub pressed: Color32,
|
||||
pub checked: Color32,
|
||||
pub accent: Color32,
|
||||
pub accent_text: Color32,
|
||||
pub input: Color32,
|
||||
pub input_border: Color32,
|
||||
pub group_label: Color32,
|
||||
pub ruler: Color32,
|
||||
pub ruler_margin: Color32,
|
||||
pub ruler_tick: Color32,
|
||||
pub status_bar: Color32,
|
||||
pub selection: Color32,
|
||||
pub caret: Color32,
|
||||
pub page_shadow: Color32,
|
||||
pub menu: Color32,
|
||||
pub blue: Color32,
|
||||
pub red: Color32,
|
||||
pub green: Color32,
|
||||
pub orange: Color32,
|
||||
}
|
||||
|
||||
impl Tokens {
|
||||
pub fn light() -> Self {
|
||||
Tokens {
|
||||
dark: false,
|
||||
title_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
ribbon: Color32::from_rgb(0xF9, 0xF9, 0xF9),
|
||||
tab_strip: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
canvas: Color32::from_rgb(0xE6, 0xE6, 0xE6),
|
||||
panel: Color32::from_rgb(0xFA, 0xFA, 0xFA),
|
||||
border: Color32::from_rgb(0xE1, 0xE1, 0xE1),
|
||||
border_strong: Color32::from_rgb(0xC8, 0xC8, 0xC8),
|
||||
text: Color32::from_rgb(0x24, 0x24, 0x24),
|
||||
text_dim: Color32::from_rgb(0x61, 0x61, 0x61),
|
||||
text_disabled: Color32::from_rgb(0xA8, 0xA8, 0xA8),
|
||||
icon: Color32::from_rgb(0x42, 0x42, 0x42),
|
||||
hover: Color32::from_rgb(0xE6, 0xE6, 0xE6),
|
||||
pressed: Color32::from_rgb(0xD6, 0xD6, 0xD6),
|
||||
checked: Color32::from_rgb(0xDD, 0xE3, 0xF8),
|
||||
accent: APP_COLOR,
|
||||
accent_text: APP_INK,
|
||||
input: Color32::WHITE,
|
||||
input_border: Color32::from_rgb(0xC7, 0xC7, 0xC7),
|
||||
group_label: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||
ruler: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||
ruler_margin: Color32::from_rgb(0xD4, 0xD4, 0xD4),
|
||||
ruler_tick: Color32::from_rgb(0x70, 0x70, 0x70),
|
||||
status_bar: Color32::from_rgb(0xF0, 0xF0, 0xF0),
|
||||
selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
|
||||
menu: Color32::WHITE,
|
||||
blue: Color32::from_rgb(0x2F, 0x6F, 0xD0),
|
||||
red: Color32::from_rgb(0xD1, 0x3B, 0x3B),
|
||||
green: Color32::from_rgb(0x2E, 0x8B, 0x57),
|
||||
orange: Color32::from_rgb(0xE0, 0x7B, 0x1F),
|
||||
}
|
||||
}
|
||||
pub fn dark() -> Self {
|
||||
Tokens {
|
||||
dark: true,
|
||||
title_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||
ribbon: Color32::from_rgb(0x29, 0x29, 0x29),
|
||||
tab_strip: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||
canvas: Color32::from_rgb(0x14, 0x14, 0x14),
|
||||
panel: Color32::from_rgb(0x26, 0x26, 0x26),
|
||||
border: Color32::from_rgb(0x3A, 0x3A, 0x3A),
|
||||
border_strong: Color32::from_rgb(0x55, 0x55, 0x55),
|
||||
text: Color32::from_rgb(0xEE, 0xEE, 0xEE),
|
||||
text_dim: Color32::from_rgb(0xB0, 0xB0, 0xB0),
|
||||
text_disabled: Color32::from_rgb(0x6A, 0x6A, 0x6A),
|
||||
icon: Color32::from_rgb(0xDD, 0xDD, 0xDD),
|
||||
hover: Color32::from_rgb(0x3D, 0x3D, 0x3D),
|
||||
pressed: Color32::from_rgb(0x4A, 0x4A, 0x4A),
|
||||
checked: Color32::from_rgb(0x2E, 0x3A, 0x66),
|
||||
accent: Color32::from_rgb(0x7A, 0x93, 0xF0),
|
||||
accent_text: Color32::from_rgb(0x9C, 0xB0, 0xF5),
|
||||
input: Color32::from_rgb(0x1E, 0x1E, 0x1E),
|
||||
input_border: Color32::from_rgb(0x58, 0x58, 0x58),
|
||||
group_label: Color32::from_rgb(0xA0, 0xA0, 0xA0),
|
||||
ruler: Color32::from_rgb(0x2E, 0x2E, 0x2E),
|
||||
ruler_margin: Color32::from_rgb(0x45, 0x45, 0x45),
|
||||
ruler_tick: Color32::from_rgb(0xA0, 0xA0, 0xA0),
|
||||
status_bar: Color32::from_rgb(0x1F, 0x1F, 0x1F),
|
||||
selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
|
||||
caret: Color32::BLACK,
|
||||
page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
|
||||
menu: Color32::from_rgb(0x2B, 0x2B, 0x2B),
|
||||
blue: Color32::from_rgb(0x6C, 0x9B, 0xEA),
|
||||
red: Color32::from_rgb(0xEE, 0x6A, 0x6A),
|
||||
green: Color32::from_rgb(0x5B, 0xC0, 0x8A),
|
||||
orange: Color32::from_rgb(0xF0, 0xA0, 0x50),
|
||||
}
|
||||
}
|
||||
pub fn get(ctx: &egui::Context) -> Tokens {
|
||||
if ctx.global_style().visuals.dark_mode { Tokens::dark() } else { Tokens::light() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Install the UI fonts (Inter, JetBrains Mono) and the craft-fonts Japanese UI face if built in.
|
||||
pub fn install_fonts(ctx: &egui::Context) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||
fonts.font_data.insert(name.into(), Arc::new(FontData::from_static(bytes)));
|
||||
};
|
||||
add(&mut fonts, "Inter", include_bytes!("../../../assets/fonts/Inter-Regular.ttf"));
|
||||
add(&mut fonts, "InterMedium", include_bytes!("../../../assets/fonts/Inter-Medium.ttf"));
|
||||
add(&mut fonts, "InterSemiBold", include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"));
|
||||
add(&mut fonts, "SourceSans", include_bytes!("../../../assets/fonts/SourceSans3-Regular.ttf"));
|
||||
add(&mut fonts, "Mono", include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf"));
|
||||
if let Some(prop) = fonts.families.get_mut(&FontFamily::Proportional) {
|
||||
prop.insert(0, "Inter".into());
|
||||
prop.push("SourceSans".into());
|
||||
}
|
||||
if let Some(m) = fonts.families.get_mut(&FontFamily::Monospace) {
|
||||
m.insert(0, "Mono".into());
|
||||
}
|
||||
fonts.families.insert(FontFamily::Name("medium".into()), vec!["InterMedium".into(), "Inter".into(), "SourceSans".into()]);
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["InterSemiBold".into(), "Inter".into(), "SourceSans".into()]);
|
||||
for f in wordcraft_fonts::japanese_ui_fonts(false).into_iter().take(1) {
|
||||
fonts.font_data.insert("JpUi".into(), Arc::new(FontData::from_static(f.bytes)));
|
||||
for fam in [FontFamily::Proportional, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
|
||||
if let Some(v) = fonts.families.get_mut(&fam) {
|
||||
v.push("JpUi".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Symbols and emoji fall back to egui's defaults (kept in the families).
|
||||
ctx.set_fonts(fonts);
|
||||
}
|
||||
|
||||
pub fn medium(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("medium".into()))
|
||||
}
|
||||
pub fn semibold(size: f32) -> FontId {
|
||||
FontId::new(size, FontFamily::Name("semibold".into()))
|
||||
}
|
||||
pub fn regular(size: f32) -> FontId {
|
||||
FontId::proportional(size)
|
||||
}
|
||||
|
||||
/// Apply tokens to egui's style.
|
||||
pub fn apply(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut v = if t.dark { Visuals::dark() } else { Visuals::light() };
|
||||
v.panel_fill = t.ribbon;
|
||||
v.window_fill = t.menu;
|
||||
v.extreme_bg_color = t.input;
|
||||
v.faint_bg_color = t.panel;
|
||||
v.window_stroke = Stroke::new(1.0, t.border_strong);
|
||||
v.window_corner_radius = CornerRadius::same(8);
|
||||
v.menu_corner_radius = CornerRadius::same(6);
|
||||
v.selection.bg_fill = t.selection;
|
||||
v.selection.stroke = Stroke::new(1.0, t.accent);
|
||||
v.hyperlink_color = t.accent_text;
|
||||
v.widgets.noninteractive.bg_stroke = Stroke::new(1.0, t.border);
|
||||
v.widgets.noninteractive.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.inactive.bg_fill = t.input;
|
||||
v.widgets.inactive.weak_bg_fill = t.input;
|
||||
v.widgets.inactive.bg_stroke = Stroke::new(1.0, t.input_border);
|
||||
v.widgets.inactive.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.inactive.corner_radius = CornerRadius::same(4);
|
||||
v.widgets.hovered.bg_fill = t.hover;
|
||||
v.widgets.hovered.weak_bg_fill = t.hover;
|
||||
v.widgets.hovered.bg_stroke = Stroke::new(1.0, t.border_strong);
|
||||
v.widgets.hovered.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.hovered.corner_radius = CornerRadius::same(4);
|
||||
v.widgets.active.bg_fill = t.pressed;
|
||||
v.widgets.active.weak_bg_fill = t.pressed;
|
||||
v.widgets.active.fg_stroke = Stroke::new(1.0, t.text);
|
||||
v.widgets.active.corner_radius = CornerRadius::same(4);
|
||||
v.widgets.open.bg_fill = t.pressed;
|
||||
v.widgets.open.weak_bg_fill = t.pressed;
|
||||
v.popup_shadow = egui::Shadow { offset: [0, 4], blur: 16, spread: 0, color: Color32::from_black_alpha(if t.dark { 120 } else { 40 }) };
|
||||
v.window_shadow = egui::Shadow { offset: [0, 8], blur: 28, spread: 0, color: Color32::from_black_alpha(if t.dark { 140 } else { 50 }) };
|
||||
ctx.set_visuals(v);
|
||||
ctx.global_style_mut(|s| {
|
||||
s.spacing.item_spacing = egui::vec2(6.0, 4.0);
|
||||
s.spacing.button_padding = egui::vec2(8.0, 3.0);
|
||||
s.spacing.interact_size = egui::vec2(24.0, 22.0);
|
||||
s.spacing.menu_margin = egui::Margin::same(6);
|
||||
s.text_styles.insert(egui::TextStyle::Body, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Button, regular(12.5));
|
||||
s.text_styles.insert(egui::TextStyle::Small, regular(10.5));
|
||||
s.text_styles.insert(egui::TextStyle::Heading, semibold(17.0));
|
||||
s.interaction.tooltip_delay = 0.45;
|
||||
});
|
||||
}
|
||||
|
||||
pub fn c32(c: wordcraft_doc::Rgb) -> Color32 {
|
||||
Color32::from_rgb(c.0, c.1, c.2)
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
//! Ribbon widgets: large and small buttons, split buttons, groups, combo fields, colour grids.
|
||||
|
||||
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::theme::{Tokens, regular};
|
||||
use crate::{WordApp, icons};
|
||||
|
||||
/// Height of the ribbon content area (without group labels).
|
||||
pub const CONTENT_H: f32 = 66.0;
|
||||
pub const LABEL_H: f32 = 16.0;
|
||||
|
||||
/// Shortcut text for a command (`⌘B` on macOS, `Ctrl+B` elsewhere).
|
||||
pub fn shortcut_text(app: &WordApp, id: &str) -> String {
|
||||
let Some(spec) = app.session.registry.get(id) else { return String::new() };
|
||||
let sc = spec.shortcut.split(" / ").next().unwrap_or("");
|
||||
if sc.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
sc.replace("Mod+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃")
|
||||
} else {
|
||||
sc.replace("Mod+", "Ctrl+")
|
||||
}
|
||||
}
|
||||
|
||||
fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response {
|
||||
let sc = shortcut_text(app, id);
|
||||
let desc = app.session.registry.get(id).map(|s| s.location).unwrap_or("");
|
||||
let enabled = app.session.registry.get(id).map(|s| (s.enabled)(&app.session).is_none()).unwrap_or(true);
|
||||
resp.on_hover_ui(|ui| {
|
||||
ui.set_max_width(260.0);
|
||||
if sc.is_empty() {
|
||||
ui.label(egui::RichText::new(label).font(crate::theme::semibold(12.0)));
|
||||
} else {
|
||||
ui.label(egui::RichText::new(format!("{label} ({sc})")).font(crate::theme::semibold(12.0)));
|
||||
}
|
||||
if !desc.is_empty() {
|
||||
ui.label(egui::RichText::new(desc).small().weak());
|
||||
}
|
||||
if !enabled {
|
||||
ui.label(egui::RichText::new("Not available right now").small().weak());
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a command exists and is enabled.
|
||||
pub fn enabled(app: &WordApp, id: &str) -> bool {
|
||||
match app.session.registry.get(id) {
|
||||
Some(s) => (s.enabled)(&app.session).is_none(),
|
||||
None => id.starts_with("ui."),
|
||||
}
|
||||
}
|
||||
|
||||
fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
|
||||
let fill = if resp.is_pointer_button_down_on() {
|
||||
t.pressed
|
||||
} else if checked {
|
||||
t.checked
|
||||
} else if resp.hovered() {
|
||||
t.hover
|
||||
} else {
|
||||
return;
|
||||
};
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), fill);
|
||||
if checked {
|
||||
ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside);
|
||||
}
|
||||
}
|
||||
|
||||
/// A large ribbon button: 32 px icon over a (possibly two-line) label.
|
||||
pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let galley_w = label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
|
||||
let w = (galley_w + 12.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, false, &t);
|
||||
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
let mut y = r.min.y + 43.0;
|
||||
let lines: Vec<&str> = label.split('\n').collect();
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let txt = if menu && i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() };
|
||||
ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), if on { t.text } else { t.text_disabled });
|
||||
y += 13.0;
|
||||
}
|
||||
let resp = tooltip(app, resp, &label.replace('\n', " "), id);
|
||||
if resp.clicked() && on && !menu {
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// A small button: 16 px icon with an optional label; `checked` draws the toggled state.
|
||||
pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, id: &str, params: Value, checked: bool) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
|
||||
let on = enabled(app, id);
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0));
|
||||
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
|
||||
icons::paint(ui.painter(), ic, icon, c, a);
|
||||
if let Some(l) = label {
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled });
|
||||
}
|
||||
let resp = tooltip(app, resp, tip, id);
|
||||
if resp.clicked() && on {
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// A split button: the left part runs `id`, the arrow opens a menu filled by `menu`.
|
||||
pub fn split(ui: &mut Ui, app: &mut WordApp, icon: &str, tip: &str, id: &str, params: Value, checked: bool, swatch: Option<Color32>, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
|
||||
bg(ui, r, &resp, checked, &t);
|
||||
bg(ui, ar, &aresp, false, &t);
|
||||
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
|
||||
icons::paint(ui.painter(), ic, icon, t.icon, t.accent);
|
||||
if let Some(sw) = swatch {
|
||||
ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent);
|
||||
let resp = tooltip(app, resp, tip, id);
|
||||
if resp.clicked() {
|
||||
let _ = app.run(id, params);
|
||||
}
|
||||
egui::Popup::menu(&aresp).show(|ui| {
|
||||
ui.set_min_width(180.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
}
|
||||
|
||||
/// A button that only opens a menu (icon + label + ▾).
|
||||
pub fn menu_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, big_btn: bool, menu: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let resp = if big_btn {
|
||||
let galley_w = label.unwrap_or("").split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
|
||||
let w = (galley_w + 22.0).max(44.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
|
||||
bg(ui, r, &resp, false, &t);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, t.icon, t.accent);
|
||||
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
|
||||
let mut y = r.min.y + 43.0;
|
||||
for (i, l) in lines.iter().enumerate() {
|
||||
let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() };
|
||||
ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text);
|
||||
y += 13.0;
|
||||
}
|
||||
resp
|
||||
} else {
|
||||
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
|
||||
bg(ui, r, &resp, false, &t);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, t.icon, t.accent);
|
||||
if let Some(l) = label {
|
||||
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), t.text);
|
||||
}
|
||||
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent);
|
||||
resp
|
||||
};
|
||||
let resp = resp.on_hover_text(tip);
|
||||
egui::Popup::menu(&resp).show(|ui| {
|
||||
ui.set_min_width(200.0);
|
||||
menu(ui, app);
|
||||
});
|
||||
}
|
||||
|
||||
/// A ribbon group: content, a centred label below and a divider on the right.
|
||||
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
|
||||
let start = ui.cursor().min;
|
||||
let inner = ui.vertical(|ui| {
|
||||
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), egui::Layout::left_to_right(egui::Align::Min), |ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
|
||||
add(ui, app);
|
||||
});
|
||||
});
|
||||
let r = inner.response.rect;
|
||||
let r = Rect::from_min_max(start, pos2(r.max.x, start.y + CONTENT_H + LABEL_H));
|
||||
ui.painter().text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
|
||||
if let Some(cmd) = launcher {
|
||||
let lr = Rect::from_center_size(pos2(r.max.x - 6.0, r.max.y - 8.0), vec2(11.0, 11.0));
|
||||
let resp = ui.interact(lr, ui.id().with(("launch", title)), Sense::click());
|
||||
if resp.hovered() {
|
||||
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
|
||||
}
|
||||
icons::paint(ui.painter(), lr, "launcher", t.text_dim, t.accent);
|
||||
if resp.on_hover_text(format!("{title} settings")).clicked() {
|
||||
let _ = app.run(cmd, serde_json::json!({}));
|
||||
}
|
||||
}
|
||||
ui.add_space(5.0);
|
||||
let x = ui.cursor().min.x;
|
||||
ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border));
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
|
||||
/// A row of small buttons (used inside a vertical stack).
|
||||
pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
|
||||
add(ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// Word's standard colour grid (theme colours + tints, standard colours). Returns a picked hex.
|
||||
pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
|
||||
let mut picked = None;
|
||||
ui.label(egui::RichText::new("Theme Colors").small().weak());
|
||||
let base: Vec<wordcraft_doc::Rgb> = theme.iter().take(10).copied().collect();
|
||||
let tint = |c: wordcraft_doc::Rgb, k: f32| {
|
||||
let f = |v: u8| if k >= 0.0 { (v as f32 + (255.0 - v as f32) * k) as u8 } else { (v as f32 * (1.0 + k)) as u8 };
|
||||
wordcraft_doc::Rgb(f(c.0), f(c.1), f(c.2))
|
||||
};
|
||||
let order = [1usize, 0, 3, 2, 4, 5, 6, 7, 8, 9];
|
||||
for k in [0.0f32, 0.8, 0.6, 0.4, -0.25, -0.5] {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, if k == 0.0 { 6.0 } else { 0.0 });
|
||||
for i in order {
|
||||
let Some(c) = base.get(i).copied() else { continue };
|
||||
let c = tint(c, k);
|
||||
if swatch(ui, c) {
|
||||
picked = Some(c.hex());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new("Standard Colors").small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(3.0, 0.0);
|
||||
for hex in ["C00000", "FF0000", "FFC000", "FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0"] {
|
||||
if let Some(c) = wordcraft_doc::Rgb::parse(hex)
|
||||
&& swatch(ui, c)
|
||||
{
|
||||
picked = Some(hex.to_string());
|
||||
}
|
||||
}
|
||||
});
|
||||
picked
|
||||
}
|
||||
|
||||
fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
|
||||
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.painter().rect_stroke(r, 0.0, Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }), egui::StrokeKind::Inside);
|
||||
resp.on_hover_text(format!("#{}", c.hex())).clicked()
|
||||
}
|
||||
|
||||
/// A ribbon-style text field with a dropdown list. Returns a chosen/typed value.
|
||||
pub fn combo(ui: &mut Ui, id: &str, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option<String> {
|
||||
let mut out = None;
|
||||
let edit_id = ui.id().with(("combo_text", id));
|
||||
let mut text = ui.data_mut(|d| d.get_temp::<String>(edit_id)).unwrap_or_else(|| current.to_string());
|
||||
let t = Tokens::get(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
|
||||
let te = egui::TextEdit::singleline(&mut text).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0));
|
||||
let resp = ui.add(te);
|
||||
if resp.has_focus() {
|
||||
ui.data_mut(|d| d.insert_temp(edit_id, text.clone()));
|
||||
} else {
|
||||
ui.data_mut(|d| d.remove::<String>(edit_id));
|
||||
}
|
||||
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
out = Some(text.clone());
|
||||
}
|
||||
let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click());
|
||||
ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
|
||||
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent);
|
||||
egui::Popup::menu(&aresp).show(|ui| {
|
||||
egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| {
|
||||
ui.set_min_width(width + 60.0);
|
||||
for it in items {
|
||||
let clicked = match preview {
|
||||
Some(p) => p(ui, it).clicked(),
|
||||
None => ui.selectable_label(it == current, it).clicked(),
|
||||
};
|
||||
if clicked {
|
||||
out = Some(it.clone());
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
out
|
||||
}
|
||||
Reference in New Issue
Block a user