Linux: open/save works without a portal file dialog (#576) (#583)

* Linux: built-in file browser when the portal file dialog is unavailable (#576)

rfd 0.17 on Linux/BSD asks the xdg-desktop-portal FileChooser and falls back to
zenity; with neither (labwc with only xdg-desktop-portal-wlr, no zenity) it
returns None, the same as Cancel, so Open/Save As/Insert Picture silently did
nothing. The dialog thread now checks first (zenity on PATH, or the portal's
FileChooser interface via dbus-send) and answers with an error when neither is
there; the app then finishes the job in its own egui file browser (folder
field, Up/Home, folders and files of the chosen type, file name, type filter,
replace confirmation) with a hint on how to get the system dialog.
File > Open > Browse in WordCraft... (Linux/BSD) and `ui.fileBrowser` reach
it at any time, even while a system dialog never showed up;
WORDCRAFT_FILE_DIALOG=builtin forces it. Native only (not wasm32).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Turkish and Russian strings for the file browser (#576)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 02:19:04 -04:00
1 parent cbd9291909
commit d69c94efcf
20 files changed
+938 -39

No files matched your search

+2
View File
@@ -133,6 +133,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-11 | Hide White Space (#645): `view.hideWhiteSpace` (View › Show checkbox, or double-click the gap between pages) collapses margins, headers and footers between pages to a thin line in Print Layout; screen only, page count unchanged |
| 2026-10-11 | Fonts: Palatino Linotype and Book Antiqua fall back to Palatino (macOS) or TeX Gyre Pagella / P052 / URW Palladio (Linux) before generic serif (#670) |
|---|---|
| 2026-10-11 | Linux/BSD: Open, Save As, Export and Insert Picture work without a file chooser portal or zenity (labwc, sway…): WordCraft shows its own file browser and says how to get the system dialog; File › Open › Browse in WordCraft… is always there (#576) |
| 2026-10-11 | Every measurement follows File › Options › Advanced › Measurement units (#755): the Line Numbers "From text" distance and the Hyphenation zone, still fixed in inches after #485, now convert like every other dialog |
| 2026-10-11 | Review › Track Changes Options › Show revisions in balloons: tracked deletions in the body leave the text for "Deleted: …" balloons in the margin, joined to where they were (the option was saved but drew nothing new before) |
| 2026-10-11 | Security review fixes: `ui.render`/`ui.screenshot` without a `path` (and the MCP bridge's `render_page`/`screenshot`) return the PNG in the reply instead of writing a fixed name into the shared temporary folder; exported HTML/Markdown write `href` only for web, mail, bookmark and relative links, never `javascript:` and other schemes (one allow-list in `wordcraft_doc::link`) |
@@ -201,6 +202,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Progress log: built-in file browser when the system file dialog is unavailable (#576) |
| 2026-10-11 | trivial | Progress log: security review fixes (temp files, exported link schemes) |
| 2026-10-11 | trivial | Progress log: Find and Replace special characters and wildcards (#543) |
| 2026-10-11 | trivial | Progress log: Windows DX12 shaders compiled with FXC (#603) |
+71 -17
View File
@@ -6,19 +6,25 @@
//! is queued; the frame (which has the window) builds an `rfd::AsyncFileDialog` parented to the
//! window, and a background thread waits for the answer, hands it to the app and wakes the UI.
//!
//! On Linux and the BSDs rfd asks the xdg-desktop-portal FileChooser and, failing that, runs
//! `zenity`; with neither (a minimal Wayland compositor such as labwc with only
//! xdg-desktop-portal-wlr, #576) it answers "cancelled" and nothing ever shows. So the waiting
//! thread first checks that one of them is there and otherwise answers with an error, and the app
//! shows its built-in file browser. `WORDCRAFT_FILE_DIALOG=builtin` always uses the built-in one.
//!
//! [`Services::file_dialog`]: wordcraft_ui_egui::Services::file_dialog
use std::future::Future;
use std::pin::Pin;
use std::sync::mpsc::{Receiver, Sender, channel};
use wordcraft_ui_egui::FileDialogRequest;
use wordcraft_ui_egui::{FileDialogAnswer, FileDialogRequest};
/// A dialog the app asked for, with the channel its answer goes back through.
type Request = (FileDialogRequest, Sender<Option<String>>);
type Request = (FileDialogRequest, Sender<FileDialogAnswer>);
/// The `Services::file_dialog` hook.
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<Option<String>>>;
pub type Hook = Box<dyn Fn(FileDialogRequest) -> Receiver<FileDialogAnswer>>;
/// A picker's answer, waited for off the UI thread.
type Picking = Pin<Box<dyn Future<Output = Option<rfd::FileHandle>> + Send>>;
@@ -58,7 +64,11 @@ impl Launcher {
};
let ctx = self.ctx.clone();
let waiting = std::thread::Builder::new().name("file dialog".into()).spawn(move || {
let picked = pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string());
// The picker only starts when first polled: without a system dialog it never does.
let picked = match no_system_dialog() {
Some(reason) => Err(reason),
None => Ok(pollster::block_on(picking).map(|f| f.path().to_string_lossy().to_string())),
};
let _ = answer.send(picked);
ctx.request_repaint();
});
@@ -70,20 +80,64 @@ impl Launcher {
}
}
/// The file types Open offers: pictures for Insert/Change Picture, recipient lists for Select
/// Recipients › Use an Existing List…, documents otherwise.
fn open_filters(d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
if purpose == "picture" {
d.add_filter("Pictures", &["png", "jpg", "jpeg", "gif", "webp", "bmp"])
} else if purpose == "font" {
d.add_filter("Fonts", wordcraft_fonts::FONT_FILE_EXTS)
} else if purpose == "recipients" {
d.add_filter("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]).add_filter("All files", &["*"])
} else {
d.add_filter("Documents", &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"])
.add_filter("Word document", &["docx"])
.add_filter("All files", &["*"])
/// The file types Open offers (shared with the built-in browser).
fn open_filters(mut d: rfd::AsyncFileDialog, purpose: &str) -> rfd::AsyncFileDialog {
for (name, exts) in wordcraft_ui_egui::open_filters(purpose) {
d = d.add_filter(*name, exts);
}
d
}
/// Why no system file dialog can be shown, if it can't (#576); run off the UI thread.
fn no_system_dialog() -> Option<String> {
if std::env::var_os("WORDCRAFT_FILE_DIALOG").is_some_and(|v| v == "builtin") {
return Some("WORDCRAFT_FILE_DIALOG=builtin".into());
}
portal_or_zenity_missing()
}
/// Linux and the BSDs: rfd needs a file chooser portal or zenity. When `dbus-send` can't ask
/// the session bus, the answer is unknown and rfd gets to try.
#[cfg(all(unix, not(target_os = "macos")))]
fn portal_or_zenity_missing() -> Option<String> {
if on_path("zenity") {
return None;
}
let portal = std::process::Command::new("dbus-send")
.args([
"--session",
"--print-reply=literal",
"--reply-timeout=10000",
"--dest=org.freedesktop.portal.Desktop",
"/org/freedesktop/portal/desktop",
"org.freedesktop.DBus.Properties.Get",
"string:org.freedesktop.portal.FileChooser",
"string:version",
])
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
match portal {
Ok(status) if !status.success() => Some("no xdg-desktop-portal FileChooser on the session bus and no zenity".into()),
Ok(_) => None,
Err(e) => {
log::debug!("couldn't ask D-Bus for the file chooser portal: {e}");
None
}
}
}
/// Windows and macOS always have their system dialog.
#[cfg(not(all(unix, not(target_os = "macos"))))]
fn portal_or_zenity_missing() -> Option<String> {
None
}
/// Whether an executable named `program` is in a `PATH` folder.
#[cfg(all(unix, not(target_os = "macos")))]
fn on_path(program: &str) -> bool {
std::env::var_os("PATH").is_some_and(|paths| std::env::split_paths(&paths).any(|dir| dir.join(program).is_file()))
}
/// The file types Save As offers: every format the engine saves, with the suggested name's own
+12 -1
View File
@@ -51,7 +51,8 @@ pub fn show(app: &mut WordApp, ui: &mut Ui) {
);
resp
};
if back.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
// Escape in the built-in file browser closes only the browser.
if back.clicked() || (!app.file_dialog_open() && ui.input(|i| i.key_pressed(egui::Key::Escape))) {
app.ui.backstage = false;
app.canvas.want_focus = true;
}
@@ -262,6 +263,16 @@ fn open_page(app: &mut WordApp, ui: &mut Ui) {
if ui.button(egui::RichText::new(tl!("Browse…")).font(medium(14.0))).clicked() {
let _ = app.run("ui.openFileDialog", json!({}));
}
// Linux and the BSDs (#576): the system dialog needs a file chooser portal or zenity; when it
// doesn't show up, WordCraft's own browser is always one click away.
#[cfg(all(unix, not(target_os = "macos"), not(target_arch = "wasm32")))]
if ui
.button(egui::RichText::new(tl!("Browse in WordCraft…")).font(medium(14.0)))
.on_hover_text(tl!("Open a file with WordCraft's own browser when the system file dialog doesn't appear."))
.clicked()
{
let _ = app.run("ui.fileBrowser", json!({}));
}
ui.add_space(18.0);
open_list(app, ui);
}
+559
View File
@@ -0,0 +1,559 @@
//! The built-in file browser (#576): Open, Save As, Export, Insert Picture and recipient lists
//! when the system's file dialog can't be shown — a Linux desktop with neither a file chooser
//! portal nor zenity (labwc, sway… with only xdg-desktop-portal-wlr) — or when the user asks for
//! it (File › Open › Browse in WordCraft…).
//!
//! A folder path field with Up and Home folder, the folder's subfolders and the files of the
//! chosen type, a file name field and the file types. It only lists folders the user navigates to
//! and never reads a file: the picked path goes to the same follow-up as the system dialog's.
use std::path::{Path, PathBuf};
use egui::{Align2, Rect, Sense, Ui, pos2, vec2};
use crate::file_dialogs::{AfterPick, FileDialogRequest, open_filters};
use crate::theme::{APP_COLOR, Tokens, regular};
use crate::{WordApp, icons};
/// At most this many entries are listed; a bigger folder shows the first ones and says so.
const MAX_LISTED: usize = 5000;
/// At most this many folder entries are read, so a huge folder can't stall the window.
const MAX_SCANNED: usize = 100_000;
const ROW: f32 = 24.0;
/// The browser while it is open, and the folder it showed last (where it opens next time).
#[derive(Default)]
pub(crate) struct State {
pub(crate) open: Option<Box<FileBrowser>>,
last_dir: Option<PathBuf>,
}
/// One subfolder or file in the listing.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Entry {
/// The name as shown (a name that isn't valid UTF-8 is shown with replacement characters).
pub(crate) name: String,
pub(crate) path: PathBuf,
pub(crate) dir: bool,
}
/// A folder's subfolders (first) and files, by name.
#[derive(Debug, Default)]
pub(crate) struct Listing {
pub(crate) entries: Vec<Entry>,
/// The folder holds more than was listed.
pub(crate) truncated: bool,
}
/// Whether `path` has one of `exts` (case-insensitive); `*` takes any file.
pub(crate) fn matches(path: &Path, exts: &[String]) -> bool {
exts.iter().any(|e| e == "*") || path.extension().and_then(|e| e.to_str()).is_some_and(|e| exts.iter().any(|x| x.eq_ignore_ascii_case(e)))
}
/// The subfolders and the files with one of `exts` in `dir`, at most `cap` of them, folders first
/// and each by name. Hidden entries (a leading dot) are left out; entries that can't be read are
/// skipped.
pub(crate) fn list_dir(dir: &Path, exts: &[String], cap: usize) -> std::io::Result<Listing> {
let mut listing = Listing::default();
for (i, item) in std::fs::read_dir(dir)?.enumerate() {
if i >= MAX_SCANNED {
listing.truncated = true;
break;
}
let Ok(item) = item else { continue };
let name = item.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let path = item.path();
// A symbolic link to a folder is a folder.
let is_dir = match item.file_type() {
Ok(t) if t.is_symlink() => std::fs::metadata(&path).is_ok_and(|m| m.is_dir()),
Ok(t) => t.is_dir(),
Err(_) => continue,
};
if !is_dir && !matches(&path, exts) {
continue;
}
if listing.entries.len() >= cap {
listing.truncated = true;
break;
}
listing.entries.push(Entry { name, path, dir: is_dir });
}
listing.entries.sort_by_cached_key(|e| (!e.dir, e.name.to_lowercase()));
Ok(listing)
}
/// The user's home folder.
pub(crate) fn home() -> Option<PathBuf> {
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).filter(|h| !h.is_empty()).map(PathBuf::from)
}
/// What the user typed in the file name field, as a path: relative to `dir`, absolute, or under
/// the home folder (`~/…`). `None` when nothing was typed.
pub(crate) fn resolve(dir: &Path, typed: &str) -> Option<PathBuf> {
let typed = typed.trim();
if typed.is_empty() {
return None;
}
if typed == "~" {
return home();
}
if let Some(rest) = typed.strip_prefix("~/") {
return home().map(|h| h.join(rest));
}
Some(dir.join(typed))
}
/// What OK (or Enter, or a double click) did.
#[derive(Debug, PartialEq)]
pub(crate) enum Step {
/// The browser stays open (it moved to a folder, or has something to say).
Stay,
/// The file to open or save to.
Pick(PathBuf),
}
/// A message under the listing.
#[derive(Clone, Debug, PartialEq)]
enum Note {
None,
NotFound(String),
Replace(PathBuf),
}
/// The open browser.
pub(crate) struct FileBrowser {
req: FileDialogRequest,
pub(crate) after: AfterPick,
/// Opened because the system dialog couldn't be shown: say why, and how to get it.
unavailable: bool,
dir: PathBuf,
/// The folder field (edited by the user, applied on Enter).
location: String,
/// The file name field.
pub(crate) name: String,
/// The file types, as (label, extensions).
filters: Vec<(String, Vec<String>)>,
filter: usize,
listing: Result<Listing, String>,
/// The highlighted entry.
selected: Option<PathBuf>,
note: Note,
focus_name: bool,
}
impl FileBrowser {
pub(crate) fn new(req: FileDialogRequest, after: AfterPick, start: PathBuf, unavailable: bool) -> Self {
let (name, filters) = match &req {
FileDialogRequest::Open { purpose } => {
(String::new(), open_filters(purpose).iter().map(|(l, e)| ((*l).to_string(), e.iter().map(|x| (*x).to_string()).collect())).collect())
}
FileDialogRequest::Save { name } => {
let ext = Path::new(name).extension().and_then(|e| e.to_str()).filter(|e| !e.is_empty());
let mut f: Vec<(String, Vec<String>)> = ext.map(|e| (format!("*.{e}"), vec![e.to_string()])).into_iter().collect();
f.push(("All files".into(), vec!["*".into()]));
(name.clone(), f)
}
};
let mut b = FileBrowser {
req,
after,
unavailable,
dir: start.clone(),
location: String::new(),
name,
filters,
filter: 0,
listing: Ok(Listing::default()),
selected: None,
note: Note::None,
focus_name: true,
};
b.navigate(start);
b
}
fn exts(&self) -> &[String] {
self.filters.get(self.filter).map_or(&[], |(_, e)| e.as_slice())
}
/// Show the folder `dir` (an unreadable folder shows why, and Up still works).
pub(crate) fn navigate(&mut self, dir: PathBuf) {
self.listing = list_dir(&dir, self.exts(), MAX_LISTED).map_err(|e| e.to_string());
self.location = dir.to_string_lossy().into_owned();
self.dir = dir;
self.selected = None;
self.note = Note::None;
}
fn up(&mut self) {
if let Some(parent) = self.dir.parent().map(Path::to_path_buf) {
self.navigate(parent);
}
}
/// OK: a highlighted folder, or a typed folder, is opened; otherwise the typed name is the
/// pick. Save adds the chosen type's extension to a name without one and asks once more
/// before replacing a file; Open wants a file that exists.
pub(crate) fn accept(&mut self) -> Step {
if let Some(dir) = self.selected.take().filter(|p| p.is_dir()) {
self.navigate(dir);
return Step::Stay;
}
let Some(mut path) = resolve(&self.dir, &self.name) else { return Step::Stay };
if path.is_dir() {
self.name.clear();
self.navigate(path);
return Step::Stay;
}
match self.req {
FileDialogRequest::Open { .. } => {
if path.is_file() {
return Step::Pick(path);
}
self.note = Note::NotFound(self.name.trim().to_string());
Step::Stay
}
FileDialogRequest::Save { .. } => {
if path.extension().is_none()
&& let Some(ext) = self.exts().first().filter(|e| e.as_str() != "*")
{
path.set_extension(ext);
}
if path.exists() && self.note != Note::Replace(path.clone()) {
self.note = Note::Replace(path);
return Step::Stay;
}
Step::Pick(path)
}
}
}
/// A click on an entry: highlight it, and a file's name goes into the file name field.
fn select(&mut self, e: &Entry) {
self.selected = Some(e.path.clone());
if !e.dir {
self.name = e.name.clone();
}
self.note = Note::None;
}
fn title(&self) -> &'static str {
match self.req {
FileDialogRequest::Open { .. } => "Open",
FileDialogRequest::Save { .. } => "Save As",
}
}
fn ok_label(&self) -> &'static str {
match self.req {
FileDialogRequest::Open { .. } => "Open",
FileDialogRequest::Save { .. } => "Save",
}
}
}
impl WordApp {
/// Ask for a file in the built-in browser and do `after` with the answer when it closes.
pub(crate) fn show_file_browser(&mut self, req: FileDialogRequest, after: AfterPick, unavailable: bool) {
let start = self
.file_browser
.last_dir
.clone()
.filter(|d| d.is_dir())
.or_else(|| self.session.path.as_ref().and_then(|p| p.parent()).map(Path::to_path_buf).filter(|d| d.is_dir()))
.or_else(home)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("/"));
self.file_browser.open = Some(Box::new(FileBrowser::new(req, after, start, unavailable)));
}
/// `ui.fileBrowser`: the built-in browser for `purpose` (document, picture, recipients, save),
/// even when a system dialog was asked for and never showed up (it is given up as cancelled).
pub(crate) fn browse_builtin(&mut self, purpose: &str) {
self.give_up_file_dialog();
if self.file_browser.open.is_some() {
return;
}
let (req, after) = match purpose {
"picture" => (FileDialogRequest::Open { purpose: "picture".into() }, AfterPick::Picture),
"recipients" => (FileDialogRequest::Open { purpose: "recipients".into() }, AfterPick::Recipients),
"save" => (FileDialogRequest::Save { name: self.default_save_name() }, AfterPick::SaveAs(crate::file_dialogs::AfterSave::Nothing)),
_ => (FileDialogRequest::Open { purpose: "document".into() }, AfterPick::OpenDocument),
};
self.show_file_browser(req, after, false);
}
/// Close the browser with this answer (`None`: cancelled) and finish what it was for.
pub(crate) fn close_file_browser(&mut self, picked: Option<PathBuf>) {
let Some(b) = self.file_browser.open.take() else { return };
self.file_browser.last_dir = Some(b.dir.clone());
let picked = picked.map(|p| p.to_string_lossy().into_owned());
if let Err(e) = self.finish_pick(b.after, picked) {
log::warn!("file browser: {e}");
}
self.canvas.want_focus = true;
}
}
/// Draw the browser, if it is open.
pub(crate) fn show(app: &mut WordApp, ctx: &egui::Context) {
let Some(mut b) = app.file_browser.open.take() else { return };
let t = Tokens::get(ctx);
let mut open = true;
let mut done: Option<Option<PathBuf>> = None;
egui::Window::new(tl!(b.title()))
.id(egui::Id::new("builtin_file_browser"))
.collapsible(false)
.resizable(false)
.anchor(Align2::CENTER_CENTER, vec2(0.0, -20.0))
.open(&mut open)
.show(ctx, |ui| {
ui.set_width(620.0);
done = body(ui, &mut b, &t);
});
if !open {
done = Some(None);
}
app.file_browser.open = Some(b);
if let Some(picked) = done {
app.close_file_browser(picked);
}
}
/// The browser's contents; `Some` once it closes (`Some(None)`: cancelled).
fn body(ui: &mut Ui, b: &mut FileBrowser, t: &Tokens) -> Option<Option<PathBuf>> {
if b.unavailable {
ui.label(
egui::RichText::new(tl!(
"The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity."
))
.color(t.text_dim),
);
ui.add_space(6.0);
}
let mut location_done = false;
ui.horizontal(|ui| {
if icon_button(ui, t, "chevronUp", tl!("Up one level"), b.dir.parent().is_some()) {
b.up();
}
if icon_button(ui, t, "homeFolder", tl!("Home folder"), true)
&& let Some(h) = home()
{
b.navigate(h);
}
let r = ui.add(egui::TextEdit::singleline(&mut b.location).desired_width(f32::INFINITY));
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
location_done = true;
let typed = b.location.clone();
if let Some(dir) = resolve(&b.dir, &typed) {
b.navigate(dir);
}
}
});
ui.add_space(4.0);
let mut pick = false;
let mut go: Option<PathBuf> = None;
let mut clicked: Option<Entry> = None;
egui::Frame::NONE.fill(t.input).stroke(egui::Stroke::new(1.0, t.input_border)).inner_margin(2.0).show(ui, |ui| {
let entries: &[Entry] = b.listing.as_ref().map(|l| l.entries.as_slice()).unwrap_or(&[]);
egui::ScrollArea::vertical().max_height(300.0).min_scrolled_height(300.0).auto_shrink([false, false]).show_rows(
ui,
ROW,
entries.len(),
|ui, range| {
for e in entries.get(range).unwrap_or(&[]) {
let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW), Sense::click());
if b.selected.as_ref() == Some(&e.path) {
ui.painter().rect_filled(r, 2.0, t.checked);
} else if resp.hovered() {
ui.painter().rect_filled(r, 2.0, t.hover);
}
let icon = Rect::from_center_size(pos2(r.min.x + 14.0, r.center().y), vec2(18.0, 18.0));
icons::paint(ui.painter(), icon, if e.dir { "folder" } else { "file" }, t.icon, t.accent);
ui.painter().with_clip_rect(r).text(pos2(r.min.x + 30.0, r.center().y), Align2::LEFT_CENTER, &e.name, regular(13.0), t.text);
if resp.double_clicked() {
if e.dir {
go = Some(e.path.clone());
} else {
clicked = Some(e.clone());
pick = true;
}
} else if resp.clicked() {
clicked = Some(e.clone());
}
}
},
);
});
if let Some(e) = clicked {
b.select(&e);
}
if let Some(dir) = go {
b.navigate(dir);
}
let message = match (&b.listing, &b.note) {
(_, Note::NotFound(name)) => Some((crate::i18n::fmt(tl!("{name} wasn't found."), &[("name", name)]), t.red)),
(_, Note::Replace(p)) => {
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
Some((crate::i18n::fmt(tl!("{name} already exists. Choose Save again to replace it."), &[("name", &name)]), t.red))
}
(Err(e), _) => Some((crate::i18n::fmt(tl!("This folder can't be read: {error}"), &[("error", e)]), t.red)),
(Ok(l), _) if l.truncated => {
Some((crate::i18n::fmt(tl!("Showing the first {count} items."), &[("count", &l.entries.len().to_string())]), t.text_dim))
}
_ => None,
};
if let Some((text, color)) = message {
ui.label(egui::RichText::new(text).color(color));
}
ui.add_space(6.0);
let mut refilter = false;
ui.horizontal(|ui| {
let label = ui.label(tl!("File name:"));
// The name field takes what the label (longer in some languages) and the type list leave.
let width = (ui.available_width() - 170.0).max(120.0);
let r = ui.add(egui::TextEdit::singleline(&mut b.name).desired_width(width)).labelled_by(label.id);
if b.focus_name {
r.request_focus();
b.focus_name = false;
}
if r.changed() {
b.selected = None;
b.note = Note::None;
}
let before = b.filter;
let current = b.filters.get(b.filter).map(|(l, _)| tl!(l.as_str()).to_string()).unwrap_or_default();
egui::ComboBox::from_id_salt("builtin_file_browser_type").selected_text(current).width(150.0).show_ui(ui, |ui| {
for (i, (l, _)) in b.filters.iter().enumerate() {
ui.selectable_value(&mut b.filter, i, tl!(l.as_str()));
}
});
refilter = b.filter != before;
});
if refilter {
b.navigate(b.dir.clone());
}
ui.add_space(8.0);
let mut cancel = ui.input(|i| i.key_pressed(egui::Key::Escape));
let enter = !location_done && ui.input(|i| i.key_pressed(egui::Key::Enter));
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
cancel |= ui.button(tl!("Cancel")).clicked();
let ok = ui.add(egui::Button::new(egui::RichText::new(tl!(b.ok_label())).color(egui::Color32::WHITE)).fill(APP_COLOR));
pick |= ok.clicked() || enter;
});
});
if cancel {
return Some(None);
}
if pick && let Step::Pick(path) = b.accept() {
return Some(Some(path));
}
None
}
/// A small button with a drawn icon and a tooltip; true when clicked.
fn icon_button(ui: &mut Ui, t: &Tokens, icon: &str, tip: &str, enabled: bool) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(28.0, 24.0), if enabled { Sense::click() } else { Sense::hover() });
if enabled && resp.hovered() {
ui.painter().rect_filled(r, 3.0, t.hover);
}
let c = if enabled { t.icon } else { t.text_disabled };
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(18.0, 18.0)), icon, c, if enabled { t.accent } else { c });
resp.on_hover_text(tip).clicked()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Services;
use serde_json::json;
use std::sync::mpsc::channel;
use wordcraft_engine::Session;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("wordcraft-ui-browser-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn exts(e: &[&str]) -> Vec<String> {
e.iter().map(|x| x.to_string()).collect()
}
/// Folders come first, then the files of the chosen type (any case), each by name; hidden
/// entries and other types are left out, and a big folder is cut off and says so.
#[test]
fn listing_filters_sorts_and_caps() {
let dir = scratch("list");
for f in ["b.docx", "A.DOCX", "notes.txt", ".hidden.docx", "c.docx"] {
std::fs::write(dir.join(f), b"x").unwrap();
}
std::fs::create_dir(dir.join("zeta")).unwrap();
std::fs::create_dir(dir.join(".git")).unwrap();
let l = list_dir(&dir, &exts(&["docx"]), 100).unwrap();
let names: Vec<&str> = l.entries.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["zeta", "A.DOCX", "b.docx", "c.docx"]);
assert!(!l.truncated);
assert_eq!(list_dir(&dir, &exts(&["*"]), 100).unwrap().entries.len(), 5);
let capped = list_dir(&dir, &exts(&["docx"]), 2).unwrap();
assert_eq!(capped.entries.len(), 2);
assert!(capped.truncated);
assert!(list_dir(&dir.join("missing"), &exts(&["*"]), 10).is_err(), "an unreadable folder is an error, not a panic");
let _ = std::fs::remove_dir_all(&dir);
}
/// Save As in the browser: a typed folder is opened, a bare name gets the type's extension,
/// and an existing file is replaced only on the second OK.
#[test]
fn save_adds_the_extension_and_asks_before_replacing() {
let dir = scratch("save");
std::fs::create_dir(dir.join("drafts")).unwrap();
std::fs::write(dir.join("drafts").join("old.docx"), b"x").unwrap();
let req = FileDialogRequest::Save { name: "Document1.docx".into() };
let mut b = FileBrowser::new(req, AfterPick::OpenDocument, dir.clone(), false);
b.name = "drafts".into();
assert_eq!(b.accept(), Step::Stay);
assert_eq!(b.dir, dir.join("drafts"));
b.name = "chapter".into();
assert_eq!(b.accept(), Step::Pick(dir.join("drafts").join("chapter.docx")));
b.name = "old".into();
assert_eq!(b.accept(), Step::Stay, "asks before replacing");
assert_eq!(b.accept(), Step::Pick(dir.join("drafts").join("old.docx")));
let _ = std::fs::remove_dir_all(&dir);
}
/// The host can't show a system dialog (#576): Save As carries on in the built-in browser,
/// and its answer saves the document.
#[test]
fn no_system_dialog_falls_back_to_the_built_in_browser() {
let dir = scratch("fallback");
let services = Services {
file_dialog: Some(Box::new(|_req| {
let (tx, rx) = channel();
tx.send(Err("no portal".to_string())).unwrap();
rx
})),
..Default::default()
};
let mut a = WordApp::new(Session::new(wordcraft_doc::Document::new()), services);
a.run("text.insert", json!({"text": "Saved without a portal"})).unwrap();
a.save_as_dialog();
assert!(a.file_dialog_open());
let b = a.file_browser.open.as_mut().expect("the built-in browser is open");
assert!(b.unavailable);
b.navigate(dir.clone());
b.name = "novel".into();
let Step::Pick(path) = b.accept() else { panic!("picked") };
a.close_file_browser(Some(path));
assert!(!a.file_dialog_open());
assert!(!a.session.dirty);
let saved = wordcraft_engine::io::open_path(&dir.join("novel.docx")).unwrap();
assert!(saved.plain_text(wordcraft_doc::StoryRef::Body).contains("Saved without a portal"));
let _ = std::fs::remove_dir_all(&dir);
}
}
+75 -13
View File
@@ -6,6 +6,10 @@
//! pending New/Open/Close, export, insert the picture). A host with only the blocking
//! `pick_open` / `pick_save` gets the same follow-up straight away. One dialog at a time: asking
//! for another while one is open does nothing.
//!
//! When the host can't show a system dialog at all (a Linux desktop with neither a file chooser
//! portal nor zenity, #576) it answers with an error, and the app finishes the job in its own
//! [`file_browser`](crate::file_browser) instead.
use std::sync::mpsc::{Receiver, TryRecvError};
@@ -23,6 +27,25 @@ pub enum FileDialogRequest {
Save { name: String },
}
/// The host's answer to a [`FileDialogRequest`]: the picked path, `Ok(None)` when the user
/// cancelled, or an error when no system dialog could be shown (the app then shows its own).
pub type FileDialogAnswer = Result<Option<String>, String>;
const PICTURES: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "bmp"];
const DOCUMENTS: &[&str] = &["docx", "docm", "dotx", "dotm", "doc", "dot", "odt", "rtf", "txt", "md", "html", "htm", "tex", "json", "xml"];
/// The file types Open offers, as (label, extensions; `*` is any file): pictures for Insert/Change
/// Picture, recipient lists for Select Recipients › Use an Existing List…, documents otherwise.
/// The host's system dialog and the built-in browser offer the same.
pub fn open_filters(purpose: &str) -> &'static [(&'static str, &'static [&'static str])] {
match purpose {
"picture" => &[("Pictures", PICTURES)],
"font" => &[("Fonts", wordcraft_fonts::FONT_FILE_EXTS)],
"recipients" => &[("Recipient lists", &["csv", "tsv", "txt", "xlsx", "xlsm", "ods"]), ("All files", &["*"])],
_ => &[("Documents", DOCUMENTS), ("Word document", &["docx"]), ("All files", &["*"])],
}
}
/// What to do with the path picked in a dialog.
pub(crate) enum AfterPick {
/// Open the picked document.
@@ -62,7 +85,8 @@ pub(crate) enum AfterSave {
/// A dialog the host is showing; the answer arrives on `rx` (`None`: cancelled).
pub(crate) struct PendingDialog {
rx: Receiver<Option<String>>,
rx: Receiver<FileDialogAnswer>,
req: FileDialogRequest,
after: AfterPick,
}
@@ -77,22 +101,32 @@ pub(crate) enum Asked {
}
impl WordApp {
/// Whether a file dialog is open (its answer hasn't arrived yet).
/// Whether a file dialog is open (its answer hasn't arrived yet), the host's or the built-in one.
pub fn file_dialog_open(&self) -> bool {
self.file_dialog.is_some()
self.file_dialog.is_some() || self.file_browser_open()
}
#[cfg(not(target_arch = "wasm32"))]
fn file_browser_open(&self) -> bool {
self.file_browser.open.is_some()
}
#[cfg(target_arch = "wasm32")]
fn file_browser_open(&self) -> bool {
false
}
/// Ask the host for a file and do `after` with the answer: now for a blocking dialog, on a
/// later frame for an asynchronous one. Ignored while another dialog is open.
pub(crate) fn ask_file(&mut self, req: FileDialogRequest, after: AfterPick) -> Asked {
if self.file_dialog.is_some() {
if self.file_dialog_open() {
log::debug!("file dialog already open; ignoring {req:?}");
self.status(tl!("A file dialog is already open."));
return Asked::Busy;
}
if let Some(show) = &self.services.file_dialog {
let rx = show(req);
self.file_dialog = Some(PendingDialog { rx, after });
let rx = show(req.clone());
self.file_dialog = Some(PendingDialog { rx, req, after });
// A host that answered at once (or failed to show anything) is handled right away.
return self.poll_file_dialog().map_or(Asked::Pending, Asked::Done);
}
@@ -106,18 +140,46 @@ impl WordApp {
/// Pick up the answer of an open file dialog, if it has arrived (every frame, from `logic`),
/// and finish what it was for. `None` while there is nothing to finish.
pub(crate) fn poll_file_dialog(&mut self) -> Option<Result<Value, String>> {
let picked = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) {
let answer = match self.file_dialog.as_ref().map(|d| d.rx.try_recv()) {
None | Some(Err(TryRecvError::Empty)) => return None,
Some(Ok(picked)) => picked,
Some(Ok(answer)) => answer,
// The host dropped the dialog without an answer: as good as cancelled.
Some(Err(TryRecvError::Disconnected)) => None,
Some(Err(TryRecvError::Disconnected)) => Ok(None),
};
let d = self.file_dialog.take()?;
Some(self.finish_pick(d.after, picked))
match answer {
Ok(picked) => Some(self.finish_pick(d.after, picked)),
Err(reason) => {
log::warn!("no system file dialog ({reason}); showing the built-in file browser");
self.dialog_unavailable(d.req, d.after)
}
}
}
/// Treat the system dialog the host is showing as cancelled, e.g. one that never showed up.
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn give_up_file_dialog(&mut self) {
if let Some(d) = self.file_dialog.take() {
let _ = self.finish_pick(d.after, None);
}
}
/// The host couldn't show a system dialog (#576): ask in the built-in browser instead, which
/// finishes the job later. `None` while it is open.
#[cfg(not(target_arch = "wasm32"))]
fn dialog_unavailable(&mut self, req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
self.show_file_browser(req, after, true);
None
}
/// The browser has no built-in file browser (it can't list the user's folders): cancelled.
#[cfg(target_arch = "wasm32")]
fn dialog_unavailable(&mut self, _req: FileDialogRequest, after: AfterPick) -> Option<Result<Value, String>> {
Some(self.finish_pick(after, None))
}
/// Do what the dialog was for with the picked path (`None`: the user cancelled).
fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
pub(crate) fn finish_pick(&mut self, after: AfterPick, picked: Option<String>) -> Result<Value, String> {
match after {
AfterPick::OpenDocument => {
let Some(path) = picked else { return Ok(json!({"cancelled": true})) };
@@ -256,7 +318,7 @@ mod tests {
const UNSAVED: &str = "My unsaved novel chapter";
/// Dialogs the fake host was asked to show, each with the channel its answer goes through.
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<Option<String>>)>>>;
type Shown = Rc<RefCell<Vec<(FileDialogRequest, Sender<FileDialogAnswer>)>>>;
/// An app whose host shows dialogs asynchronously: nothing is answered until the test does.
fn async_app() -> (WordApp, Shown) {
@@ -276,7 +338,7 @@ mod tests {
/// Answer the dialog the host is showing (the last one asked for).
fn answer(shown: &Shown, picked: Option<&str>) {
let (_, tx) = shown.borrow_mut().pop().unwrap();
tx.send(picked.map(str::to_string)).unwrap();
tx.send(Ok(picked.map(str::to_string))).unwrap();
}
fn typed(a: &mut WordApp) {
+16
View File
@@ -1705,6 +1705,22 @@
Continuous numbering Numeración continua
Apply Aplicar
Convert Notes Convertir notas
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Documentos
Word document Documento de Word
All files Todos los archivos
Recipient lists Listas de destinatarios
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. El cuadro de diálogo de archivos del sistema no está disponible, así que WordCraft muestra el suyo. Para usar el del sistema, instala un portal selector de archivos (como xdg-desktop-portal-gtk) o zenity.
Up one level Subir un nivel
Home folder Carpeta personal
{name} wasn't found. No se encontró {name}.
{name} already exists. Choose Save again to replace it. {name} ya existe. Elige Guardar otra vez para reemplazarlo.
This folder can't be read: {error} No se puede leer esta carpeta: {error}
Showing the first {count} items. Se muestran los primeros {count} elementos.
File name: Nombre de archivo:
Browse in WordCraft… Examinar en WordCraft…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Abre un archivo con el explorador propio de WordCraft cuando no aparece el cuadro de diálogo de archivos del sistema.
Hide White Space Ocultar espacio en blanco
All pages Todas las páginas
Current page Página actual
Can't render this file because it contains an unexpected character in line 6 and column 25.
+16
View File
@@ -1666,6 +1666,22 @@
Continuous numbering Pidev nummerdus
Apply Rakenda
Convert Notes Teisenda märkused
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Dokumendid
Word document Wordi dokument
All files Kõik failid
Recipient lists Adressaatide loendid
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Süsteemi failidialoog pole saadaval, seega näitab WordCraft enda oma. Süsteemi dialoogi saamiseks paigalda failivalija portaal (näiteks xdg-desktop-portal-gtk) või zenity.
Up one level Taseme võrra üles
Home folder Kodukaust
{name} wasn't found. {name} ei leitud.
{name} already exists. Choose Save again to replace it. {name} on juba olemas. Selle asendamiseks vali uuesti Salvesta.
This folder can't be read: {error} Seda kausta ei saa lugeda: {error}
Showing the first {count} items. Kuvatud on ainult esimesed kirjed: {count}.
File name: Faili nimi:
Browse in WordCraft… Sirvi WordCraftis…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Ava fail WordCrafti enda failisirvijaga, kui süsteemi failidialoog ei ilmu.
Hide White Space Peida tühi ala lehtede vahel
All pages Kõik leheküljed
Current page Praegune lehekülg
Can't render this file because it contains an unexpected character in line 445 and column 13.
+16
View File
@@ -1689,6 +1689,22 @@
Continuous numbering 通し番号
Apply 適用
Convert Notes 注の変換
# Built-in file browser when the system file dialog is unavailable (#576)
Documents ドキュメント
Word document Word 文書
All files すべてのファイル
Recipient lists 宛先リスト
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. システムのファイル ダイアログを利用できないため、WordCraft 独自のダイアログを表示しています。システムのダイアログを使うには、ファイル選択ポータル (xdg-desktop-portal-gtk など) または zenity をインストールしてください。
Up one level 1 つ上のフォルダー
Home folder ホーム フォルダー
{name} wasn't found. {name} が見つかりません。
{name} already exists. Choose Save again to replace it. {name} は既に存在します。置き換えるには、もう一度 [保存] を選んでください。
This folder can't be read: {error} このフォルダーを読み取れません: {error}
Showing the first {count} items. 最初の {count} 項目を表示しています。
File name: ファイル名:
Browse in WordCraft… WordCraft で参照…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. システムのファイル ダイアログが表示されないときに、WordCraft 独自のブラウザーでファイルを開きます。
Hide White Space 余白を非表示
All pages すべてのページ
Current page 現在のページ
Can't render this file because it contains an unexpected character in line 553 and column 13.
+16
View File
@@ -1701,6 +1701,22 @@
Continuous numbering Numeração contínua
Apply Aplicar
Convert Notes Converter notas
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Documentos
Word document Documento do Word
All files Todos os arquivos
Recipient lists Listas de destinatários
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. A caixa de diálogo de arquivos do sistema não está disponível, então o WordCraft mostra a sua própria. Para usar a do sistema, instale um portal seletor de arquivos (como xdg-desktop-portal-gtk) ou o zenity.
Up one level Subir um nível
Home folder Pasta pessoal
{name} wasn't found. {name} não foi encontrado.
{name} already exists. Choose Save again to replace it. {name} já existe. Escolha Salvar novamente para substituí-lo.
This folder can't be read: {error} Não é possível ler esta pasta: {error}
Showing the first {count} items. Mostrando os primeiros {count} itens.
File name: Nome do arquivo:
Browse in WordCraft… Procurar no WordCraft…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Abra um arquivo com o navegador próprio do WordCraft quando a caixa de diálogo de arquivos do sistema não aparecer.
Hide White Space Ocultar espaço em branco
All pages Todas as páginas
Current page Página atual
Can't render this file because it contains an unexpected character in line 6 and column 25.
+16 -1
View File
@@ -2134,6 +2134,22 @@ preview Title Пример
Continuous numbering Сквозная нумерация
Apply Применить
Convert Notes Преобразовать сноски
# Built-in file browser (#576).
Documents Документы
Word document Документ Word
All files Все файлы
Recipient lists Списки получателей
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системное диалоговое окно выбора файлов недоступно, поэтому WordCraft показывает собственное. Чтобы пользоваться системным, установите портал выбора файлов (например, xdg-desktop-portal-gtk) или zenity.
Up one level На уровень вверх
Home folder Домашняя папка
{name} wasn't found. {name} не найден.
{name} already exists. Choose Save again to replace it. {name} уже существует. Чтобы заменить его, снова выберите «Сохранить».
This folder can't be read: {error} Не удаётся прочитать папку: {error}
Showing the first {count} items. Показаны первые элементы: {count}.
File name: Имя файла:
Browse in WordCraft… Обзор в WordCraft…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Открыть файл во встроенном обозревателе WordCraft, если системное диалоговое окно выбора файлов не появляется.
Hide White Space Скрыть пустое место между страницами
All pages Все страницы
Current page Текущая страница
@@ -2150,7 +2166,6 @@ preview Title Пример
The printer, two-sided printing and pages per sheet are chosen in that dialog. Принтер, двусторонняя печать и число страниц на листе выбираются в этом диалоге.
Previous Page Предыдущая страница
Print to PDF Печать в PDF
# Word XML Document (#548).
Word XML Document (*.xml) Документ Word XML (*.xml)
# Find and Replace special characters and wildcards (#543).
Can't render this file because it contains an unexpected character in line 7 and column 25.
+16
View File
@@ -1699,6 +1699,22 @@
Continuous numbering Neprekidna numeracija
Apply Primeni
Convert Notes Pretvori napomene
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Dokumenti
Word document Word dokument
All files Sve datoteke
Recipient lists Liste primalaca
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Sistemski dijalog za datoteke nije dostupan, pa WordCraft prikazuje svoj. Da biste dobili sistemski dijalog, instalirajte portal za izbor datoteka (na primer xdg-desktop-portal-gtk) ili zenity.
Up one level Jedan nivo gore
Home folder Lična fascikla
{name} wasn't found. {name} nije pronađen.
{name} already exists. Choose Save again to replace it. {name} već postoji. Izaberite Sačuvaj ponovo da biste zamenili datoteku.
This folder can't be read: {error} Ova fascikla ne može da se pročita: {error}
Showing the first {count} items. Prikazane su samo prve stavke: {count}.
File name: Ime datoteke:
Browse in WordCraft… Potraži u WordCraft-u…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Otvorite datoteku pomoću WordCraft-ovog pregledača kada se sistemski dijalog za datoteke ne pojavi.
Hide White Space Sakrij prazan prostor
All pages Sve stranice
Current page Trenutna stranica
Can't render this file because it contains an unexpected character in line 6 and column 25.
+16
View File
@@ -1703,6 +1703,22 @@
Continuous numbering Непрекидна нумерација
Apply Примени
Convert Notes Претвори напомене
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Документи
Word document Word документ
All files Све датотеке
Recipient lists Листе прималаца
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системски дијалог за датотеке није доступан, па WordCraft приказује свој. Да бисте добили системски дијалог, инсталирајте портал за избор датотека (на пример xdg-desktop-portal-gtk) или zenity.
Up one level Један ниво горе
Home folder Лична фасцикла
{name} wasn't found. {name} није пронађен.
{name} already exists. Choose Save again to replace it. {name} већ постоји. Изаберите Сачувај поново да бисте заменили датотеку.
This folder can't be read: {error} Ова фасцикла не може да се прочита: {error}
Showing the first {count} items. Приказане су само прве ставке: {count}.
File name: Име датотеке:
Browse in WordCraft… Потражи у WordCraft-у…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Отворите датотеку помоћу WordCraft-овог прегледача када се системски дијалог за датотеке не појави.
Hide White Space Сакриј празан простор
All pages Све странице
Current page Тренутна страница
Can't render this file because it contains an unexpected character in line 11 and column 25.
+16 -1
View File
@@ -1756,6 +1756,22 @@
Continuous numbering Sürekli numaralandırma
Apply Uygula
Convert Notes Notları Dönüştür
# Built-in file browser (#576).
Documents Belgeler
Word document Word belgesi
All files Tüm dosyalar
Recipient lists Alıcı listeleri
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Sistemin dosya iletişim kutusu kullanılamadığı için WordCraft kendi iletişim kutusunu gösteriyor. Sistem iletişim kutusunu kullanmak için bir dosya seçici portalı (örneğin xdg-desktop-portal-gtk) ya da zenity yükleyin.
Up one level Bir düzey yukarı
Home folder Ana klasör
{name} wasn't found. {name} bulunamadı.
{name} already exists. Choose Save again to replace it. {name} zaten var. Değiştirmek için Kaydet'i yeniden seçin.
This folder can't be read: {error} Bu klasör okunamıyor: {error}
Showing the first {count} items. İlk {count} öğe gösteriliyor.
File name: Dosya adı:
Browse in WordCraft… WordCraft'ta Gözat…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Sistemin dosya iletişim kutusu açılmadığında dosyayı WordCraft'ın kendi dosya tarayıcısıyla açın.
Hide White Space Boşluğu gizle
All pages Tüm sayfalar
Current page Geçerli sayfa
@@ -1772,7 +1788,6 @@
The printer, two-sided printing and pages per sheet are chosen in that dialog. Yazıcı, çift taraflı yazdırma ve yaprak başına sayfa sayısı o iletişim kutusunda seçilir.
Previous Page Önceki sayfa
Print to PDF PDF'ye yazdır
# Word XML Document (#548).
Word XML Document (*.xml) Word XML Belgesi (*.xml)
# Find and Replace special characters and wildcards (#543).
Can't render this file because it contains an unexpected character in line 431 and column 13.
+16
View File
@@ -1701,6 +1701,22 @@
Continuous numbering Наскрізна нумерація
Apply Застосувати
Convert Notes Перетворити примітки
# Built-in file browser when the system file dialog is unavailable (#576)
Documents Документи
Word document Документ Word
All files Усі файли
Recipient lists Списки одержувачів
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. Системне діалогове вікно файлів недоступне, тож WordCraft показує власне. Щоб отримати системне вікно, установіть портал вибору файлів (наприклад, xdg-desktop-portal-gtk) або zenity.
Up one level На рівень вище
Home folder Домашня тека
{name} wasn't found. {name} не знайдено.
{name} already exists. Choose Save again to replace it. {name} уже існує. Щоб замінити файл, виберіть «Зберегти» ще раз.
This folder can't be read: {error} Не вдається прочитати цю теку: {error}
Showing the first {count} items. Показано лише перші елементи: {count}.
File name: Ім'я файлу:
Browse in WordCraft… Огляд у WordCraft…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. Відкрити файл власним оглядачем WordCraft, коли системне діалогове вікно файлів не з'являється.
Hide White Space Приховати порожній простір
All pages Усі сторінки
Current page Поточна сторінка
Can't render this file because it contains an unexpected character in line 6 and column 31.
+16
View File
@@ -1701,6 +1701,22 @@
Continuous numbering 连续编号
Apply 应用
Convert Notes 转换注释
# Built-in file browser when the system file dialog is unavailable (#576)
Documents 文档
Word document Word 文档
All files 所有文件
Recipient lists 收件人列表
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. 系统文件对话框不可用,因此 WordCraft 显示自己的对话框。要使用系统对话框,请安装文件选择器门户(例如 xdg-desktop-portal-gtk)或 zenity。
Up one level 向上一级
Home folder 主文件夹
{name} wasn't found. 未找到 {name}。
{name} already exists. Choose Save again to replace it. {name} 已存在。再次选择“保存”即可替换它。
This folder can't be read: {error} 无法读取此文件夹:{error}
Showing the first {count} items. 仅显示前 {count} 项。
File name: 文件名:
Browse in WordCraft… 在 WordCraft 中浏览…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. 当系统文件对话框没有出现时,使用 WordCraft 自带的浏览器打开文件。
Hide White Space 隐藏空白
All pages 所有页面
Current page 当前页
Can't render this file because it contains an unexpected character in line 7 and column 25.
+16
View File
@@ -1690,6 +1690,22 @@
Continuous numbering 連續編號
Apply 套用
Convert Notes 轉換附註
# Built-in file browser when the system file dialog is unavailable (#576)
Documents 文件
Word document Word 文件
All files 所有檔案
Recipient lists 收件者清單
The system file dialog isn't available, so WordCraft shows its own. To get the system dialog, install a file chooser portal (such as xdg-desktop-portal-gtk) or zenity. 系統檔案對話方塊無法使用,因此 WordCraft 顯示自己的對話方塊。若要使用系統對話方塊,請安裝檔案選擇器入口(例如 xdg-desktop-portal-gtk)或 zenity。
Up one level 上移一層
Home folder 個人資料夾
{name} wasn't found. 找不到 {name}。
{name} already exists. Choose Save again to replace it. {name} 已存在。再次選擇「儲存」即可取代它。
This folder can't be read: {error} 無法讀取此資料夾:{error}
Showing the first {count} items. 僅顯示前 {count} 個項目。
File name: 檔案名稱:
Browse in WordCraft… 在 WordCraft 中瀏覽…
Open a file with WordCraft's own browser when the system file dialog doesn't appear. 當系統檔案對話方塊沒有出現時,使用 WordCraft 內建的瀏覽器開啟檔案。
Hide White Space 隱藏空白
All pages 所有頁面
Current page 目前頁面
Can't render this file because it contains an unexpected character in line 554 and column 13.
+16
View File
@@ -1298,6 +1298,22 @@ pub fn paint(p: &Painter, r: Rect, name: &str, c: Color32, accent: Color32) {
}
}
// The built-in file browser (#576): a folder, a file and the home folder.
"folder" => {
pen.frect(2.5, 4.5, 8.5, 7.0, yellow);
pen.frect(2.5, 6.5, 17.5, 16.0, yellow);
pen.closed(&[(2.5, 4.5), (8.0, 4.5), (9.5, 6.5), (17.5, 6.5), (17.5, 16.0), (2.5, 16.0)], c);
}
"file" => {
pen.page(4.5, 2.0, 15.5, 18.0);
pen.lines(7.0, 13.0, &[9.0, 12.0, 15.0]);
}
"homeFolder" => {
pen.closed(&[(3.0, 9.5), (10.0, 3.5), (17.0, 9.5)], c);
pen.closed(&[(5.0, 8.5), (5.0, 17.0), (15.0, 17.0), (15.0, 8.5)], c);
pen.frect(8.5, 12.0, 11.5, 17.0, a);
}
// Outlining tab: arrows moving a paragraph between outline levels and along the outline.
"outlinePromote" | "outlineDemote" => {
pen.lines(9.0, 17.0, &[6.0, 10.0, 14.0]);
+24 -5
View File
@@ -38,6 +38,9 @@ pub mod dialogs_ribbon;
pub mod dialogs_smart_art;
pub mod dialogs_templates;
pub mod equation_tab;
/// The built-in file browser, for desktops without a system file dialog (#576).
#[cfg(not(target_arch = "wasm32"))]
pub mod file_browser;
pub mod file_dialogs;
pub mod frame;
pub mod i18n;
@@ -67,7 +70,7 @@ use serde_json::{Value, json};
use wordcraft_engine::Session;
pub use control::ControlRequest;
pub use file_dialogs::FileDialogRequest;
pub use file_dialogs::{FileDialogAnswer, FileDialogRequest, open_filters};
/// Platform services injected by the host (file dialogs, file I/O).
#[derive(Default)]
@@ -77,10 +80,11 @@ pub struct Services {
/// Pick a path to save to, given a suggested name.
pub pick_save: Option<Box<dyn Fn(&str) -> Option<String>>>,
/// Desktop: show a native file dialog without blocking the UI thread (#94); used instead of
/// `pick_open` / `pick_save` when set. The host sends the picked path (`None` when cancelled)
/// through the returned channel once the user answers, and wakes the UI; a dropped sender
/// counts as cancelled. The app shows one dialog at a time ([`file_dialogs`]).
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<Option<String>>>>,
/// `pick_open` / `pick_save` when set. The host sends the picked path (`Ok(None)` when
/// cancelled) through the returned channel once the user answers, and wakes the UI; a dropped
/// sender counts as cancelled. An `Err` says no system dialog could be shown, and the app shows
/// its built-in file browser instead (#576). The app shows one dialog at a time ([`file_dialogs`]).
pub file_dialog: Option<Box<dyn Fn(FileDialogRequest) -> std::sync::mpsc::Receiver<FileDialogAnswer>>>,
/// Web: open a file picker; the file arrives later through `inbox`.
pub open_async: Option<Box<dyn Fn(&str)>>,
/// Web: files (name, bytes) delivered asynchronously (picker, drag and drop).
@@ -326,6 +330,9 @@ pub struct WordApp {
autosave_path: Option<std::path::PathBuf>,
/// The file dialog the host is showing, and what its answer is for ([`file_dialogs`]).
file_dialog: Option<file_dialogs::PendingDialog>,
/// The built-in file browser, when it is open, and the folder it showed last (#576).
#[cfg(not(target_arch = "wasm32"))]
pub(crate) file_browser: file_browser::State,
/// File › Info: the property field being typed in and the document revision after its last
/// keystroke, so one visit's keystrokes make a single undo step (and anything else in
/// between, such as an Undo, starts a new one).
@@ -397,6 +404,8 @@ impl WordApp {
font_generation: wordcraft_fonts::FontDb::global().generation(),
dialog_pick: None,
file_dialog: None,
#[cfg(not(target_arch = "wasm32"))]
file_browser: file_browser::State::default(),
info_editing: None,
}
}
@@ -891,6 +900,14 @@ impl WordApp {
self.open_dialog();
json!({})
}
// The built-in file browser (#576), for when the system's file dialog doesn't show up
// (desktop only): `purpose` is document (default), picture, recipients or save. A system
// dialog still waiting for an answer is given up as cancelled.
#[cfg(not(target_arch = "wasm32"))]
"ui.fileBrowser" => {
self.browse_builtin(s("purpose").unwrap_or("document"));
json!({"open": self.file_browser.open.is_some()})
}
// Mailings › Select Recipients › Use an Existing List…: pick a CSV/TSV/text file and
// load it as the mail-merge recipients. Opens system UI, like `ui.openFileDialog`.
"ui.openRecipientList" => {
@@ -1229,6 +1246,8 @@ impl WordApp {
}
content_controls::popup(self, &ctx);
dialogs::show(self, &ctx);
#[cfg(not(target_arch = "wasm32"))]
file_browser::show(self, &ctx);
colors::show(self, &ctx);
zotero::show_alert(self, &ctx);
read_aloud::show(self, &ctx);
+1 -1
View File
@@ -50,7 +50,7 @@ and set `WORDCRAFT_CONTROL_KEY` to that app's key.
| Method | Params | Result |
|---|---|---|
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
| `engine.execute` | `command`, `params` | the command's result (any id from `engine.commands`, including UI commands `ui.tab`, `ui.dialog`, `ui.backstage`, `ui.zotero.*` (see `docs/zotero.md`), `ui.language` (`{"value": "auto"|"en"|"zh-hans"|"zh-hant"|"ja"|"pt-br"|"es"|"uk"|"sr"|"sr-latn"|"et"|"tr"|"ru"}`), `ui.theme` (`{"value": "system"|"light"|"dark"}`; `system` follows the OS appearance, light when it reports none), `ui.interfaceSize` (`{"percent": 80–200}`, clamped; no value reads it; scales the interface on top of the system display scale, document zoom stays `view.zoom`), `ui.fileBrowser` (desktop: `{"purpose": "document"|"picture"|"recipients"|"save"}` opens WordCraft's own file browser, for when the system file dialog doesn't show up, #576), `ui.caretAnimation` (`{"value": true|false}`; no value reads it; off, the caret jumps and blinks on and off instead of gliding and fading)…) |
| `engine.commands` | — | every command: id, label, location, shortcut, params, enabled |
| `document.inspect` | `text?` | blocks (text, style, runs, lists, tables), parts, sections, selection, pages |
| `ui.inspect` | — | UI state, view, dialog, window size, page rects on screen, caret, comment balloons (`balloons`: id + screen rect; `balloon`: the selected one), perf |
+2
View File
@@ -32,6 +32,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Status bar (page x of y, words, language, view buttons, zoom slider) | ✅ | ✅ | 80% | — |
| Interface themes (light, dark, system #249, follows OS changes live #311), dark page separate from the interface theme (#194, #312) | ✅ | ✅ | 90% | — |
| Interface size (File › Options › General, 80–200 %, on top of the system display scale; `ui.interfaceSize`) (#475) | ✅ | system display scaling only | 100% | — |
| File dialogs (Open, Save As, Export, Insert Picture) | system dialog, without blocking the window (#94); Linux/BSD without a file chooser portal or zenity: WordCraft's own browser, also File › Open › Browse in WordCraft… (#576) | system dialog | 95% | — |
| Platform conventions (macOS menus/traffic lights #255, Windows title bar, Linux CSD on Wayland #78) | partial | native | 70% | 3–5 |
| Screen readers (VoiceOver, Narrator, Orca) | AccessKit on; the document canvas describes paragraphs, headings (level), list items, tables, links, pictures (alt text) and the caret and selection, for the pages on screen (#488); not yet tried with real screen readers | full | 50% | 5–8 |
| Right-to-left (mirrored) interface | ❌ | ✅ (Arabic, Hebrew Word) | 0% | in localization |
@@ -40,6 +41,7 @@ own (`AGENTS.md`: no Microsoft iconography, ever).
| Date | Change | Summary |
|---|---|---|
| 2026-10-11 | trivial | Built-in file browser when the system file dialog can't be shown on Linux/BSD; `ui.fileBrowser` (#576) |
| 2026-10-11 | trivial | Every measurement now follows File › Options › Advanced › Measurement units: the Line Numbers "From text" distance and the Hyphenation zone were still fixed in inches (#755) |
| 2026-10-11 | trivial | Track Changes Options › Show revisions in balloons works: tracked deletions in the body leave the text for "Deleted: …" balloons in the markup area, joined to where they were; inline again where no balloons show (Comments pane, Read Mode, side by side, Web Layout), and always inline in headers, footers, notes and text boxes |
| 2026-10-11 | trivial | Mini toolbar in two rows like Word's (font, size, grow, shrink; B I U S, highlight, font colour, four alignments) with every button inside the bar; font and size lists drop from the box's left edge; dropdown arrows a readable size; font list entries take the theme's text colour in dark mode (#535) |