mirror of
https://github.com/devmobasa/wayscriber.git
synced 2026-11-01 07:36:42 +01:00
Merge pull request #411 from weimona/main
Add multi-language UI support (zh-CN first) 「using deepseek-V4.1-flash」
This commit is contained in:
366 files changed
+12534
-3866
No files matched your search
@@ -1236,6 +1236,33 @@ Pressure controls the width of pressure-sensitive freehand Pen strokes. Marker/T
|
||||
|
||||
Tablet input works out of the box in default builds. Set `[tablet].enabled = false` in `config.toml` to opt out. To build without tablet support, drop only that feature (bare `--no-default-features` would also strip portal capture, tray, and the GTK toolbars): `cargo build --release --no-default-features --features portal,tray,toolbar-gtk`.
|
||||
|
||||
### Interface language
|
||||
|
||||
Wayscriber's interface is available in English and Simplified Chinese. By
|
||||
default it follows your desktop locale and falls back to English. Pick a
|
||||
language in the configurator (**UI → Language**, which previews it right away
|
||||
and keeps it when you save) or in `config.toml`:
|
||||
|
||||
```toml
|
||||
[ui]
|
||||
language = "zh-CN" # "auto" (default), "en", or "zh-CN"
|
||||
```
|
||||
|
||||
The overlay and the About window use the saved language the next time they
|
||||
open, and background mode after it restarts. To use a language for one run
|
||||
without changing `config.toml`, or to see which one is in effect:
|
||||
|
||||
```bash
|
||||
wayscriber --list-languages # the choices, and the language in effect and why
|
||||
wayscriber --active --language zh-CN # this overlay only
|
||||
wayscriber --daemon --language en # background mode and every overlay it opens
|
||||
```
|
||||
|
||||
`--language` also applies to `--freeze` and `--about`. It beats both
|
||||
`[ui] language` and `WAYSCRIBER_LANGUAGE`. Typed text, board names and other
|
||||
content you create are never translated. See [docs/I18N.md](docs/I18N.md) for
|
||||
translations, local catalog overrides and adding a language.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
@@ -1329,6 +1356,7 @@ Common toggles:
|
||||
- `WAYSCRIBER_TOOLBAR_BACKEND=auto|gtk|builtin` overrides the toolbar frontend (default: auto)
|
||||
- `WAYSCRIBER_NO_TRAY=1` disables the tray icon (default: tray enabled)
|
||||
- `WAYSCRIBER_TRAY_FORCE_PIXMAP=1` forces colored tray pixmaps and overrides `[tray].icon_style`
|
||||
- `WAYSCRIBER_LANGUAGE=zh-CN` uses an interface language for this run (overrides `[ui] language`; `--language` overrides it)
|
||||
|
||||
See `docs/CONFIG.md` for the full list.
|
||||
|
||||
|
||||
@@ -415,6 +415,16 @@ theme = "auto"
|
||||
# currently behaves like "off" (full motion).
|
||||
reduced_motion = "auto"
|
||||
|
||||
# UI language: overlay chrome (help, toolbars, menus, toasts, settings), the
|
||||
# About window, background mode's tray and notices, and the configurator.
|
||||
# Options: "auto", "en", "zh-CN". "auto" follows the desktop locale
|
||||
# (WAYSCRIBER_LANGUAGE, then LANGUAGE/LC_ALL/LC_MESSAGES/LANG) and falls back
|
||||
# to English when that locale has no translation. Each process reads this when
|
||||
# it starts: the overlay and About window on their next open, background mode
|
||||
# after a restart. `wayscriber --list-languages` shows the choices and the one
|
||||
# in effect; `--language CODE` overrides this for one run.
|
||||
language = "auto"
|
||||
|
||||
# Show the status bar and its configured contents
|
||||
show_status_bar = true
|
||||
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
//! the page builders registered ([`super::pages`]).
|
||||
|
||||
mod effects;
|
||||
mod rebuild;
|
||||
mod shell;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod view;
|
||||
|
||||
use relm4::prelude::*;
|
||||
@@ -87,6 +90,10 @@ pub(crate) struct AppWidgets {
|
||||
/// instead of on every refresh.
|
||||
seen_focus_serial: u64,
|
||||
bindings: Vec<Binding>,
|
||||
/// The language the pages were built in. When the active language moves
|
||||
/// away from it, the next refresh rebuilds them.
|
||||
language: wayscriber::domain::Language,
|
||||
chrome: shell::Chrome,
|
||||
}
|
||||
|
||||
impl Component for ConfiguratorApp {
|
||||
@@ -115,6 +122,9 @@ impl Component for ConfiguratorApp {
|
||||
spawn_effect(effect, &sender, &root);
|
||||
}
|
||||
|
||||
// The window opens in English: the config, and with it the saved
|
||||
// language, arrives with the asynchronous first load, which applies
|
||||
// that language and rebuilds the pages like any other change.
|
||||
let widgets = shell::build(&root, &sender);
|
||||
ComponentParts { model, widgets }
|
||||
}
|
||||
@@ -136,7 +146,7 @@ impl Component for ConfiguratorApp {
|
||||
}
|
||||
}
|
||||
|
||||
fn update_view(&self, widgets: &mut Self::Widgets, _sender: ComponentSender<Self>) {
|
||||
view::refresh(self, widgets);
|
||||
fn update_view(&self, widgets: &mut Self::Widgets, sender: ComponentSender<Self>) {
|
||||
view::refresh(self, widgets, &sender);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
//! Redrawing the window in a new UI language.
|
||||
//!
|
||||
//! Every caption on a page is translated while the page is built, so a
|
||||
//! language change builds the pages again from the model instead of
|
||||
//! retitling thousands of labels one by one; the persistent chrome around
|
||||
//! them is retitled in place. The model owns everything the user entered —
|
||||
//! draft text, invalid input, open shortcut edits, active tabs, search — and
|
||||
//! the first refresh writes it into the new pages. What the model does not
|
||||
//! own is view state: which control has focus, the caret and selection in
|
||||
//! it, and how far the visible page is scrolled. This module carries those
|
||||
//! across.
|
||||
|
||||
use relm4::{ComponentSender, gtk};
|
||||
|
||||
use gtk::glib;
|
||||
use gtk::prelude::*;
|
||||
|
||||
use super::super::pages;
|
||||
use super::super::state::ConfiguratorApp;
|
||||
use super::{AppWidgets, shell};
|
||||
|
||||
/// A widget's position below a page: at each level, its index among its
|
||||
/// parent's children and its type. A rebuilt page has the same shape while
|
||||
/// the model gives it the same rows, so the same path finds the same control;
|
||||
/// a type mismatch on the way means the control is gone.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct WidgetPath(Vec<(usize, glib::Type)>);
|
||||
|
||||
impl WidgetPath {
|
||||
fn of(widget: >k::Widget, page: >k::Widget) -> Option<Self> {
|
||||
let mut steps = Vec::new();
|
||||
let mut current = widget.clone();
|
||||
while ¤t != page {
|
||||
let parent = current.parent()?;
|
||||
let index = children(&parent).position(|child| child == current)?;
|
||||
steps.push((index, current.type_()));
|
||||
current = parent;
|
||||
}
|
||||
steps.reverse();
|
||||
|
||||
Some(Self(steps))
|
||||
}
|
||||
|
||||
fn resolve(&self, page: >k::Widget) -> Option<gtk::Widget> {
|
||||
let mut current = page.clone();
|
||||
for (index, kind) in &self.0 {
|
||||
let child = children(¤t).nth(*index)?;
|
||||
if child.type_() != *kind {
|
||||
return None;
|
||||
}
|
||||
current = child;
|
||||
}
|
||||
|
||||
Some(current)
|
||||
}
|
||||
}
|
||||
|
||||
fn children(widget: >k::Widget) -> impl Iterator<Item = gtk::Widget> {
|
||||
std::iter::successors(widget.first_child(), gtk::Widget::next_sibling)
|
||||
}
|
||||
|
||||
/// The caret and selection of an editable control, in characters.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
struct Caret {
|
||||
position: i32,
|
||||
selection: Option<(i32, i32)>,
|
||||
}
|
||||
|
||||
/// View state from the pages being replaced, applied to the new ones.
|
||||
pub(super) struct ViewState {
|
||||
/// The stack name of the page the state was read from.
|
||||
page: Option<gtk::glib::GString>,
|
||||
/// Set when focus was inside the visible page; focus elsewhere sits in
|
||||
/// persistent chrome, which the rebuild leaves alone.
|
||||
focus: Option<(Option<WidgetPath>, Option<Caret>)>,
|
||||
scrolls: Vec<(WidgetPath, f64, f64)>,
|
||||
transition: gtk::StackTransitionType,
|
||||
}
|
||||
|
||||
/// Rebuilds the pages when the active language differs from the one they
|
||||
/// were built in, and returns the view state to restore once the refresh
|
||||
/// has written the model into them.
|
||||
pub(super) fn rebuild_if_language_changed(
|
||||
widgets: &mut AppWidgets,
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
) -> Option<ViewState> {
|
||||
let language = wayscriber::i18n::language();
|
||||
if widgets.language == language {
|
||||
return None;
|
||||
}
|
||||
widgets.language = language;
|
||||
let state = capture(widgets);
|
||||
|
||||
{
|
||||
// Closing the old pages' popovers is the rebuild's doing, not a
|
||||
// cancel from the user.
|
||||
let _replacing = pages::ReplacingPages::begin();
|
||||
// No crossfade: the new page simply takes the old one's place.
|
||||
widgets
|
||||
.stack
|
||||
.set_transition_type(gtk::StackTransitionType::None);
|
||||
while let Some(child) = widgets.stack.first_child() {
|
||||
widgets.stack.remove(&child);
|
||||
}
|
||||
// Dropping the bindings drops the last references to the old rows.
|
||||
widgets.bindings.clear();
|
||||
widgets.bindings = shell::install_pages(&widgets.stack, &widgets.sidebar_rows, sender);
|
||||
}
|
||||
shell::retitle(widgets);
|
||||
|
||||
Some(state)
|
||||
}
|
||||
|
||||
fn capture(widgets: &AppWidgets) -> ViewState {
|
||||
let transition = widgets.stack.transition_type();
|
||||
let page_name = widgets.stack.visible_child_name();
|
||||
let Some(page) = widgets.stack.visible_child() else {
|
||||
return ViewState {
|
||||
page: page_name,
|
||||
focus: None,
|
||||
scrolls: Vec::new(),
|
||||
transition,
|
||||
};
|
||||
};
|
||||
|
||||
let focus = gtk::prelude::GtkWindowExt::focus(&widgets.root)
|
||||
.filter(|focused| focused.is_ancestor(&page))
|
||||
.map(|focused| {
|
||||
let caret = focused
|
||||
.downcast_ref::<gtk::Editable>()
|
||||
.map(|editable| Caret {
|
||||
position: editable.position(),
|
||||
selection: editable.selection_bounds(),
|
||||
});
|
||||
(WidgetPath::of(&focused, &page), caret)
|
||||
});
|
||||
let scrolls = descendants(&page)
|
||||
.filter_map(|widget| {
|
||||
let scrolled = widget.downcast_ref::<gtk::ScrolledWindow>()?;
|
||||
let path = WidgetPath::of(&widget, &page)?;
|
||||
Some((
|
||||
path,
|
||||
scrolled.hadjustment().value(),
|
||||
scrolled.vadjustment().value(),
|
||||
))
|
||||
})
|
||||
.filter(|(_, horizontal, vertical)| *horizontal > 0.0 || *vertical > 0.0)
|
||||
.collect();
|
||||
|
||||
ViewState {
|
||||
page: page_name,
|
||||
focus,
|
||||
scrolls,
|
||||
transition,
|
||||
}
|
||||
}
|
||||
|
||||
fn descendants(widget: >k::Widget) -> impl Iterator<Item = gtk::Widget> {
|
||||
let mut pending = vec![widget.clone()];
|
||||
std::iter::from_fn(move || {
|
||||
let next = pending.pop()?;
|
||||
pending.extend(children(&next));
|
||||
Some(next)
|
||||
})
|
||||
}
|
||||
|
||||
/// Puts focus, caret and scroll back on the rebuilt page. Called after the
|
||||
/// refresh, so the page is the visible one and holds the model's values.
|
||||
pub(super) fn restore(widgets: &AppWidgets, state: ViewState) {
|
||||
let page = widgets.stack.visible_child();
|
||||
// A path means something only on the page it was read from.
|
||||
let same_page = widgets.stack.visible_child_name() == state.page;
|
||||
|
||||
if let (Some(page), Some((path, caret))) = (page.as_ref(), state.focus) {
|
||||
let target = path
|
||||
.filter(|_| same_page)
|
||||
.and_then(|path| path.resolve(page))
|
||||
.filter(|widget| widget.is_visible() && (widget.is_focusable() || widget.can_focus()));
|
||||
match target {
|
||||
Some(widget) if widget.grab_focus() => {
|
||||
if let (Some(editable), Some(caret)) =
|
||||
(widget.downcast_ref::<gtk::Editable>(), caret)
|
||||
{
|
||||
match caret.selection {
|
||||
// `select_region` puts the caret on its second
|
||||
// argument, and the bounds come back ordered: a
|
||||
// selection made backwards keeps its active end
|
||||
// where the caret was.
|
||||
Some((start, end)) => {
|
||||
let (anchor, active) = if caret.position == start {
|
||||
(end, start)
|
||||
} else {
|
||||
(start, end)
|
||||
};
|
||||
editable.select_region(anchor, active);
|
||||
}
|
||||
None => {
|
||||
editable.select_region(caret.position, caret.position);
|
||||
editable.set_position(caret.position);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// The control did not survive the transition (a load or Defaults
|
||||
// changed the rows, or another page is showing now): the page's
|
||||
// first control takes focus.
|
||||
_ => {
|
||||
page.child_focus(gtk::DirectionType::TabForward);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(page) = page.as_ref().filter(|_| same_page) {
|
||||
for (path, horizontal, vertical) in state.scrolls {
|
||||
if let Some(scrolled) = path
|
||||
.resolve(page)
|
||||
.and_then(|widget| widget.downcast::<gtk::ScrolledWindow>().ok())
|
||||
{
|
||||
restore_scroll(&scrolled, horizontal, vertical);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
widgets.stack.set_transition_type(state.transition);
|
||||
}
|
||||
|
||||
/// Scrolls once the new page has been laid out: before its first
|
||||
/// allocation an adjustment's range is empty and clamps every value to 0.
|
||||
fn restore_scroll(scrolled: >k::ScrolledWindow, horizontal: f64, vertical: f64) {
|
||||
scrolled.add_tick_callback(move |scrolled, _clock| {
|
||||
if scrolled.height() == 0 {
|
||||
return glib::ControlFlow::Continue;
|
||||
}
|
||||
scrolled.hadjustment().set_value(horizontal);
|
||||
scrolled.vadjustment().set_value(vertical);
|
||||
glib::ControlFlow::Break
|
||||
});
|
||||
}
|
||||
|
||||
/// A path finds the control at the same place in a tree of the same shape,
|
||||
/// and nothing once the shape on its way has changed: that is what sends
|
||||
/// focus to the page's first control instead of to a stranger.
|
||||
#[cfg(test)]
|
||||
pub(super) fn assert_widget_paths_find_only_the_same_shape() {
|
||||
fn page_with(second: Option<>k::Widget>) -> gtk::Box {
|
||||
let page = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
let group = gtk::Box::new(gtk::Orientation::Vertical, 0);
|
||||
page.append(&group);
|
||||
group.append(>k::Label::new(Some("title")));
|
||||
if let Some(second) = second {
|
||||
group.append(second);
|
||||
}
|
||||
page
|
||||
}
|
||||
|
||||
let old_entry = gtk::Entry::new();
|
||||
let old_page = page_with(Some(old_entry.upcast_ref()));
|
||||
let path = WidgetPath::of(old_entry.upcast_ref(), old_page.upcast_ref()).expect("path");
|
||||
|
||||
let new_entry = gtk::Entry::new();
|
||||
let same = page_with(Some(new_entry.upcast_ref()));
|
||||
assert_eq!(
|
||||
path.resolve(same.upcast_ref()),
|
||||
Some(new_entry.upcast::<gtk::Widget>())
|
||||
);
|
||||
|
||||
let replaced = page_with(Some(gtk::Button::new().upcast_ref()));
|
||||
assert_eq!(path.resolve(replaced.upcast_ref()), None);
|
||||
let removed = page_with(None);
|
||||
assert_eq!(path.resolve(removed.upcast_ref()), None);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::TabId;
|
||||
|
||||
@@ -10,6 +12,25 @@ use super::super::search::AppSearchSummary;
|
||||
use super::super::state::ConfiguratorApp;
|
||||
use super::AppWidgets;
|
||||
|
||||
/// Window chrome that outlives a language change: it is built once and
|
||||
/// retitled in place by [`retitle`], while the pages are rebuilt.
|
||||
pub(super) struct Chrome {
|
||||
build_identity: gtk::Label,
|
||||
content_page: adw::NavigationPage,
|
||||
migration_apply: gtk::Button,
|
||||
migration_dismiss: gtk::Button,
|
||||
leave_save: gtk::Button,
|
||||
leave_discard: gtk::Button,
|
||||
sidebar_labels: Vec<(TabId, gtk::Label)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Chrome {
|
||||
pub(super) fn sidebar_labels(&self) -> &[(TabId, gtk::Label)] {
|
||||
&self.sidebar_labels
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn build(
|
||||
root: &adw::ApplicationWindow,
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
@@ -22,9 +43,7 @@ pub(super) fn build(
|
||||
});
|
||||
}
|
||||
// ---- Sidebar ----------------------------------------------------
|
||||
let search_entry = gtk::SearchEntry::builder()
|
||||
.placeholder_text("Search settings")
|
||||
.build();
|
||||
let search_entry = gtk::SearchEntry::new();
|
||||
{
|
||||
let sender = sender.clone();
|
||||
search_entry.connect_search_changed(move |entry| {
|
||||
@@ -43,21 +62,19 @@ pub(super) fn build(
|
||||
.css_classes(["navigation-sidebar"])
|
||||
.build();
|
||||
let mut sidebar_rows = Vec::new();
|
||||
let mut sidebar_labels = Vec::new();
|
||||
for tab in TabId::ALL {
|
||||
let row = gtk::ListBoxRow::builder()
|
||||
.child(
|
||||
>k::Label::builder()
|
||||
.label(tab.title())
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(6)
|
||||
.margin_end(6)
|
||||
.build(),
|
||||
)
|
||||
let label = gtk::Label::builder()
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(6)
|
||||
.margin_end(6)
|
||||
.build();
|
||||
let row = gtk::ListBoxRow::builder().child(&label).build();
|
||||
sidebar.append(&row);
|
||||
sidebar_rows.push((tab, row));
|
||||
sidebar_labels.push((tab, label));
|
||||
}
|
||||
{
|
||||
let sender = sender.clone();
|
||||
@@ -84,11 +101,6 @@ pub(super) fn build(
|
||||
sidebar_box.append(&search_entry);
|
||||
sidebar_box.append(&sidebar_scroll);
|
||||
let build_identity = gtk::Label::builder()
|
||||
.label(format!(
|
||||
"Version {} · commit {}",
|
||||
wayscriber::build_info::version(),
|
||||
wayscriber::build_info::commit_hash()
|
||||
))
|
||||
.css_classes(["dim-label"])
|
||||
.halign(gtk::Align::Start)
|
||||
.selectable(true)
|
||||
@@ -103,8 +115,8 @@ pub(super) fn build(
|
||||
.build();
|
||||
|
||||
// ---- Header actions ---------------------------------------------
|
||||
let window_title = adw::WindowTitle::new("Wayscriber Configurator", "");
|
||||
let reload_button = gtk::Button::with_label("Reload");
|
||||
let window_title = adw::WindowTitle::new("", "");
|
||||
let reload_button = gtk::Button::new();
|
||||
{
|
||||
let sender = sender.clone();
|
||||
reload_button.connect_clicked(move |_| sender.input(Message::ReloadRequested));
|
||||
@@ -114,7 +126,7 @@ pub(super) fn build(
|
||||
// button that asks steps aside for the pair that answers. All three
|
||||
// exist from the start and visibility picks between them, which is
|
||||
// what keeps a repeat of the same press from ever applying defaults.
|
||||
let defaults_button = gtk::Button::with_label("Defaults");
|
||||
let defaults_button = gtk::Button::new();
|
||||
{
|
||||
let sender = sender.clone();
|
||||
defaults_button.connect_clicked(move |_| {
|
||||
@@ -122,7 +134,6 @@ pub(super) fn build(
|
||||
});
|
||||
}
|
||||
let defaults_confirm_button = gtk::Button::builder()
|
||||
.label("Confirm Defaults")
|
||||
.visible(false)
|
||||
.css_classes(["destructive-action"])
|
||||
.build();
|
||||
@@ -133,7 +144,6 @@ pub(super) fn build(
|
||||
});
|
||||
}
|
||||
let defaults_cancel_button = gtk::Button::builder()
|
||||
.label("Cancel")
|
||||
.visible(false)
|
||||
.css_classes(["flat"])
|
||||
.build();
|
||||
@@ -148,7 +158,7 @@ pub(super) fn build(
|
||||
defaults_box.append(&defaults_confirm_button);
|
||||
defaults_box.append(&defaults_cancel_button);
|
||||
|
||||
let save_button = gtk::Button::with_label("Save");
|
||||
let save_button = gtk::Button::new();
|
||||
save_button.add_css_class("suggested-action");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
@@ -175,7 +185,7 @@ pub(super) fn build(
|
||||
let status_revealer = gtk::Revealer::builder().child(&status_label).build();
|
||||
|
||||
let migration_label = gtk::Label::builder().wrap(true).xalign(0.0).build();
|
||||
let migration_apply = gtk::Button::with_label("Apply Update");
|
||||
let migration_apply = gtk::Button::new();
|
||||
migration_apply.add_css_class("suggested-action");
|
||||
{
|
||||
let sender = sender.clone();
|
||||
@@ -183,7 +193,7 @@ pub(super) fn build(
|
||||
sender.input(Message::MigrationApplyRequested);
|
||||
});
|
||||
}
|
||||
let migration_dismiss = gtk::Button::with_label("Dismiss");
|
||||
let migration_dismiss = gtk::Button::new();
|
||||
{
|
||||
let sender = sender.clone();
|
||||
migration_dismiss.connect_clicked(move |_| {
|
||||
@@ -212,41 +222,18 @@ pub(super) fn build(
|
||||
.transition_type(gtk::StackTransitionType::Crossfade)
|
||||
.vexpand(true)
|
||||
.build();
|
||||
let mut bindings: Vec<Binding> = Vec::new();
|
||||
for (tab, built) in pages::build_all(sender) {
|
||||
stack.add_named(&built.widget, Some(pages::stack_name(tab)));
|
||||
bindings.extend(built.bindings);
|
||||
}
|
||||
|
||||
// Sidebar visibility follows the search summary.
|
||||
{
|
||||
let rows = sidebar_rows.clone();
|
||||
bindings.push(Box::new(
|
||||
move |_app: &ConfiguratorApp, summary: &AppSearchSummary| {
|
||||
for (tab, row) in &rows {
|
||||
let visible = !summary.is_active() || summary.tab(*tab).is_some();
|
||||
if row.is_visible() != visible {
|
||||
row.set_visible(visible);
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
}
|
||||
let bindings = install_pages(&stack, &sidebar_rows, sender);
|
||||
|
||||
let leave_actions = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
leave_actions.set_visible(false);
|
||||
leave_actions.set_margin_start(12);
|
||||
leave_actions.set_margin_bottom(6);
|
||||
let leave_cancel = gtk::Button::with_label("Cancel");
|
||||
let leave_save = gtk::Button::new();
|
||||
let leave_discard = gtk::Button::new();
|
||||
let leave_cancel = gtk::Button::new();
|
||||
for (button, message) in [
|
||||
(
|
||||
gtk::Button::with_label("Save changes"),
|
||||
Message::LeaveSaveRequested,
|
||||
),
|
||||
(
|
||||
gtk::Button::with_label("Discard changes"),
|
||||
Message::LeaveDiscardRequested,
|
||||
),
|
||||
(leave_save.clone(), Message::LeaveSaveRequested),
|
||||
(leave_discard.clone(), Message::LeaveDiscardRequested),
|
||||
(leave_cancel.clone(), Message::LeaveCanceled),
|
||||
] {
|
||||
let sender = sender.clone();
|
||||
@@ -263,10 +250,7 @@ pub(super) fn build(
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
let content_page = adw::NavigationPage::builder()
|
||||
.title("Settings")
|
||||
.child(&toolbar_view)
|
||||
.build();
|
||||
let content_page = adw::NavigationPage::builder().child(&toolbar_view).build();
|
||||
|
||||
let split = adw::NavigationSplitView::builder()
|
||||
.sidebar(&sidebar_page)
|
||||
@@ -319,7 +303,7 @@ pub(super) fn build(
|
||||
root.add_controller(click);
|
||||
}
|
||||
|
||||
AppWidgets {
|
||||
let widgets = AppWidgets {
|
||||
leave_actions,
|
||||
leave_cancel,
|
||||
root: root.clone(),
|
||||
@@ -341,5 +325,85 @@ pub(super) fn build(
|
||||
search_entry,
|
||||
seen_focus_serial: 0,
|
||||
bindings,
|
||||
language: wayscriber::i18n::language(),
|
||||
chrome: Chrome {
|
||||
build_identity,
|
||||
content_page,
|
||||
migration_apply,
|
||||
migration_dismiss,
|
||||
leave_save,
|
||||
leave_discard,
|
||||
sidebar_labels,
|
||||
},
|
||||
};
|
||||
retitle(&widgets);
|
||||
|
||||
widgets
|
||||
}
|
||||
|
||||
/// Builds every page into `stack` and returns their bindings, plus the one
|
||||
/// that shows only the sidebar rows a search matches.
|
||||
///
|
||||
/// The pages are the language-dependent part of the window: a language
|
||||
/// change calls this again with the old pages removed, and the next refresh
|
||||
/// writes the model's values into the new ones.
|
||||
pub(super) fn install_pages(
|
||||
stack: >k::Stack,
|
||||
sidebar_rows: &[(TabId, gtk::ListBoxRow)],
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
) -> Vec<Binding> {
|
||||
let mut bindings: Vec<Binding> = Vec::new();
|
||||
for (tab, built) in pages::build_all(sender) {
|
||||
stack.add_named(&built.widget, Some(pages::stack_name(tab)));
|
||||
bindings.extend(built.bindings);
|
||||
}
|
||||
|
||||
let rows = sidebar_rows.to_vec();
|
||||
bindings.push(Box::new(
|
||||
move |_app: &ConfiguratorApp, summary: &AppSearchSummary| {
|
||||
for (tab, row) in &rows {
|
||||
let visible = !summary.is_active() || summary.tab(*tab).is_some();
|
||||
if row.is_visible() != visible {
|
||||
row.set_visible(visible);
|
||||
}
|
||||
}
|
||||
},
|
||||
));
|
||||
|
||||
bindings
|
||||
}
|
||||
|
||||
/// Writes every caption of the persistent chrome in the active language.
|
||||
pub(super) fn retitle(widgets: &AppWidgets) {
|
||||
let chrome = &widgets.chrome;
|
||||
|
||||
widgets
|
||||
.search_entry
|
||||
.set_placeholder_text(Some(tr("Search settings")));
|
||||
for (tab, label) in &chrome.sidebar_labels {
|
||||
label.set_label(tab.title());
|
||||
}
|
||||
chrome.build_identity.set_label(&trf(
|
||||
"Version {} · commit {}",
|
||||
&[
|
||||
&wayscriber::build_info::version(),
|
||||
&wayscriber::build_info::commit_hash(),
|
||||
],
|
||||
));
|
||||
widgets
|
||||
.window_title
|
||||
.set_title(tr("Wayscriber Configurator"));
|
||||
widgets.reload_button.set_label(tr("Reload"));
|
||||
widgets.defaults_button.set_label(tr("Defaults"));
|
||||
widgets
|
||||
.defaults_confirm_button
|
||||
.set_label(tr("Confirm Defaults"));
|
||||
widgets.defaults_cancel_button.set_label(tr("Cancel"));
|
||||
widgets.save_button.set_label(tr("Save"));
|
||||
chrome.migration_apply.set_label(tr("Apply Update"));
|
||||
chrome.migration_dismiss.set_label(tr("Dismiss"));
|
||||
chrome.leave_save.set_label(tr("Save changes"));
|
||||
chrome.leave_discard.set_label(tr("Discard changes"));
|
||||
widgets.leave_cancel.set_label(tr("Cancel"));
|
||||
chrome.content_page.set_title(tr("Settings"));
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! The shell under GTK: a language change rebuilds the window in place.
|
||||
//!
|
||||
//! These checks launch the real component on the repository's private
|
||||
//! headless compositor (`tools/test-gtk-widgets.sh`) and never on the user's
|
||||
//! display, so ordinary test runs skip them. They are the spike that chose
|
||||
//! the rebuild for decision D1, kept as regression tests:
|
||||
//!
|
||||
//! - a language-only change, delivered as the selection message itself,
|
||||
//! loses no editor, recorder or conflict state, even after the popovers
|
||||
//! the old pages owned have closed and every queued message has run;
|
||||
//! - focus returns to the control that had it, with its caret and
|
||||
//! selection, and the visible page keeps its scroll;
|
||||
//! - navigation works in the new language (finding N1);
|
||||
//! - a load that changes the language keeps the resets it always makes;
|
||||
//! - repeated switches leave no extra pages or bindings behind.
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use relm4::prelude::*;
|
||||
use relm4::{adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
|
||||
use wayscriber::domain::Language;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::{KeybindingField, LanguageOption, StartupRequest, TabId, UiTabId};
|
||||
|
||||
use super::super::state::ConfiguratorApp;
|
||||
|
||||
const MARKER: &str = "EXECUTED: configurator GTK language rebuild assertions";
|
||||
const CHILD_ENV: &str = "WAYSCRIBER_CONFIGURATOR_GTK_CHILD";
|
||||
|
||||
#[test]
|
||||
fn language_changes_rebuild_the_window_in_place() {
|
||||
// A window is allowed only on the dedicated test compositor.
|
||||
if std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none()
|
||||
|| std::env::var("WAYLAND_DISPLAY").as_deref() != Ok("wayscriber-widget-tests")
|
||||
{
|
||||
eprintln!("skipping configurator GTK test: not on the private test compositor");
|
||||
return;
|
||||
}
|
||||
|
||||
if std::env::var_os(CHILD_ENV).is_none() {
|
||||
run_in_child();
|
||||
return;
|
||||
}
|
||||
|
||||
run_scenario();
|
||||
eprintln!("{MARKER}");
|
||||
}
|
||||
|
||||
/// The window, config and catalogs belong to a fresh process with its own
|
||||
/// homes, so nothing reads or writes the user's files, no setup tool runs,
|
||||
/// and the catalogs load from the embedded copies.
|
||||
fn run_in_child() {
|
||||
let homes = crate::test_temp::tempdir().expect("child homes");
|
||||
let mut command = std::process::Command::new(std::env::current_exe().expect("test binary"));
|
||||
command
|
||||
.args([
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::language_changes_rebuild_the_window_in_place"
|
||||
)
|
||||
.strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::"))
|
||||
.expect("test path starts with the crate name"),
|
||||
"--exact",
|
||||
"--test-threads=1",
|
||||
"--nocapture",
|
||||
])
|
||||
.env(CHILD_ENV, "1")
|
||||
.env_remove(wayscriber::env_vars::LANGUAGE_OVERRIDE_ENV);
|
||||
for (key, directory) in [
|
||||
("XDG_CONFIG_HOME", "config"),
|
||||
("XDG_DATA_HOME", "data"),
|
||||
("XDG_STATE_HOME", "state"),
|
||||
("XDG_CACHE_HOME", "cache"),
|
||||
(wayscriber::env_vars::LOCALE_DIR_ENV, "locales"),
|
||||
// No setup tool (systemctl, gsettings) is reachable from here.
|
||||
("PATH", "bin"),
|
||||
] {
|
||||
let path = homes.path().join(directory);
|
||||
std::fs::create_dir_all(&path).expect("child home");
|
||||
command.env(key, path);
|
||||
}
|
||||
for key in wayscriber::env_vars::LOCALE_ENV_KEYS {
|
||||
command.env_remove(key);
|
||||
}
|
||||
write_config(&homes.path().join("config"), "[ui]\nlanguage = \"en\"\n");
|
||||
|
||||
let status = command.status().expect("run the GTK child");
|
||||
assert!(status.success(), "configurator GTK child failed");
|
||||
}
|
||||
|
||||
fn write_config(config_home: &Path, contents: &str) {
|
||||
let directory = config_home.join("wayscriber");
|
||||
std::fs::create_dir_all(&directory).expect("config directory");
|
||||
std::fs::write(directory.join("config.toml"), contents).expect("write config");
|
||||
}
|
||||
|
||||
fn run_scenario() {
|
||||
adw::init().expect("libadwaita initializes on the test compositor");
|
||||
let app = ConfiguratorApp::builder()
|
||||
.launch(StartupRequest::default())
|
||||
.detach();
|
||||
app.widget().present();
|
||||
pump_until("the first config load", || {
|
||||
!app.model().document.is_loading()
|
||||
});
|
||||
settle();
|
||||
assert_eq!(wayscriber::i18n::language(), Language::English);
|
||||
assert_eq!(sidebar_label(&app, TabId::Drawing), "Drawing");
|
||||
let pages = app.widgets().stack.observe_children().n_items();
|
||||
let bindings = app.widgets().bindings.len();
|
||||
|
||||
open_edits_survive_a_language_change(&app);
|
||||
focus_caret_and_scroll_survive_a_language_change(&app);
|
||||
navigation_works_after_a_language_change(&app);
|
||||
repeated_switches_leave_nothing_behind(&app, pages, bindings);
|
||||
a_load_that_changes_the_language_keeps_its_resets(&app);
|
||||
super::rebuild::assert_widget_paths_find_only_the_same_shape();
|
||||
}
|
||||
|
||||
fn open_edits_survive_a_language_change(app: &Controller<ConfiguratorApp>) {
|
||||
app.emit(Message::TabSelected(TabId::Keybindings));
|
||||
app.emit(Message::ShortcutRecordingStarted(KeybindingField::Undo));
|
||||
settle();
|
||||
assert!(app.model().shortcuts.recorder().is_some());
|
||||
|
||||
for language in [
|
||||
LanguageOption::SimplifiedChinese,
|
||||
LanguageOption::English,
|
||||
LanguageOption::SimplifiedChinese,
|
||||
] {
|
||||
app.emit(Message::UiLanguageChanged(language));
|
||||
settle();
|
||||
assert_eq!(
|
||||
app.model()
|
||||
.shortcuts
|
||||
.recorder()
|
||||
.map(|recorder| recorder.field),
|
||||
Some(KeybindingField::Undo),
|
||||
"switching to {language:?} canceled the recording"
|
||||
);
|
||||
}
|
||||
assert_eq!(sidebar_label(app, TabId::Drawing), "绘图");
|
||||
assert_eq!(app.widgets().save_button.label().as_deref(), Some("保存"));
|
||||
// The reopened recorder prompts in the new language.
|
||||
let prompt = app
|
||||
.model()
|
||||
.shortcuts
|
||||
.recorder()
|
||||
.map(|recorder| recorder.prompt())
|
||||
.expect("recorder");
|
||||
assert_eq!(prompt, "请按一个快捷键。");
|
||||
assert!(
|
||||
visible_labels(app).contains(&prompt),
|
||||
"the reopened recorder must show {prompt:?}"
|
||||
);
|
||||
app.emit(Message::ShortcutRecordingCanceled(KeybindingField::Undo));
|
||||
settle();
|
||||
assert!(app.model().shortcuts.recorder().is_none());
|
||||
|
||||
// An unapplied text edit, then the conflict applying it raises.
|
||||
app.emit(Message::ShortcutTextEditStarted(KeybindingField::Undo));
|
||||
app.emit(Message::ShortcutTextEditChanged(
|
||||
"Ctrl+Z, Bogus+".to_string(),
|
||||
));
|
||||
settle();
|
||||
app.emit(Message::UiLanguageChanged(LanguageOption::English));
|
||||
settle();
|
||||
assert_eq!(
|
||||
app.model()
|
||||
.shortcuts
|
||||
.editor()
|
||||
.map(|editor| editor.text.clone()),
|
||||
Some("Ctrl+Z, Bogus+".to_string()),
|
||||
"the language change dropped the text edit"
|
||||
);
|
||||
app.emit(Message::ShortcutTextEditChanged("E".to_string()));
|
||||
app.emit(Message::ShortcutTextEditApplied);
|
||||
settle();
|
||||
assert!(
|
||||
app.model().shortcuts.conflict().is_some(),
|
||||
"E is Clear Canvas"
|
||||
);
|
||||
app.emit(Message::UiLanguageChanged(
|
||||
LanguageOption::SimplifiedChinese,
|
||||
));
|
||||
settle();
|
||||
let conflict = app
|
||||
.model()
|
||||
.shortcuts
|
||||
.conflict()
|
||||
.map(|conflict| conflict.prompt());
|
||||
let conflict = conflict.expect("the language change dropped the conflict");
|
||||
assert!(conflict.contains("清空画布"), "{conflict}");
|
||||
app.emit(Message::ShortcutConflictCanceled);
|
||||
settle();
|
||||
assert!(app.model().shortcuts.conflict().is_none());
|
||||
}
|
||||
|
||||
fn focus_caret_and_scroll_survive_a_language_change(app: &Controller<ConfiguratorApp>) {
|
||||
// The Language row keeps focus after a pick in it.
|
||||
app.emit(Message::TabSelected(TabId::Ui));
|
||||
settle();
|
||||
let row = language_row(app).expect("Language row");
|
||||
assert!(row.grab_focus());
|
||||
app.emit(Message::UiLanguageChanged(LanguageOption::English));
|
||||
settle();
|
||||
let focused = focus(app).expect("something has focus");
|
||||
let new_row = language_row(app).expect("rebuilt Language row");
|
||||
assert!(
|
||||
focused == new_row.clone().upcast::<gtk::Widget>() || focused.is_ancestor(&new_row),
|
||||
"focus moved to {focused:?}"
|
||||
);
|
||||
assert_ne!(new_row, row, "the page was rebuilt");
|
||||
assert_eq!(new_row.title(), "Language");
|
||||
|
||||
// An entry keeps its caret and selection.
|
||||
app.emit(Message::TabSelected(TabId::Drawing));
|
||||
settle();
|
||||
let entry = visible_page_descendants(app)
|
||||
.into_iter()
|
||||
.find_map(|widget| widget.downcast::<adw::EntryRow>().ok())
|
||||
.expect("an entry row on the Drawing page");
|
||||
entry.set_text("12.5");
|
||||
settle();
|
||||
assert!(entry.grab_focus());
|
||||
entry.select_region(1, 3);
|
||||
app.emit(Message::UiLanguageChanged(
|
||||
LanguageOption::SimplifiedChinese,
|
||||
));
|
||||
settle();
|
||||
assert_caret(app, (1, 3), 3, "a forward selection");
|
||||
|
||||
// Selected backwards, the caret sits at the start; Shift+Arrow must keep
|
||||
// moving that end after the switch.
|
||||
focused_editable(app).select_region(3, 1);
|
||||
app.emit(Message::UiLanguageChanged(LanguageOption::English));
|
||||
settle();
|
||||
assert_caret(app, (1, 3), 1, "a backward selection");
|
||||
|
||||
// The visible page keeps its scroll position.
|
||||
let scrolled = scrollable(app).expect("the Drawing page scrolls");
|
||||
let target = (scrolled.vadjustment().upper() - scrolled.vadjustment().page_size()).min(150.0);
|
||||
assert!(target > 0.0, "the page must be taller than the window");
|
||||
scrolled.vadjustment().set_value(target);
|
||||
app.emit(Message::UiLanguageChanged(
|
||||
LanguageOption::SimplifiedChinese,
|
||||
));
|
||||
// The message is queued: let it run before judging the scroll.
|
||||
settle();
|
||||
assert_ne!(scrollable(app), Some(scrolled), "the page was rebuilt");
|
||||
pump_until("the rebuilt page to scroll back", || {
|
||||
scrollable(app)
|
||||
.is_some_and(|scrolled| (scrolled.vadjustment().value() - target).abs() < 0.5)
|
||||
});
|
||||
}
|
||||
|
||||
fn navigation_works_after_a_language_change(app: &Controller<ConfiguratorApp>) {
|
||||
app.emit(Message::UiLanguageChanged(
|
||||
LanguageOption::SimplifiedChinese,
|
||||
));
|
||||
settle();
|
||||
|
||||
// The sidebar, as a click on it reports.
|
||||
for tab in [TabId::Capture, TabId::Boards, TabId::Ui] {
|
||||
let row = app
|
||||
.widgets()
|
||||
.sidebar_rows
|
||||
.iter()
|
||||
.find(|(row_tab, _)| *row_tab == tab)
|
||||
.map(|(_, row)| row.clone())
|
||||
.expect("sidebar row");
|
||||
app.widgets().sidebar.select_row(Some(&row));
|
||||
settle();
|
||||
assert_eq!(app.model().active_tab, tab);
|
||||
assert_eq!(
|
||||
app.widgets().stack.visible_child_name().as_deref(),
|
||||
Some(tab.stack_name()),
|
||||
"{tab:?} did not open in Chinese"
|
||||
);
|
||||
}
|
||||
|
||||
// A UI sub-tab, as its switcher reports.
|
||||
let inner = visible_page_descendants(app)
|
||||
.into_iter()
|
||||
.find_map(|widget| {
|
||||
widget.downcast::<gtk::Stack>().ok().filter(|stack| {
|
||||
stack
|
||||
.child_by_name(UiTabId::StatusBar.stack_name())
|
||||
.is_some()
|
||||
})
|
||||
})
|
||||
.expect("the UI sub-page stack");
|
||||
inner.set_visible_child_name(UiTabId::StatusBar.stack_name());
|
||||
settle();
|
||||
assert_eq!(app.model().active_ui_tab, UiTabId::StatusBar);
|
||||
}
|
||||
|
||||
fn repeated_switches_leave_nothing_behind(
|
||||
app: &Controller<ConfiguratorApp>,
|
||||
pages: u32,
|
||||
bindings: usize,
|
||||
) {
|
||||
for language in [
|
||||
LanguageOption::English,
|
||||
LanguageOption::SimplifiedChinese,
|
||||
LanguageOption::English,
|
||||
LanguageOption::SimplifiedChinese,
|
||||
] {
|
||||
app.emit(Message::UiLanguageChanged(language));
|
||||
settle();
|
||||
assert_eq!(app.widgets().stack.observe_children().n_items(), pages);
|
||||
assert_eq!(app.widgets().bindings.len(), bindings);
|
||||
}
|
||||
}
|
||||
|
||||
fn a_load_that_changes_the_language_keeps_its_resets(app: &Controller<ConfiguratorApp>) {
|
||||
// Back to what the file says, so the reload is a real language change.
|
||||
app.emit(Message::UiLanguageChanged(LanguageOption::English));
|
||||
app.emit(Message::TabSelected(TabId::Keybindings));
|
||||
app.emit(Message::ShortcutRecordingStarted(KeybindingField::Undo));
|
||||
settle();
|
||||
write_config(
|
||||
Path::new(&std::env::var_os("XDG_CONFIG_HOME").expect("child config home")),
|
||||
"[ui]\nlanguage = \"zh-CN\"\n",
|
||||
);
|
||||
|
||||
// The open recorder is unsaved work, so Reload asks first; discarding
|
||||
// is the user's choice to drop the edit.
|
||||
app.emit(Message::ReloadRequested);
|
||||
settle();
|
||||
app.emit(Message::LeaveDiscardRequested);
|
||||
pump_until("the reload", || {
|
||||
!app.model().document.is_loading()
|
||||
&& wayscriber::i18n::language() == Language::SimplifiedChinese
|
||||
});
|
||||
settle();
|
||||
|
||||
assert!(
|
||||
app.model().shortcuts.recorder().is_none(),
|
||||
"a load clears edits"
|
||||
);
|
||||
assert_eq!(sidebar_label(app, TabId::Drawing), "绘图");
|
||||
// The pages are insensitive while a reload runs, so focus has already
|
||||
// left them for the chrome by the time the rebuild happens; the rebuild
|
||||
// leaves it there, on a control that still exists.
|
||||
let focused = focus(app).expect("focus is never lost");
|
||||
assert!(focused.is_visible(), "{focused:?}");
|
||||
assert_eq!(
|
||||
focused.root().map(|root| root.upcast::<gtk::Widget>()),
|
||||
Some(app.widget().clone().upcast::<gtk::Widget>())
|
||||
);
|
||||
}
|
||||
|
||||
fn sidebar_label(app: &Controller<ConfiguratorApp>, tab: TabId) -> String {
|
||||
app.widgets()
|
||||
.chrome
|
||||
.sidebar_labels()
|
||||
.iter()
|
||||
.find(|(label_tab, _)| *label_tab == tab)
|
||||
.map(|(_, label)| label.label().to_string())
|
||||
.expect("sidebar label")
|
||||
}
|
||||
|
||||
fn focused_editable(app: &Controller<ConfiguratorApp>) -> gtk::Editable {
|
||||
focus(app)
|
||||
.and_then(|widget| widget.downcast::<gtk::Editable>().ok())
|
||||
.expect("the caret is in an editable")
|
||||
}
|
||||
|
||||
/// The focused entry after a rebuild: the same text, the same selected
|
||||
/// range, and the caret at the same end of it.
|
||||
fn assert_caret(
|
||||
app: &Controller<ConfiguratorApp>,
|
||||
selection: (i32, i32),
|
||||
position: i32,
|
||||
what: &str,
|
||||
) {
|
||||
let editable = focused_editable(app);
|
||||
assert_eq!(editable.text(), "12.5", "{what}");
|
||||
assert_eq!(editable.selection_bounds(), Some(selection), "{what}");
|
||||
assert_eq!(editable.position(), position, "{what} keeps its active end");
|
||||
}
|
||||
|
||||
fn focus(app: &Controller<ConfiguratorApp>) -> Option<gtk::Widget> {
|
||||
gtk::prelude::GtkWindowExt::focus(app.widget())
|
||||
}
|
||||
|
||||
fn visible_page_descendants(app: &Controller<ConfiguratorApp>) -> Vec<gtk::Widget> {
|
||||
let page = app.widgets().stack.visible_child().expect("visible page");
|
||||
let mut found = Vec::new();
|
||||
let mut pending = vec![page];
|
||||
while let Some(widget) = pending.pop() {
|
||||
pending.extend(std::iter::successors(
|
||||
widget.first_child(),
|
||||
gtk::Widget::next_sibling,
|
||||
));
|
||||
found.push(widget);
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn visible_labels(app: &Controller<ConfiguratorApp>) -> Vec<String> {
|
||||
visible_page_descendants(app)
|
||||
.into_iter()
|
||||
.filter(|widget| widget.is_visible())
|
||||
.filter_map(|widget| widget.downcast::<gtk::Label>().ok())
|
||||
.map(|label| label.label().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn language_row(app: &Controller<ConfiguratorApp>) -> Option<adw::ComboRow> {
|
||||
let title = wayscriber::i18n::tr("Language");
|
||||
visible_page_descendants(app)
|
||||
.into_iter()
|
||||
.filter_map(|widget| widget.downcast::<adw::ComboRow>().ok())
|
||||
.find(|row| row.title() == title)
|
||||
}
|
||||
|
||||
fn scrollable(app: &Controller<ConfiguratorApp>) -> Option<gtk::ScrolledWindow> {
|
||||
visible_page_descendants(app)
|
||||
.into_iter()
|
||||
.filter_map(|widget| widget.downcast::<gtk::ScrolledWindow>().ok())
|
||||
.find(|scrolled| {
|
||||
let adjustment = scrolled.vadjustment();
|
||||
adjustment.upper() - adjustment.page_size() > 1.0
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the main loop until `done`, failing after a generous timeout.
|
||||
fn pump_until(what: &str, mut done: impl FnMut() -> bool) {
|
||||
let context = gtk::glib::MainContext::default();
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while !done() {
|
||||
assert!(Instant::now() < deadline, "timed out waiting for {what}");
|
||||
while context.pending() {
|
||||
context.iteration(false);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets queued messages, refreshes and a few frames run.
|
||||
fn settle() {
|
||||
let context = gtk::glib::MainContext::default();
|
||||
let until = Instant::now() + Duration::from_millis(300);
|
||||
while Instant::now() < until {
|
||||
while context.pending() {
|
||||
context.iteration(false);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,32 @@
|
||||
use relm4::{adw, gtk};
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::super::pages;
|
||||
use super::super::state::{ConfiguratorApp, StatusMessage};
|
||||
use super::AppWidgets;
|
||||
use super::{AppWidgets, rebuild};
|
||||
|
||||
pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) {
|
||||
pub(super) fn refresh(
|
||||
app: &ConfiguratorApp,
|
||||
widgets: &mut AppWidgets,
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
) {
|
||||
// First, so the refresh writes into pages in the current language.
|
||||
let rebuilt = rebuild::rebuild_if_language_changed(widgets, sender);
|
||||
|
||||
write_widgets(app, widgets);
|
||||
|
||||
if let Some(state) = rebuilt {
|
||||
rebuild::restore(widgets, state);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_widgets(app: &ConfiguratorApp, widgets: &mut AppWidgets) {
|
||||
// Header chrome.
|
||||
let subtitle = if app.has_unsaved_work() {
|
||||
"Unsaved changes"
|
||||
tr("Unsaved changes")
|
||||
} else {
|
||||
""
|
||||
};
|
||||
@@ -80,6 +97,7 @@ pub(super) fn refresh(app: &ConfiguratorApp, widgets: &mut AppWidgets) {
|
||||
StatusMessage::Success(text) => (text.as_str(), Some("success")),
|
||||
StatusMessage::Warning(text) => (text.as_str(), Some("warning")),
|
||||
StatusMessage::Error(text) => (text.as_str(), Some("error")),
|
||||
StatusMessage::Progress(notice) => (notice.message(), None),
|
||||
StatusMessage::Confirmation(prompt) => (prompt.message(), Some("warning")),
|
||||
};
|
||||
if widgets.status_label.text() != status_text {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::trf;
|
||||
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
@@ -32,11 +34,9 @@ pub(super) fn run_command_checked(program: &str, args: &[&str]) -> Result<Comman
|
||||
|
||||
pub(super) fn run_command(program: &str, args: &[&str]) -> Result<CommandCapture, String> {
|
||||
let output = Command::new(program).args(args).output().map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to execute `{}` with args [{}]: {}",
|
||||
program,
|
||||
args.join(" "),
|
||||
err
|
||||
&[&program, &args.join(" "), &err],
|
||||
)
|
||||
})?;
|
||||
Ok(CommandCapture {
|
||||
@@ -47,11 +47,13 @@ pub(super) fn run_command(program: &str, args: &[&str]) -> Result<CommandCapture
|
||||
}
|
||||
|
||||
fn format_command_failure(program: &str, args: &[&str], capture: &CommandCapture) -> String {
|
||||
format!(
|
||||
trf(
|
||||
"`{}` failed with args [{}]\nstdout: {}\nstderr: {}",
|
||||
program,
|
||||
args.join(" "),
|
||||
capture.stdout.trim(),
|
||||
capture.stderr.trim()
|
||||
&[
|
||||
&program,
|
||||
&args.join(" "),
|
||||
&capture.stdout.trim(),
|
||||
&capture.stderr.trim(),
|
||||
],
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
@@ -37,40 +39,45 @@ pub(super) struct HyprlandLightControlsInstallResult {
|
||||
}
|
||||
|
||||
impl HyprlandLightControlsInstallResult {
|
||||
/// The controls were written, but reloading Hyprland to apply them failed.
|
||||
pub(super) fn reload_failed(&self) -> bool {
|
||||
self.reload_attempted && !self.reload_succeeded
|
||||
}
|
||||
|
||||
pub(super) fn summary(&self) -> String {
|
||||
let mut parts = vec![format!(
|
||||
let mut parts = vec![trf(
|
||||
"Wrote Hyprland light controls to {}",
|
||||
self.include_path.display()
|
||||
&[&self.include_path.display()],
|
||||
)];
|
||||
parts.push("clears the default light keys before rebinding them".to_string());
|
||||
parts.push(tr("clears the default light keys before rebinding them").to_string());
|
||||
|
||||
if self.source_configured {
|
||||
if self.source_updated {
|
||||
parts.push(format!(
|
||||
parts.push(trf(
|
||||
"added source line to {}",
|
||||
self.main_config_path.display()
|
||||
&[&self.main_config_path.display()],
|
||||
));
|
||||
} else {
|
||||
parts.push(format!(
|
||||
parts.push(trf(
|
||||
"source line already present in {}",
|
||||
self.main_config_path.display()
|
||||
&[&self.main_config_path.display()],
|
||||
));
|
||||
}
|
||||
} else {
|
||||
parts.push(format!(
|
||||
parts.push(trf(
|
||||
"add `{}` to your Hyprland config to enable it",
|
||||
self.source_line
|
||||
&[&self.source_line],
|
||||
));
|
||||
}
|
||||
|
||||
if self.reload_attempted {
|
||||
if self.reload_succeeded {
|
||||
parts.push("reloaded Hyprland".to_string());
|
||||
parts.push(tr("reloaded Hyprland").to_string());
|
||||
} else if let Some(error) = self.reload_error.as_deref() {
|
||||
parts.push(format!("hyprctl reload failed: {error}"));
|
||||
parts.push(trf("hyprctl reload failed: {}", &[&error]));
|
||||
}
|
||||
} else if self.source_configured {
|
||||
parts.push("run `hyprctl reload` to apply it".to_string());
|
||||
parts.push(tr("run `hyprctl reload` to apply it").to_string());
|
||||
}
|
||||
|
||||
parts.join("; ")
|
||||
@@ -105,7 +112,7 @@ fn read_light_controls_status_from_config_root(config_root: &Path) -> HyprlandLi
|
||||
|
||||
pub(super) fn install_light_controls() -> Result<HyprlandLightControlsInstallResult, String> {
|
||||
let config_root = wayscriber::paths::config_dir().ok_or_else(|| {
|
||||
"Cannot resolve config directory; failed to determine Hyprland config path.".to_string()
|
||||
tr("Cannot resolve config directory; failed to determine Hyprland config path.").to_string()
|
||||
})?;
|
||||
let binary_path = resolve_wayscriber_binary_path()?;
|
||||
let mut result = write_light_controls(&config_root, &binary_path)?;
|
||||
@@ -117,10 +124,9 @@ pub(super) fn install_light_controls() -> Result<HyprlandLightControlsInstallRes
|
||||
result.reload_succeeded = true;
|
||||
}
|
||||
Ok(capture) => {
|
||||
result.reload_error = Some(format!(
|
||||
result.reload_error = Some(trf(
|
||||
"stdout: {}; stderr: {}",
|
||||
capture.stdout.trim(),
|
||||
capture.stderr.trim()
|
||||
&[&capture.stdout.trim(), &capture.stderr.trim()],
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -138,10 +144,9 @@ fn write_light_controls(
|
||||
) -> Result<HyprlandLightControlsInstallResult, String> {
|
||||
let hyprland_dir = config_root.join(HYPRLAND_DIR);
|
||||
fs::create_dir_all(&hyprland_dir).map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to create Hyprland config directory {}: {}",
|
||||
hyprland_dir.display(),
|
||||
err
|
||||
&[&hyprland_dir.display(), &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -152,10 +157,9 @@ fn write_light_controls(
|
||||
AtomicWriteOptions::user_config_file(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to write Hyprland light controls {}: {}",
|
||||
include_path.display(),
|
||||
err
|
||||
&[&include_path.display(), &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -176,20 +180,18 @@ fn write_light_controls(
|
||||
AtomicWriteOptions::user_config_file(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to update Hyprland config {}: {}",
|
||||
main_config_path.display(),
|
||||
err
|
||||
&[&main_config_path.display(), &err],
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(format!(
|
||||
return Err(trf(
|
||||
"Failed to read Hyprland config {}: {}",
|
||||
main_config_path.display(),
|
||||
err
|
||||
&[&main_config_path.display(), &err],
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,3 +139,41 @@ fn write_light_controls_handles_missing_main_config() {
|
||||
assert!(!result.source_configured);
|
||||
assert!(!result.source_updated);
|
||||
}
|
||||
|
||||
fn install_result(
|
||||
reload_attempted: bool,
|
||||
reload_error: Option<&str>,
|
||||
) -> HyprlandLightControlsInstallResult {
|
||||
HyprlandLightControlsInstallResult {
|
||||
include_path: PathBuf::from("/tmp/hypr/wayscriber-light.conf"),
|
||||
main_config_path: PathBuf::from("/tmp/hypr/hyprland.conf"),
|
||||
source_line: "source = /tmp/hypr/wayscriber-light.conf".to_string(),
|
||||
source_configured: true,
|
||||
source_updated: false,
|
||||
reload_attempted,
|
||||
reload_succeeded: reload_attempted && reload_error.is_none(),
|
||||
reload_error: reload_error.map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
/// A reload that fails after the controls were written is reported as a
|
||||
/// failure beside the message, so the panel does not read the message to
|
||||
/// decide how to style it.
|
||||
#[test]
|
||||
fn a_failed_reload_after_writing_is_a_partial_failure() {
|
||||
let failed = super::super::ActionReport::light_controls(&install_result(
|
||||
true,
|
||||
Some("compositor unavailable"),
|
||||
));
|
||||
let reloaded = super::super::ActionReport::light_controls(&install_result(true, None));
|
||||
let not_attempted = super::super::ActionReport::light_controls(&install_result(false, None));
|
||||
|
||||
assert!(failed.partial_failure);
|
||||
assert!(
|
||||
failed.message.contains("compositor unavailable"),
|
||||
"{}",
|
||||
failed.message
|
||||
);
|
||||
assert!(!reloaded.partial_failure);
|
||||
assert!(!not_attempted.partial_failure);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ mod hyprland;
|
||||
mod service;
|
||||
mod shortcut;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use crate::models::{
|
||||
DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment,
|
||||
LightShortcutApplyCapability, ShortcutBackend,
|
||||
@@ -35,19 +37,50 @@ pub(super) async fn perform_daemon_action(
|
||||
shortcut_input: String,
|
||||
) -> Result<DaemonActionResult, String> {
|
||||
run_blocking(BlockingJobKind::DaemonAction, move || {
|
||||
let message = perform_daemon_action_sync(action, shortcut_input.trim())?;
|
||||
let report = perform_daemon_action_sync(action, shortcut_input.trim())?;
|
||||
let status = load_daemon_runtime_status_sync()?;
|
||||
Ok(DaemonActionResult { status, message })
|
||||
Ok(DaemonActionResult {
|
||||
status,
|
||||
message: report.message,
|
||||
partial_failure: report.partial_failure,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// What a finished setup action reports. Whether a step failed travels next
|
||||
/// to the message, never inside it: the panel styles the outcome from this
|
||||
/// flag, and the message is translated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct ActionReport {
|
||||
message: String,
|
||||
/// The action did its main work, but a later step failed; the message
|
||||
/// says which.
|
||||
partial_failure: bool,
|
||||
}
|
||||
|
||||
impl ActionReport {
|
||||
fn complete(message: String) -> Self {
|
||||
Self {
|
||||
message,
|
||||
partial_failure: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn light_controls(result: &hyprland::HyprlandLightControlsInstallResult) -> Self {
|
||||
Self {
|
||||
message: result.summary(),
|
||||
partial_failure: result.reload_failed(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn perform_daemon_action_sync(
|
||||
action: DaemonAction,
|
||||
shortcut_input: &str,
|
||||
) -> Result<String, String> {
|
||||
match action {
|
||||
DaemonAction::RefreshStatus => Ok("Daemon status refreshed.".to_string()),
|
||||
) -> Result<ActionReport, String> {
|
||||
let message = match action {
|
||||
DaemonAction::RefreshStatus => tr("Daemon status refreshed.").to_string(),
|
||||
DaemonAction::InstallOrUpdateService => {
|
||||
let desktop = DesktopEnvironment::detect_current();
|
||||
let service_path = install_or_update_user_service()?;
|
||||
@@ -58,38 +91,42 @@ fn perform_daemon_action_sync(
|
||||
run_systemctl_user(&["restart", SERVICE_NAME])?;
|
||||
}
|
||||
}
|
||||
Ok(format!(
|
||||
trf(
|
||||
"Installed/updated user service at {}{}",
|
||||
service_path.display(),
|
||||
if removed_dropin {
|
||||
"; removed stale GNOME portal shortcut drop-in"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
))
|
||||
&[
|
||||
&service_path.display(),
|
||||
&if removed_dropin {
|
||||
tr("; removed stale GNOME portal shortcut drop-in")
|
||||
} else {
|
||||
""
|
||||
},
|
||||
],
|
||||
)
|
||||
}
|
||||
DaemonAction::EnableAndStartService => {
|
||||
require_systemctl_available()?;
|
||||
run_systemctl_user(&["daemon-reload"])?;
|
||||
run_systemctl_user(&["enable", "--now", SERVICE_NAME])?;
|
||||
Ok("Enabled and started wayscriber.service.".to_string())
|
||||
tr("Enabled and started wayscriber.service.").to_string()
|
||||
}
|
||||
DaemonAction::RestartService => {
|
||||
require_systemctl_available()?;
|
||||
run_systemctl_user(&["restart", SERVICE_NAME])?;
|
||||
Ok("Restarted wayscriber.service.".to_string())
|
||||
tr("Restarted wayscriber.service.").to_string()
|
||||
}
|
||||
DaemonAction::StopAndDisableService => {
|
||||
require_systemctl_available()?;
|
||||
run_systemctl_user(&["disable", "--now", SERVICE_NAME])?;
|
||||
Ok("Stopped and disabled wayscriber.service.".to_string())
|
||||
tr("Stopped and disabled wayscriber.service.").to_string()
|
||||
}
|
||||
DaemonAction::ApplyShortcut => apply_shortcut(shortcut_input),
|
||||
DaemonAction::ApplyShortcut => apply_shortcut(shortcut_input)?,
|
||||
DaemonAction::ApplyLightControls => {
|
||||
let result = install_hyprland_light_controls()?;
|
||||
Ok(result.summary())
|
||||
return Ok(ActionReport::light_controls(&result));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ActionReport::complete(message))
|
||||
}
|
||||
|
||||
pub(super) fn load_daemon_runtime_status_sync() -> Result<DaemonRuntimeStatus, String> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -75,7 +77,7 @@ pub(super) fn require_systemctl_available() -> Result<(), String> {
|
||||
if command_available("systemctl") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("systemctl is not available in {PATH_ENV}."))
|
||||
Err(trf("systemctl is not available in {}.", &[&PATH_ENV]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +101,16 @@ pub(super) fn install_or_update_user_service() -> Result<PathBuf, String> {
|
||||
let binary_path = resolve_wayscriber_binary_path()?;
|
||||
|
||||
let service_path = user_service_unit_path().ok_or_else(|| {
|
||||
"Cannot resolve home directory; failed to determine user systemd service path.".to_string()
|
||||
tr("Cannot resolve home directory; failed to determine user systemd service path.")
|
||||
.to_string()
|
||||
})?;
|
||||
let service_dir = service_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Invalid user service path".to_string())?;
|
||||
.ok_or_else(|| tr("Invalid user service path").to_string())?;
|
||||
fs::create_dir_all(service_dir).map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to create user service directory {}: {}",
|
||||
service_dir.display(),
|
||||
err
|
||||
&[&service_dir.display(), &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -119,10 +121,9 @@ pub(super) fn install_or_update_user_service() -> Result<PathBuf, String> {
|
||||
AtomicWriteOptions::user_config_file(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to write user service file {}: {}",
|
||||
service_path.display(),
|
||||
err
|
||||
&[&service_path.display(), &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -245,21 +246,21 @@ fn query_wayscriber_runtime_capabilities(
|
||||
.arg(RUNTIME_CAPABILITIES_FLAG)
|
||||
.output()
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to execute `{}` with args [{}]: {}",
|
||||
binary_path.display(),
|
||||
RUNTIME_CAPABILITIES_FLAG,
|
||||
err
|
||||
&[&binary_path.display(), &RUNTIME_CAPABILITIES_FLAG, &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
return Err(trf(
|
||||
"`{}` failed with args [{}]\nstdout: {}\nstderr: {}",
|
||||
binary_path.display(),
|
||||
RUNTIME_CAPABILITIES_FLAG,
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
&[
|
||||
&binary_path.display(),
|
||||
&RUNTIME_CAPABILITIES_FLAG,
|
||||
&String::from_utf8_lossy(&output.stdout).trim(),
|
||||
&String::from_utf8_lossy(&output.stderr).trim(),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -283,10 +284,9 @@ pub(super) fn remove_portal_shortcut_dropin() -> Result<bool, String> {
|
||||
return Ok(false);
|
||||
}
|
||||
fs::remove_file(&path).map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to remove portal shortcut drop-in {}: {}",
|
||||
path.display(),
|
||||
err
|
||||
&[&path.display(), &err],
|
||||
)
|
||||
})?;
|
||||
Ok(true)
|
||||
@@ -318,8 +318,9 @@ pub(super) fn resolve_wayscriber_binary_path() -> Result<PathBuf, String> {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Unable to locate `wayscriber` binary. Set {BIN_ENV} or install `wayscriber` in {PATH_ENV}."
|
||||
Err(trf(
|
||||
"Unable to locate `wayscriber` binary. Set {} or install `wayscriber` in {}.",
|
||||
&[&BIN_ENV, &PATH_ENV],
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::fs;
|
||||
|
||||
use crate::models::{DesktopEnvironment, ShortcutApplyCapability, ShortcutBackend};
|
||||
@@ -59,7 +61,7 @@ pub(super) fn apply_shortcut(shortcut_input: &str) -> Result<String, String> {
|
||||
run_systemctl_user(&["restart", "wayscriber.service"])?;
|
||||
}
|
||||
}
|
||||
Ok(format!("Configured GNOME shortcut: {normalized}"))
|
||||
Ok(trf("Configured GNOME shortcut: {}", &[&normalized]))
|
||||
}
|
||||
ShortcutApplyCapability::PortalServiceDropIn => {
|
||||
require_systemctl_available()?;
|
||||
@@ -69,13 +71,13 @@ pub(super) fn apply_shortcut(shortcut_input: &str) -> Result<String, String> {
|
||||
if query_service_active() {
|
||||
run_systemctl_user(&["restart", "wayscriber.service"])?;
|
||||
}
|
||||
Ok(format!(
|
||||
"Configured portal shortcut: {normalized} (drop-in: {})",
|
||||
dropin_path.display()
|
||||
Ok(trf(
|
||||
"Configured portal shortcut: {} (drop-in: {})",
|
||||
&[&normalized, &dropin_path.display()],
|
||||
))
|
||||
}
|
||||
ShortcutApplyCapability::Manual => Err(
|
||||
"Automatic shortcut setup is not available in this desktop session; bind `wayscriber --daemon-toggle` manually."
|
||||
tr("Automatic shortcut setup is not available in this desktop session; bind `wayscriber --daemon-toggle` manually.")
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
@@ -165,7 +167,7 @@ fn require_gsettings_available() -> Result<(), String> {
|
||||
if command_available("gsettings") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("gsettings is not available in {PATH_ENV}."))
|
||||
Err(trf("gsettings is not available in {}.", &[&PATH_ENV]))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,16 +178,15 @@ fn run_gsettings_command(args: &[&str]) -> Result<(), String> {
|
||||
|
||||
fn write_portal_shortcut_dropin(shortcut: &str) -> Result<std::path::PathBuf, String> {
|
||||
let dropin_path = portal_shortcut_dropin_path().ok_or_else(|| {
|
||||
"Cannot resolve home directory; failed to determine systemd drop-in path.".to_string()
|
||||
tr("Cannot resolve home directory; failed to determine systemd drop-in path.").to_string()
|
||||
})?;
|
||||
let dropin_dir = dropin_path
|
||||
.parent()
|
||||
.ok_or_else(|| "Invalid drop-in path".to_string())?;
|
||||
.ok_or_else(|| tr("Invalid drop-in path").to_string())?;
|
||||
fs::create_dir_all(dropin_dir).map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to create service drop-in directory {}: {}",
|
||||
dropin_dir.display(),
|
||||
err
|
||||
&[&dropin_dir.display(), &err],
|
||||
)
|
||||
})?;
|
||||
|
||||
@@ -198,10 +199,9 @@ fn write_portal_shortcut_dropin(shortcut: &str) -> Result<std::path::PathBuf, St
|
||||
AtomicWriteOptions::user_config_file(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
trf(
|
||||
"Failed to write portal shortcut drop-in {}: {}",
|
||||
dropin_path.display(),
|
||||
err
|
||||
&[&dropin_path.display(), &err],
|
||||
)
|
||||
})?;
|
||||
Ok(dropin_path)
|
||||
@@ -282,7 +282,7 @@ fn normalize_shortcut_for_portal(input: &str) -> Result<String, String> {
|
||||
fn normalize_shortcut(input: &str, gnome_style: bool) -> Result<String, String> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Shortcut cannot be empty.".to_string());
|
||||
return Err(tr("Shortcut cannot be empty.").to_string());
|
||||
}
|
||||
if trimmed.contains('<') && trimmed.contains('>') {
|
||||
return Ok(trimmed.to_string());
|
||||
@@ -294,7 +294,7 @@ fn normalize_shortcut(input: &str, gnome_style: bool) -> Result<String, String>
|
||||
.filter(|segment| !segment.is_empty())
|
||||
.collect();
|
||||
if parts.is_empty() {
|
||||
return Err("Shortcut cannot be empty.".to_string());
|
||||
return Err(tr("Shortcut cannot be empty.").to_string());
|
||||
}
|
||||
let key = normalize_key_name(parts[parts.len() - 1])?;
|
||||
let modifiers = &parts[..parts.len() - 1];
|
||||
@@ -302,9 +302,9 @@ fn normalize_shortcut(input: &str, gnome_style: bool) -> Result<String, String>
|
||||
let mut rendered_modifiers: Vec<&'static str> = Vec::new();
|
||||
for modifier in modifiers {
|
||||
let normalized = normalize_modifier(modifier, gnome_style).ok_or_else(|| {
|
||||
format!(
|
||||
trf(
|
||||
"Unsupported modifier `{}`. Supported: Ctrl, Shift, Alt, Super/Meta.",
|
||||
modifier
|
||||
&[&modifier],
|
||||
)
|
||||
})?;
|
||||
if !rendered_modifiers.contains(&normalized) {
|
||||
@@ -333,7 +333,7 @@ fn normalize_modifier(modifier: &str, gnome_style: bool) -> Option<&'static str>
|
||||
fn normalize_key_name(key: &str) -> Result<String, String> {
|
||||
let trimmed = key.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Err("Shortcut key is empty.".to_string());
|
||||
return Err(tr("Shortcut key is empty.").to_string());
|
||||
}
|
||||
let upper = trimmed.to_ascii_uppercase();
|
||||
if upper.starts_with('F')
|
||||
|
||||
@@ -1,18 +1,86 @@
|
||||
//! Background setup workflow; callback identity and feedback policy live together.
|
||||
use super::effects::Effect;
|
||||
use crate::models::{DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DesktopEnvironment};
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
#[derive(Debug)]
|
||||
/// What the background setup panel reports, kept as what happened rather than
|
||||
/// as text: it renders in the current UI language, and its tone comes from the
|
||||
/// outcome, never from reading the words.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum DaemonFeedback {
|
||||
Status(String),
|
||||
Action(String),
|
||||
/// Reported by the panel itself; the next status load replaces it.
|
||||
Status(StatusFeedback),
|
||||
/// The outcome of something the user asked for; status loads keep it.
|
||||
Action(ActionFeedback),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum StatusFeedback {
|
||||
Detecting,
|
||||
Loaded,
|
||||
LoadFailed(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) enum ActionFeedback {
|
||||
Pending(DaemonAction),
|
||||
/// The setup step's own report. It was written in the language active
|
||||
/// when the step ran, like any other event message.
|
||||
Completed(String),
|
||||
/// A report whose main work succeeded but whose follow-up step failed,
|
||||
/// as the step itself said.
|
||||
PartlyFailed(String),
|
||||
Failed(String),
|
||||
/// A status refresh failed after `previous`, which it keeps visible.
|
||||
RefreshFailed {
|
||||
previous: Option<Box<DaemonFeedback>>,
|
||||
error: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl DaemonFeedback {
|
||||
pub(crate) fn text(&self) -> &str {
|
||||
pub(crate) fn text(&self) -> String {
|
||||
match self {
|
||||
Self::Status(text) | Self::Action(text) => text,
|
||||
Self::Status(StatusFeedback::Detecting) => {
|
||||
tr("Detecting background mode setup status...").to_string()
|
||||
}
|
||||
Self::Status(StatusFeedback::Loaded) => {
|
||||
tr("Background mode status loaded.").to_string()
|
||||
}
|
||||
Self::Status(StatusFeedback::LoadFailed(error)) => {
|
||||
trf("Failed to load background setup status: {}", &[error])
|
||||
}
|
||||
Self::Action(ActionFeedback::Pending(action)) => {
|
||||
action_pending_message(*action).to_string()
|
||||
}
|
||||
Self::Action(
|
||||
ActionFeedback::Completed(message) | ActionFeedback::PartlyFailed(message),
|
||||
) => message.clone(),
|
||||
Self::Action(ActionFeedback::Failed(error)) => {
|
||||
trf("Background setup action failed: {}", &[error])
|
||||
}
|
||||
Self::Action(ActionFeedback::RefreshFailed { previous, error }) => {
|
||||
let previous = previous
|
||||
.as_deref()
|
||||
.map(DaemonFeedback::text)
|
||||
.unwrap_or_else(|| tr("Background setup action failed.").to_string());
|
||||
trf("{}\nStatus refresh failed: {}", &[&previous, error])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this reports a failure, for the panel's error styling.
|
||||
pub(crate) fn is_failure(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Status(StatusFeedback::LoadFailed(_))
|
||||
| Self::Action(
|
||||
ActionFeedback::PartlyFailed(_)
|
||||
| ActionFeedback::Failed(_)
|
||||
| ActionFeedback::RefreshFailed { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -33,9 +101,7 @@ impl DaemonWorkflow {
|
||||
Self {
|
||||
status: None,
|
||||
shortcut_input: desktop.default_shortcut_input().to_string(),
|
||||
feedback: Some(DaemonFeedback::Status(
|
||||
"Detecting background mode setup status...".to_string(),
|
||||
)),
|
||||
feedback: Some(DaemonFeedback::Status(StatusFeedback::Detecting)),
|
||||
active_action: None,
|
||||
next_status_request_id: 2,
|
||||
latest_status_request_id: 1,
|
||||
@@ -65,25 +131,18 @@ impl DaemonWorkflow {
|
||||
self.is_busy(),
|
||||
self.feedback.as_ref(),
|
||||
) {
|
||||
self.feedback = Some(DaemonFeedback::Status(
|
||||
"Background mode status loaded.".to_string(),
|
||||
));
|
||||
self.feedback = Some(DaemonFeedback::Status(StatusFeedback::Loaded));
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
Err(error) => {
|
||||
if preserve_feedback && !self.is_busy() {
|
||||
let previous_feedback = self
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(DaemonFeedback::text)
|
||||
.unwrap_or("Background setup action failed.");
|
||||
self.feedback = Some(DaemonFeedback::Action(format!(
|
||||
"{previous_feedback}\nStatus refresh failed: {err}"
|
||||
)));
|
||||
let previous = self.feedback.take().map(Box::new);
|
||||
self.feedback = Some(DaemonFeedback::Action(ActionFeedback::RefreshFailed {
|
||||
previous,
|
||||
error,
|
||||
}));
|
||||
} else if !self.is_busy() {
|
||||
self.feedback = Some(DaemonFeedback::Status(format!(
|
||||
"Failed to load background setup status: {err}"
|
||||
)));
|
||||
self.feedback = Some(DaemonFeedback::Status(StatusFeedback::LoadFailed(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,7 +160,7 @@ impl DaemonWorkflow {
|
||||
}
|
||||
self.invalidate_pending_daemon_status_requests();
|
||||
self.active_action = Some(action);
|
||||
self.feedback = Some(DaemonFeedback::Action(action_pending_message(action)));
|
||||
self.feedback = Some(DaemonFeedback::Action(ActionFeedback::Pending(action)));
|
||||
let shortcut_input = self.shortcut_input.clone();
|
||||
vec![Effect::PerformDaemonAction {
|
||||
action,
|
||||
@@ -117,13 +176,16 @@ impl DaemonWorkflow {
|
||||
match result {
|
||||
Ok(output) => {
|
||||
self.apply_daemon_status(output.status);
|
||||
self.feedback = Some(DaemonFeedback::Action(output.message));
|
||||
let report = if output.partial_failure {
|
||||
ActionFeedback::PartlyFailed(output.message)
|
||||
} else {
|
||||
ActionFeedback::Completed(output.message)
|
||||
};
|
||||
self.feedback = Some(DaemonFeedback::Action(report));
|
||||
Vec::new()
|
||||
}
|
||||
Err(err) => {
|
||||
self.feedback = Some(DaemonFeedback::Action(format!(
|
||||
"Background setup action failed: {err}"
|
||||
)));
|
||||
Err(error) => {
|
||||
self.feedback = Some(DaemonFeedback::Action(ActionFeedback::Failed(error)));
|
||||
self.schedule_daemon_status_reload(true)
|
||||
}
|
||||
}
|
||||
@@ -164,40 +226,34 @@ fn should_update_feedback_after_status_load(
|
||||
!preserve_feedback && !busy && matches!(feedback, None | Some(DaemonFeedback::Status(_)))
|
||||
}
|
||||
|
||||
fn action_pending_message(action: DaemonAction) -> String {
|
||||
fn action_pending_message(action: DaemonAction) -> &'static str {
|
||||
match action {
|
||||
DaemonAction::RefreshStatus => "Refreshing background setup status...".to_string(),
|
||||
DaemonAction::InstallOrUpdateService => {
|
||||
"Installing/updating background service...".to_string()
|
||||
}
|
||||
DaemonAction::EnableAndStartService => {
|
||||
"Enabling and starting background mode...".to_string()
|
||||
}
|
||||
DaemonAction::RestartService => "Restarting background service...".to_string(),
|
||||
DaemonAction::StopAndDisableService => {
|
||||
"Stopping and disabling background mode...".to_string()
|
||||
}
|
||||
DaemonAction::ApplyShortcut => "Applying desktop shortcut setup...".to_string(),
|
||||
DaemonAction::ApplyLightControls => {
|
||||
"Applying light passthrough controls setup...".to_string()
|
||||
}
|
||||
DaemonAction::RefreshStatus => tr("Refreshing background setup status..."),
|
||||
DaemonAction::InstallOrUpdateService => tr("Installing/updating background service..."),
|
||||
DaemonAction::EnableAndStartService => tr("Enabling and starting background mode..."),
|
||||
DaemonAction::RestartService => tr("Restarting background service..."),
|
||||
DaemonAction::StopAndDisableService => tr("Stopping and disabling background mode..."),
|
||||
DaemonAction::ApplyShortcut => tr("Applying desktop shortcut setup..."),
|
||||
DaemonAction::ApplyLightControls => tr("Applying light passthrough controls setup..."),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use wayscriber::domain::Language;
|
||||
|
||||
#[test]
|
||||
fn refresh_policy_depends_on_feedback_kind_not_its_wording() {
|
||||
let translated_status = DaemonFeedback::Status("Status geladen".into());
|
||||
let status = DaemonFeedback::Status(StatusFeedback::Loaded);
|
||||
assert!(should_update_feedback_after_status_load(
|
||||
false,
|
||||
false,
|
||||
Some(&translated_status)
|
||||
Some(&status)
|
||||
));
|
||||
let action_using_status_words = DaemonFeedback::Action(ActionFeedback::Completed(
|
||||
"Background mode status loaded.".into(),
|
||||
));
|
||||
let action_using_status_words =
|
||||
DaemonFeedback::Action("Background mode status loaded.".into());
|
||||
assert!(!should_update_feedback_after_status_load(
|
||||
false,
|
||||
false,
|
||||
@@ -206,12 +262,73 @@ mod tests {
|
||||
assert!(!should_update_feedback_after_status_load(
|
||||
true,
|
||||
false,
|
||||
Some(&translated_status)
|
||||
Some(&status)
|
||||
));
|
||||
assert!(!should_update_feedback_after_status_load(
|
||||
false,
|
||||
true,
|
||||
Some(&translated_status)
|
||||
Some(&status)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_are_typed_whatever_the_words_say() {
|
||||
let failures = [
|
||||
DaemonFeedback::Status(StatusFeedback::LoadFailed("portal".into())),
|
||||
DaemonFeedback::Action(ActionFeedback::Failed("boom".into())),
|
||||
DaemonFeedback::Action(ActionFeedback::PartlyFailed(
|
||||
"Wrote Hyprland light controls; hyprctl reload failed: portal".into(),
|
||||
)),
|
||||
DaemonFeedback::Action(ActionFeedback::RefreshFailed {
|
||||
previous: None,
|
||||
error: "portal".into(),
|
||||
}),
|
||||
];
|
||||
let successes = [
|
||||
DaemonFeedback::Status(StatusFeedback::Detecting),
|
||||
DaemonFeedback::Status(StatusFeedback::Loaded),
|
||||
DaemonFeedback::Action(ActionFeedback::Pending(DaemonAction::RestartService)),
|
||||
// A report that happens to contain the word is still a success.
|
||||
DaemonFeedback::Action(ActionFeedback::Completed("error handler installed".into())),
|
||||
];
|
||||
|
||||
assert!(failures.iter().all(DaemonFeedback::is_failure));
|
||||
assert!(!successes.iter().any(DaemonFeedback::is_failure));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_renders_in_the_language_active_when_it_is_drawn() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::feedback_renders_in_the_language_active_when_it_is_drawn"
|
||||
),
|
||||
|| {
|
||||
let failed = DaemonFeedback::Action(ActionFeedback::RefreshFailed {
|
||||
previous: Some(Box::new(DaemonFeedback::Action(ActionFeedback::Failed(
|
||||
"boom".into(),
|
||||
)))),
|
||||
error: "portal".into(),
|
||||
});
|
||||
let detecting = DaemonFeedback::Status(StatusFeedback::Detecting);
|
||||
let english = (failed.text(), detecting.text());
|
||||
|
||||
wayscriber::i18n::set_language(Language::SimplifiedChinese);
|
||||
|
||||
assert_eq!(
|
||||
english.0,
|
||||
"Background setup action failed: boom\nStatus refresh failed: portal"
|
||||
);
|
||||
assert_eq!(english.1, "Detecting background mode setup status...");
|
||||
assert_eq!(
|
||||
failed.text(),
|
||||
"后台设置操作失败:boom\n状态刷新失败:portal"
|
||||
);
|
||||
assert_eq!(detecting.text(), "正在检测后台模式设置状态...");
|
||||
// The failure keeps its tone in Chinese, where neither "failed" nor
|
||||
// "error" appears.
|
||||
assert!(failed.is_failure());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,10 @@ use super::blocking_jobs::{BlockingJobKind, run_blocking};
|
||||
pub(super) async fn load_config_from_disk() -> Result<(Box<ConfigDocument>, Option<String>), String>
|
||||
{
|
||||
run_blocking(BlockingJobKind::ConfigLoad, || {
|
||||
// Every catalog, whatever language the file names: the Language row
|
||||
// can preview any of them later, and reading a user override is file
|
||||
// work that must not happen on the GTK thread.
|
||||
wayscriber::i18n::prepare_catalogs();
|
||||
ConfigDocument::load_for_editing()
|
||||
.map(|(document, warning)| (Box::new(document), warning))
|
||||
.map_err(|err| format!("{err:#}"))
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! an `AdwEntryRow` has no hint slot, so the range appears in the row's
|
||||
//! error text instead, on the same `.error` styling every ported page uses.
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::prelude::*;
|
||||
use wayscriber::config::{ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN};
|
||||
|
||||
@@ -19,27 +21,27 @@ use super::{BuiltPage, PageBuilder};
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::Arrow);
|
||||
|
||||
page.group_in_area("Arrow Settings", SearchArea::Arrow)
|
||||
page.group_in_area(tr("Arrow Settings"), SearchArea::Arrow)
|
||||
.entry_row_validated(
|
||||
"Arrow length (px)",
|
||||
tr("Arrow length (px)"),
|
||||
|app| app.draft.arrow_length.clone(),
|
||||
|value| Message::TextChanged(TextField::ArrowLength, value),
|
||||
|app| validate_f64_range(&app.draft.arrow_length, ARROW_LENGTH_MIN, ARROW_LENGTH_MAX),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Arrow angle (deg)",
|
||||
tr("Arrow angle (deg)"),
|
||||
|app| app.draft.arrow_angle.clone(),
|
||||
|value| Message::TextChanged(TextField::ArrowAngle, value),
|
||||
|app| validate_f64_range(&app.draft.arrow_angle, ARROW_ANGLE_MIN, ARROW_ANGLE_MAX),
|
||||
)
|
||||
.switch_row(
|
||||
"Place arrowhead at end of line",
|
||||
"Off draws the head at the start of the line instead.",
|
||||
tr("Place arrowhead at end of line"),
|
||||
tr("Off draws the head at the start of the line instead."),
|
||||
|app| app.draft.arrow_head_at_end,
|
||||
|value| Message::ToggleChanged(ToggleField::ArrowHeadAtEnd, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Arrow style",
|
||||
tr("Arrow style"),
|
||||
"Shape of the next arrow drawn. Every arrow keeps its own style, so \
|
||||
changing this never restyles existing drawings.",
|
||||
ArrowStyleOption::list(),
|
||||
@@ -59,10 +61,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
fn validate_f64_range(value: &str, min: f64, max: f64) -> Option<String> {
|
||||
match value.trim().parse::<f64>() {
|
||||
Ok(parsed) if (min..=max).contains(&parsed) => None,
|
||||
_ => Some(format!(
|
||||
"Enter a number between {} and {}.",
|
||||
format_float(min),
|
||||
format_float(max)
|
||||
_ => Some(trf(
|
||||
tr("Enter a number between {} and {}."),
|
||||
&[&format_float(min), &format_float(max)],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ mod section;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::prelude::*;
|
||||
use relm4::{adw, gtk};
|
||||
|
||||
@@ -49,46 +51,44 @@ use section::{BoundBoardSection, rebuild_sections};
|
||||
/// is selected", which is how the Iced pick list rendered an unknown default.
|
||||
const NO_SELECTION: u32 = u32::MAX;
|
||||
|
||||
const INTRO: &str = "Templates used to seed a new session. Boards you add or rename in the overlay belong to that session, not to this list.";
|
||||
|
||||
// Kept visible rather than hidden: the key is still in existing files, and a
|
||||
// control that quietly disappears leaves the user to work out on their own why
|
||||
// a setting they wrote stopped doing anything.
|
||||
const PERSIST_NOTE: &str = "No effect: board renames, recolors, additions, and deletions belong to the running session, and nothing but Save writes config.toml. This setting is still parsed so older files load cleanly, and will be removed.";
|
||||
|
||||
const LEGACY_NOTE: &str =
|
||||
"Legacy [board] settings detected. Editing board settings will write [boards].";
|
||||
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::Boards);
|
||||
|
||||
// The list below is the seed, not the running session, so the heading has
|
||||
// to say which of the two this screen edits.
|
||||
page.group_in_area("Boards", SearchArea::BoardsGeneral);
|
||||
page.custom(¬e_label(INTRO));
|
||||
page.group_in_area(tr("Boards"), SearchArea::BoardsGeneral);
|
||||
page.custom(¬e_label(tr(
|
||||
"Templates used to seed a new session. Boards you add or rename in the overlay belong to that session, not to this list.",
|
||||
)));
|
||||
|
||||
page.group_in_area("", SearchArea::BoardsGeneral)
|
||||
.entry_row_validated(
|
||||
"Max boards",
|
||||
tr("Max boards"),
|
||||
|app| app.draft.boards.max_count.clone(),
|
||||
|value| Message::TextChanged(TextField::BoardsMaxCount, value),
|
||||
|app| validate_min(&app.draft.boards.max_count, 1),
|
||||
)
|
||||
.switch_row(
|
||||
"Auto-create missing boards",
|
||||
tr("Auto-create missing boards"),
|
||||
"",
|
||||
|app| app.draft.boards.auto_create,
|
||||
|value| Message::ToggleChanged(ToggleField::BoardsAutoCreate, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show board badge",
|
||||
tr("Show board badge"),
|
||||
"",
|
||||
|app| app.draft.boards.show_board_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::BoardsShowBadge, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Persist runtime customizations (deprecated)",
|
||||
PERSIST_NOTE,
|
||||
tr("Persist runtime customizations (deprecated)"),
|
||||
// Kept visible rather than hidden: the key is still in existing
|
||||
// files, and a control that quietly disappears leaves the user to
|
||||
// work out on their own why a setting they wrote stopped doing
|
||||
// anything.
|
||||
tr(
|
||||
"No effect: board renames, recolors, additions, and deletions belong to the running session, and nothing but Save writes config.toml. This setting is still parsed so older files load cleanly, and will be removed.",
|
||||
),
|
||||
|app| app.draft.boards.persist_customizations,
|
||||
|value| Message::ToggleChanged(ToggleField::BoardsPersistCustomizations, value),
|
||||
);
|
||||
@@ -104,7 +104,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
/// The default-board picker: a combo whose model is the effective board ids,
|
||||
/// which change as the user edits an id.
|
||||
fn add_default_board_row(page: &mut PageBuilder) {
|
||||
let row = adw::ComboRow::builder().title("Default board").build();
|
||||
let row = adw::ComboRow::builder().title(tr("Default board")).build();
|
||||
row.set_model(Some(>k::StringList::new(&[])));
|
||||
let handler = {
|
||||
let sender = page.sender();
|
||||
@@ -136,7 +136,7 @@ fn add_default_board_row(page: &mut PageBuilder) {
|
||||
let subtitle = if has_boards {
|
||||
""
|
||||
} else {
|
||||
"Add a board to choose a default"
|
||||
tr("Add a board to choose a default")
|
||||
};
|
||||
if row.subtitle().as_deref() != Some(subtitle) {
|
||||
row.set_subtitle(subtitle);
|
||||
@@ -146,7 +146,7 @@ fn add_default_board_row(page: &mut PageBuilder) {
|
||||
|
||||
fn add_add_button(page: &mut PageBuilder) {
|
||||
let button = gtk::Button::builder()
|
||||
.label("Add board")
|
||||
.label(tr("Add board"))
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
@@ -158,7 +158,9 @@ fn add_add_button(page: &mut PageBuilder) {
|
||||
}
|
||||
|
||||
fn add_legacy_note(page: &mut PageBuilder) {
|
||||
let label = note_label(LEGACY_NOTE);
|
||||
let label = note_label(tr(
|
||||
"Legacy [board] settings detected. Editing board settings will write [boards].",
|
||||
));
|
||||
label.set_margin_top(6);
|
||||
// Hidden until a load reports a legacy file, so it cannot flash on the
|
||||
// first frame.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::{
|
||||
SectionLayout,
|
||||
rows::{TextRow, build_text_row},
|
||||
@@ -16,7 +18,7 @@ pub(super) fn build(
|
||||
) -> (adw::ComboRow, TextRow) {
|
||||
let labels: Vec<_> = BoardGridKind::ALL.iter().map(|kind| kind.label()).collect();
|
||||
let grid = adw::ComboRow::builder()
|
||||
.title("Paper pattern")
|
||||
.title(tr("Paper pattern"))
|
||||
.model(>k::StringList::new(&labels))
|
||||
.visible(layout.expanded)
|
||||
.build();
|
||||
@@ -29,7 +31,7 @@ pub(super) fn build(
|
||||
let solid = layout.background_kind != BoardBackgroundOption::Transparent;
|
||||
grid.set_sensitive(solid);
|
||||
if !solid {
|
||||
grid.set_subtitle("Paper patterns require a solid board background");
|
||||
grid.set_subtitle(tr("Paper patterns require a solid board background"));
|
||||
}
|
||||
let grid_sender = sender.clone();
|
||||
grid.connect_selected_notify(move |row| {
|
||||
@@ -38,10 +40,10 @@ pub(super) fn build(
|
||||
}
|
||||
});
|
||||
let spacing_title = match layout.grid_kind {
|
||||
BoardGridKind::None => "Grid spacing (8–200 logical pixels)",
|
||||
BoardGridKind::Cartesian => "Square side (8–200 logical pixels)",
|
||||
BoardGridKind::None => tr("Grid spacing (8–200 logical pixels)"),
|
||||
BoardGridKind::Cartesian => tr("Square side (8–200 logical pixels)"),
|
||||
BoardGridKind::Isometric | BoardGridKind::IsometricDots => {
|
||||
"Equilateral triangle side (8–200 logical pixels)"
|
||||
tr("Equilateral triangle side (8–200 logical pixels)")
|
||||
}
|
||||
};
|
||||
let spacing = build_text_row(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{ComponentSender, gtk};
|
||||
|
||||
use gtk::prelude::*;
|
||||
@@ -17,16 +19,16 @@ pub(super) struct HeaderRow {
|
||||
impl HeaderRow {
|
||||
pub(super) fn set_labels(&self, index: usize, id: &str, name: &str) {
|
||||
let title = if name.trim().is_empty() {
|
||||
format!("Board {}", index + 1)
|
||||
trf("Board {}", &[&(index + 1)])
|
||||
} else {
|
||||
name.trim().to_string()
|
||||
};
|
||||
set_label(&self.title, &title);
|
||||
|
||||
let id_label = if id.trim().is_empty() {
|
||||
"id: <unset>".to_string()
|
||||
tr("id: <unset>").to_string()
|
||||
} else {
|
||||
format!("id: {}", id.trim())
|
||||
trf("id: {}", &[&id.trim()])
|
||||
};
|
||||
set_label(&self.id, &id_label);
|
||||
}
|
||||
@@ -57,13 +59,17 @@ pub(super) fn build_header_row(
|
||||
content.append(&labels);
|
||||
for (label, message) in [
|
||||
(
|
||||
if expanded { "Collapse" } else { "Expand" },
|
||||
if expanded {
|
||||
tr("Collapse")
|
||||
} else {
|
||||
tr("Expand")
|
||||
},
|
||||
Message::BoardsCollapseToggled(index),
|
||||
),
|
||||
("Up", Message::BoardsMoveItemUp(index)),
|
||||
("Down", Message::BoardsMoveItemDown(index)),
|
||||
("Duplicate", Message::BoardsDuplicateItem(index)),
|
||||
("Remove", Message::BoardsRemoveItem(index)),
|
||||
(tr("Up"), Message::BoardsMoveItemUp(index)),
|
||||
(tr("Down"), Message::BoardsMoveItemDown(index)),
|
||||
(tr("Duplicate"), Message::BoardsDuplicateItem(index)),
|
||||
(tr("Remove"), Message::BoardsRemoveItem(index)),
|
||||
] {
|
||||
let button = gtk::Button::builder()
|
||||
.label(label)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -50,7 +52,7 @@ pub(super) fn build_kind_row(
|
||||
.collect();
|
||||
let label_refs: Vec<&str> = labels.iter().map(String::as_str).collect();
|
||||
let row = adw::ComboRow::builder()
|
||||
.title("Background")
|
||||
.title(tr("Background"))
|
||||
.model(>k::StringList::new(&label_refs))
|
||||
.build();
|
||||
|
||||
@@ -183,7 +185,7 @@ pub(super) fn is_collapsed(app: &ConfiguratorApp, index: usize) -> bool {
|
||||
pub(super) fn validate_min(value: &str, min: usize) -> Option<String> {
|
||||
match value.trim().parse::<usize>() {
|
||||
Ok(parsed) if parsed >= min => None,
|
||||
Ok(_) => Some(format!("Minimum: {min}")),
|
||||
Err(_) => Some("Expected a whole number".to_string()),
|
||||
Ok(_) => Some(trf("Minimum: {}", &[&min])),
|
||||
Err(_) => Some(tr("Expected a whole number").to_string()),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -68,11 +70,11 @@ fn build_section(
|
||||
let header = build_header_row(index, layout.expanded, sender);
|
||||
section.append(&header.row);
|
||||
|
||||
let id = build_text_row("Board id", index, BoardItemTextField::Id, sender);
|
||||
let id = build_text_row(tr("Board id"), index, BoardItemTextField::Id, sender);
|
||||
id.row.set_visible(layout.expanded);
|
||||
section.append(&id.row);
|
||||
|
||||
let name = build_text_row("Display name", index, BoardItemTextField::Name, sender);
|
||||
let name = build_text_row(tr("Display name"), index, BoardItemTextField::Name, sender);
|
||||
name.row.set_visible(layout.expanded);
|
||||
section.append(&name.row);
|
||||
|
||||
@@ -81,7 +83,7 @@ fn build_section(
|
||||
section.append(&kind_row);
|
||||
|
||||
let background = build_color_row(
|
||||
"Background color (0-1)",
|
||||
tr("Background color (0-1)"),
|
||||
ColorPickerId::BoardBackground(index),
|
||||
index,
|
||||
Message::BoardsBackgroundColorChanged,
|
||||
@@ -99,7 +101,7 @@ fn build_section(
|
||||
section.append(&grid_spacing.row);
|
||||
|
||||
let pen_enabled = adw::SwitchRow::builder()
|
||||
.title("Override default pen color")
|
||||
.title(tr("Override default pen color"))
|
||||
.active(layout.pen_enabled)
|
||||
.visible(layout.expanded)
|
||||
.build();
|
||||
@@ -115,7 +117,7 @@ fn build_section(
|
||||
section.append(&pen_enabled);
|
||||
|
||||
let pen = build_color_row(
|
||||
"Pen color (0-1)",
|
||||
tr("Pen color (0-1)"),
|
||||
ColorPickerId::BoardPen(index),
|
||||
index,
|
||||
Message::BoardsDefaultPenColorChanged,
|
||||
@@ -126,13 +128,13 @@ fn build_section(
|
||||
|
||||
for (title, field, active) in [
|
||||
(
|
||||
"Auto-adjust pen",
|
||||
tr("Auto-adjust pen"),
|
||||
BoardItemToggleField::AutoAdjustPen,
|
||||
layout.auto_adjust,
|
||||
),
|
||||
("Persist", BoardItemToggleField::Persist, layout.persist),
|
||||
(tr("Persist"), BoardItemToggleField::Persist, layout.persist),
|
||||
(
|
||||
"Configured default pinned",
|
||||
tr("Configured default pinned"),
|
||||
BoardItemToggleField::Pinned,
|
||||
layout.pinned,
|
||||
),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
//! the color dialog feed the same `ColorPickerHexChanged` the Iced picker's
|
||||
//! hex field sent; the four RGBA component boxes it drew are gone with it.
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::prelude::*;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -25,100 +27,103 @@ use super::{BuiltPage, PageBuilder};
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::Capture);
|
||||
|
||||
page.group_in_area("Capture", SearchArea::CaptureFiles)
|
||||
page.group_in_area(tr("Capture"), SearchArea::CaptureFiles)
|
||||
.switch_row(
|
||||
"Enable capture shortcuts",
|
||||
tr("Enable capture shortcuts"),
|
||||
"",
|
||||
|app| app.draft.capture.enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureEnabled, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Save directory",
|
||||
tr("Save directory"),
|
||||
|app| app.draft.capture.save_directory.clone(),
|
||||
|value| Message::TextChanged(TextField::CaptureSaveDirectory, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Filename template",
|
||||
tr("Filename template"),
|
||||
|app| app.draft.capture.filename_template.clone(),
|
||||
|value| Message::TextChanged(TextField::CaptureFilename, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Format (png, jpg, ...)",
|
||||
tr("Format (png, jpg, ...)"),
|
||||
|app| app.draft.capture.format.clone(),
|
||||
|value| Message::TextChanged(TextField::CaptureFormat, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Copy to clipboard",
|
||||
tr("Copy to clipboard"),
|
||||
"",
|
||||
|app| app.draft.capture.copy_to_clipboard,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureCopyToClipboard, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Include drawings in screenshots",
|
||||
"Composites the active board's committed annotations into full-screen and region captures by default. Interactive region capture can override this per screenshot.",
|
||||
tr("Include drawings in screenshots"),
|
||||
tr("Composites the active board's committed annotations into full-screen and region captures by default. Interactive region capture can override this per screenshot."),
|
||||
|app| app.draft.capture.include_drawings,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureIncludeDrawings, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Always exit overlay after capture",
|
||||
tr("Always exit overlay after capture"),
|
||||
"",
|
||||
|app| app.draft.capture.exit_after,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureExitAfter, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"OCR languages (e.g. eng, eng+deu; needs the matching Tesseract packages)",
|
||||
tr("OCR languages (e.g. eng, eng+deu; needs the matching Tesseract packages)"),
|
||||
|app| app.draft.capture.ocr_languages.clone(),
|
||||
|value| Message::TextChanged(TextField::CaptureOcrLanguages, value),
|
||||
|app| {
|
||||
validate_ocr_languages(&app.draft.capture.ocr_languages)
|
||||
.err()
|
||||
.map(|reason| {
|
||||
format!("{reason}. Use Tesseract codes joined by '+', such as eng+deu.")
|
||||
trf(
|
||||
"{}. Use Tesseract codes joined by '+', such as eng+deu.",
|
||||
&[&reason],
|
||||
)
|
||||
})
|
||||
},
|
||||
);
|
||||
|
||||
page.group_in_area("Region picker", SearchArea::CaptureRegion)
|
||||
page.group_in_area(tr("Region picker"), SearchArea::CaptureRegion)
|
||||
.combo_row(
|
||||
"Selection frontend",
|
||||
"Native draws the picker over a frozen desktop image. Slurp uses the external selector.",
|
||||
tr("Selection frontend"),
|
||||
tr("Native draws the picker over a frozen desktop image. Slurp uses the external selector."),
|
||||
RegionPickerOption::list(),
|
||||
labels(RegionPickerOption::list(), RegionPickerOption::label),
|
||||
|app| app.draft.capture.region_picker,
|
||||
Message::CaptureRegionPickerChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Show pointer position and selection size",
|
||||
"Shows coordinates while idle and the selected export size while dragging.",
|
||||
tr("Show pointer position and selection size"),
|
||||
tr("Shows coordinates while idle and the selected export size while dragging."),
|
||||
|app| app.draft.capture.region_show_size_readout,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureRegionShowSizeReadout, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show magnified pixel loupe",
|
||||
"Shows a magnified pixel grid beside the pointer while selecting or reviewing.",
|
||||
tr("Show magnified pixel loupe"),
|
||||
tr("Shows a magnified pixel grid beside the pointer while selecting or reviewing."),
|
||||
|app| app.draft.capture.region_show_loupe,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureRegionShowLoupe, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show hotkey legend",
|
||||
"Shows a short picker hotkey guide until the first drag.",
|
||||
tr("Show hotkey legend"),
|
||||
tr("Shows a short picker hotkey guide until the first drag."),
|
||||
|app| app.draft.capture.region_show_legend,
|
||||
|value| Message::ToggleChanged(ToggleField::CaptureRegionShowLegend, value),
|
||||
);
|
||||
|
||||
page.group_in_area("PDF export", SearchArea::CapturePdf)
|
||||
page.group_in_area(tr("PDF export"), SearchArea::CapturePdf)
|
||||
.entry_row(
|
||||
"PDF filename template (blank = capture template)",
|
||||
tr("PDF filename template (blank = capture template)"),
|
||||
|app| app.draft.export_pdf_filename_template.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfFilenameTemplate, value),
|
||||
)
|
||||
.entry_row(
|
||||
"All boards PDF filename template",
|
||||
tr("All boards PDF filename template"),
|
||||
|app| app.draft.export_pdf_all_boards_filename_template.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfAllBoardsFilenameTemplate, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Page size",
|
||||
tr("Page size"),
|
||||
"",
|
||||
PdfPageSizeOption::list(),
|
||||
labels(PdfPageSizeOption::list(), PdfPageSizeOption::label),
|
||||
@@ -126,7 +131,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfPageSizeChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Orientation",
|
||||
tr("Orientation"),
|
||||
"",
|
||||
PdfOrientationOption::list(),
|
||||
labels(PdfOrientationOption::list(), PdfOrientationOption::label),
|
||||
@@ -134,7 +139,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfOrientationChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Fit",
|
||||
tr("Fit"),
|
||||
"",
|
||||
PdfFitModeOption::list(),
|
||||
labels(PdfFitModeOption::list(), PdfFitModeOption::label),
|
||||
@@ -142,7 +147,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfFitChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Transparent page background",
|
||||
tr("Transparent page background"),
|
||||
"",
|
||||
PdfTransparentBackgroundOption::list(),
|
||||
labels(
|
||||
@@ -153,31 +158,31 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfTransparentBackgroundChanged,
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Custom width (PDF points)",
|
||||
tr("Custom width (PDF points)"),
|
||||
|app| app.draft.export_pdf_custom_width.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfCustomWidth, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_custom_width, 1.0, 14400.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Custom height (PDF points)",
|
||||
tr("Custom height (PDF points)"),
|
||||
|app| app.draft.export_pdf_custom_height.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfCustomHeight, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_custom_height, 1.0, 14400.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Content source padding",
|
||||
tr("Content source padding"),
|
||||
|app| app.draft.export_pdf_content_source_padding.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfContentSourcePadding, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_content_source_padding, 0.0, 4096.0),
|
||||
)
|
||||
.switch_row(
|
||||
"Show PDF page labels",
|
||||
tr("Show PDF page labels"),
|
||||
"",
|
||||
|app| app.draft.export_pdf_labels_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::ExportPdfLabelsEnabled, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Label position",
|
||||
tr("Label position"),
|
||||
"",
|
||||
PdfLabelPositionOption::list(),
|
||||
labels(
|
||||
@@ -188,7 +193,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfLabelPositionChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Label content",
|
||||
tr("Label content"),
|
||||
"",
|
||||
PdfLabelContentModeOption::list(),
|
||||
labels(
|
||||
@@ -199,35 +204,35 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ExportPdfLabelContentChanged,
|
||||
)
|
||||
.entry_row(
|
||||
"Label template",
|
||||
tr("Label template"),
|
||||
|app| app.draft.export_pdf_label_template.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelTemplate, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Label font family",
|
||||
tr("Label font family"),
|
||||
|app| app.draft.export_pdf_label_font_family.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelFontFamily, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Label font size",
|
||||
tr("Label font size"),
|
||||
|app| app.draft.export_pdf_label_font_size.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelFontSize, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_label_font_size, 1.0, 72.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Label margin",
|
||||
tr("Label margin"),
|
||||
|app| app.draft.export_pdf_label_margin.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelMargin, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_label_margin, 0.0, 240.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Label horizontal padding",
|
||||
tr("Label horizontal padding"),
|
||||
|app| app.draft.export_pdf_label_padding_x.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelPaddingX, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_label_padding_x, 0.0, 120.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Label vertical padding",
|
||||
tr("Label vertical padding"),
|
||||
|app| app.draft.export_pdf_label_padding_y.clone(),
|
||||
|value| Message::TextChanged(TextField::ExportPdfLabelPaddingY, value),
|
||||
|app| validate_f64_range(&app.draft.export_pdf_label_padding_y, 0.0, 120.0),
|
||||
@@ -235,13 +240,13 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
color_row(
|
||||
&mut page,
|
||||
"Label text color",
|
||||
tr("Label text color"),
|
||||
ColorPickerId::ExportPdfLabelText,
|
||||
|app| quad_color(&app.draft.export_pdf_label_text_color),
|
||||
);
|
||||
|
||||
page.switch_row(
|
||||
"Label solid background",
|
||||
tr("Label solid background"),
|
||||
"",
|
||||
|app| app.draft.export_pdf_label_background_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::ExportPdfLabelBackgroundEnabled, value),
|
||||
@@ -249,7 +254,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
color_row(
|
||||
&mut page,
|
||||
"Label background color",
|
||||
tr("Label background color"),
|
||||
ColorPickerId::ExportPdfLabelBackground,
|
||||
|app| quad_color(&app.draft.export_pdf_label_background_color),
|
||||
);
|
||||
@@ -265,10 +270,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
pub(super) fn validate_f64_range(value: &str, min: f64, max: f64) -> Option<String> {
|
||||
match value.trim().parse::<f64>() {
|
||||
Ok(parsed) if parsed.is_finite() && (min..=max).contains(&parsed) => None,
|
||||
_ => Some(format!(
|
||||
"Enter a number between {} and {}.",
|
||||
format_float(min),
|
||||
format_float(max)
|
||||
_ => Some(trf(
|
||||
tr("Enter a number between {} and {}."),
|
||||
&[&format_float(min), &format_float(max)],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -23,24 +25,26 @@ pub(super) fn overview_group(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Background Mode")
|
||||
.description("Run wayscriber in the background and toggle it with a keyboard shortcut.")
|
||||
.title(tr("Background Mode"))
|
||||
.description(tr(
|
||||
"Run wayscriber in the background and toggle it with a keyboard shortcut.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let body = column_box();
|
||||
let status_row = row_box();
|
||||
let status_label = body_label("");
|
||||
status_label.add_css_class("heading");
|
||||
let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender);
|
||||
let refresh = action_button(tr("Refresh"), DaemonAction::RefreshStatus, sender);
|
||||
status_row.append(&status_label);
|
||||
status_row.append(&refresh);
|
||||
body.append(&status_row);
|
||||
|
||||
let feedback_label = body_label("");
|
||||
body.append(&feedback_label);
|
||||
let busy_label = caption_label("Working...");
|
||||
let busy_label = caption_label(tr("Working..."));
|
||||
body.append(&busy_label);
|
||||
let loading_label = hint_label("Checking your system and background service status...");
|
||||
let loading_label = hint_label(tr("Checking your system and background service status..."));
|
||||
body.append(&loading_label);
|
||||
group.add(&body);
|
||||
|
||||
@@ -51,14 +55,9 @@ pub(super) fn overview_group(
|
||||
set_visible(&status_row, shown_areas(summary).status);
|
||||
set_sensitive(&refresh, !app.daemon.is_busy());
|
||||
|
||||
match app
|
||||
.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text)
|
||||
{
|
||||
match app.daemon.feedback.as_ref() {
|
||||
Some(feedback) => {
|
||||
set_label(&feedback_label, feedback);
|
||||
set_label(&feedback_label, &feedback.text());
|
||||
apply_tone(&feedback_label, feedback_tone(feedback));
|
||||
set_visible(&feedback_label, true);
|
||||
}
|
||||
@@ -77,8 +76,8 @@ pub(super) fn install_group(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Step 1: Install the service")
|
||||
.description("Install wayscriber as a background service.")
|
||||
.title(tr("Step 1: Install the service"))
|
||||
.description(tr("Install wayscriber as a background service."))
|
||||
.build();
|
||||
|
||||
let row = row_box();
|
||||
@@ -109,24 +108,26 @@ pub(super) fn shortcut_group(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Step 2: Set your shortcut")
|
||||
.title(tr("Step 2: Set your shortcut"))
|
||||
.build();
|
||||
|
||||
let locked_label = hint_label("Install the background service first, then set your shortcut.");
|
||||
let locked_label = hint_label(tr(
|
||||
"Install the background service first, then set your shortcut.",
|
||||
));
|
||||
group.add(&locked_label);
|
||||
|
||||
let body = column_box();
|
||||
body.append(&body_label(
|
||||
body.append(&body_label(tr(
|
||||
"Choose a keyboard shortcut to toggle drawing on/off.",
|
||||
));
|
||||
body.append(&hint_label(
|
||||
)));
|
||||
body.append(&hint_label(tr(
|
||||
"The shortcut takes effect after the background service is installed and running.",
|
||||
));
|
||||
)));
|
||||
let configured_label = hint_label("");
|
||||
body.append(&configured_label);
|
||||
let manual_label = warning_label(
|
||||
let manual_label = warning_label(tr(
|
||||
"Automatic shortcut setup is unavailable here. Add a manual keybind for `wayscriber --daemon-toggle`.",
|
||||
);
|
||||
));
|
||||
body.append(&manual_label);
|
||||
|
||||
let entry = gtk::Entry::builder().hexpand(true).build();
|
||||
@@ -140,7 +141,7 @@ pub(super) fn shortcut_group(
|
||||
};
|
||||
body.append(&entry);
|
||||
|
||||
let apply = action_button("Apply Shortcut", DaemonAction::ApplyShortcut, sender);
|
||||
let apply = action_button(tr("Apply Shortcut"), DaemonAction::ApplyShortcut, sender);
|
||||
apply.add_css_class("suggested-action");
|
||||
body.append(&apply);
|
||||
group.add(&body);
|
||||
@@ -170,7 +171,7 @@ pub(super) fn shortcut_group(
|
||||
Some(configured) => {
|
||||
set_label(
|
||||
&configured_label,
|
||||
&format!("Current shortcut: {configured}"),
|
||||
&trf("Current shortcut: {}", &[&configured]),
|
||||
);
|
||||
set_visible(&configured_label, true);
|
||||
}
|
||||
@@ -190,22 +191,24 @@ pub(super) fn light_controls_group(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Light passthrough controls")
|
||||
.description("Install global controls for light passthrough and quick drawing.")
|
||||
.title(tr("Light passthrough controls"))
|
||||
.description(tr(
|
||||
"Install global controls for light passthrough and quick drawing.",
|
||||
))
|
||||
.build();
|
||||
|
||||
let path_label = hint_label("");
|
||||
group.add(&path_label);
|
||||
|
||||
let native_body = column_box();
|
||||
let service_warning = warning_label(
|
||||
let service_warning = warning_label(tr(
|
||||
"Install the background service first so these bindings have a daemon to control.",
|
||||
);
|
||||
));
|
||||
native_body.append(&service_warning);
|
||||
let native_row = row_box();
|
||||
let state_label = body_label("");
|
||||
let install = action_button(
|
||||
"Install Hyprland Light Controls",
|
||||
tr("Install Hyprland Light Controls"),
|
||||
DaemonAction::ApplyLightControls,
|
||||
sender,
|
||||
);
|
||||
@@ -215,16 +218,16 @@ pub(super) fn light_controls_group(
|
||||
native_body.append(&native_row);
|
||||
group.add(&native_body);
|
||||
|
||||
let manual_label = warning_label(
|
||||
let manual_label = warning_label(tr(
|
||||
"Automatic light controls setup is unavailable here. Add compositor bindings for `wayscriber --light-toggle` and `wayscriber --light-draw-toggle`.",
|
||||
);
|
||||
));
|
||||
group.add(&manual_label);
|
||||
|
||||
bindings.push(Box::new(move |app, _summary| {
|
||||
let status = app.daemon.status.as_ref();
|
||||
match status.and_then(|status| status.light_controls_config_path.as_deref()) {
|
||||
Some(path) => {
|
||||
set_label(&path_label, &format!("Hyprland include: {path}"));
|
||||
set_label(&path_label, &trf("Hyprland include: {}", &[&path]));
|
||||
set_visible(&path_label, true);
|
||||
}
|
||||
None => set_visible(&path_label, false),
|
||||
@@ -254,21 +257,21 @@ pub(super) fn start_group(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Step 3: Start the service")
|
||||
.title(tr("Step 3: Start the service"))
|
||||
.build();
|
||||
|
||||
let locked_label = hint_label("Install the background service first.");
|
||||
let locked_label = hint_label(tr("Install the background service first."));
|
||||
group.add(&locked_label);
|
||||
|
||||
let body = column_box();
|
||||
body.append(&body_label("Enable and start the background service."));
|
||||
body.append(&body_label(tr("Enable and start the background service.")));
|
||||
let state_label = body_label("");
|
||||
body.append(&state_label);
|
||||
|
||||
let running_row = row_box();
|
||||
let restart = action_button("Restart", DaemonAction::RestartService, sender);
|
||||
let restart = action_button(tr("Restart"), DaemonAction::RestartService, sender);
|
||||
let stop = action_button(
|
||||
"Stop & Disable",
|
||||
tr("Stop & Disable"),
|
||||
DaemonAction::StopAndDisableService,
|
||||
sender,
|
||||
);
|
||||
@@ -276,7 +279,7 @@ pub(super) fn start_group(
|
||||
running_row.append(&stop);
|
||||
body.append(&running_row);
|
||||
|
||||
let start = action_button("Start", DaemonAction::EnableAndStartService, sender);
|
||||
let start = action_button(tr("Start"), DaemonAction::EnableAndStartService, sender);
|
||||
start.add_css_class("suggested-action");
|
||||
body.append(&start);
|
||||
group.add(&body);
|
||||
@@ -307,10 +310,12 @@ pub(super) fn details_group(
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> adw::PreferencesGroup {
|
||||
let group = adw::PreferencesGroup::builder().title("Details").build();
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title(tr("Details"))
|
||||
.build();
|
||||
|
||||
let body = column_box();
|
||||
let detecting_label = hint_label("Detecting environment...");
|
||||
let detecting_label = hint_label(tr("Detecting environment..."));
|
||||
let desktop_label = hint_label("");
|
||||
let backend_label = hint_label("");
|
||||
let shortcut_capability_label = hint_label("");
|
||||
@@ -331,7 +336,7 @@ pub(super) fn details_group(
|
||||
body.append(label);
|
||||
}
|
||||
|
||||
let refresh = action_button("Refresh", DaemonAction::RefreshStatus, sender);
|
||||
let refresh = action_button(tr("Refresh"), DaemonAction::RefreshStatus, sender);
|
||||
body.append(&refresh);
|
||||
group.add(&body);
|
||||
|
||||
@@ -357,7 +362,7 @@ pub(super) fn details_group(
|
||||
for (label, text) in [
|
||||
(
|
||||
&desktop_label,
|
||||
format!("Desktop: {}", status.desktop.label()),
|
||||
trf("Desktop: {}", &[&status.desktop.label()]),
|
||||
),
|
||||
(
|
||||
&backend_label,
|
||||
@@ -388,7 +393,7 @@ pub(super) fn details_group(
|
||||
status
|
||||
.service_unit_path
|
||||
.as_deref()
|
||||
.map(|path| format!("Service file: {path}")),
|
||||
.map(|path| trf("Service file: {}", &[&path])),
|
||||
),
|
||||
(&light_controls_label, light_controls_details(status)),
|
||||
(&missing_tools_label, missing_tools(status)),
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::gtk;
|
||||
|
||||
use gtk::prelude::*;
|
||||
|
||||
use crate::models::{DaemonRuntimeStatus, ShortcutApplyCapability};
|
||||
|
||||
use super::super::super::daemon_workflow::DaemonFeedback;
|
||||
use super::super::super::state::ConfiguratorApp;
|
||||
|
||||
/// Emphasis for a status line, mapped to the Adwaita state classes that
|
||||
@@ -31,18 +34,19 @@ impl Tone {
|
||||
|
||||
pub(super) fn overall_status(status: Option<&DaemonRuntimeStatus>) -> (&'static str, Tone) {
|
||||
match status {
|
||||
None => ("Status: Detecting...", Tone::Neutral),
|
||||
Some(status) if status.service_active => ("Status: Running", Tone::Positive),
|
||||
None => (tr("Status: Detecting..."), Tone::Neutral),
|
||||
Some(status) if status.service_active => (tr("Status: Running"), Tone::Positive),
|
||||
Some(status) if status.service_installed => {
|
||||
("Status: Installed, not running", Tone::Caution)
|
||||
(tr("Status: Installed, not running"), Tone::Caution)
|
||||
}
|
||||
Some(_) => ("Status: Not installed", Tone::Neutral),
|
||||
Some(_) => (tr("Status: Not installed"), Tone::Neutral),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn feedback_tone(feedback: &str) -> Tone {
|
||||
let feedback = feedback.to_ascii_lowercase();
|
||||
if feedback.contains("failed") || feedback.contains("error") {
|
||||
/// The feedback's styling, from its outcome. The words are translated, so they
|
||||
/// are never inspected.
|
||||
pub(super) fn feedback_tone(feedback: &DaemonFeedback) -> Tone {
|
||||
if feedback.is_failure() {
|
||||
Tone::Negative
|
||||
} else {
|
||||
Tone::Positive
|
||||
@@ -51,51 +55,53 @@ pub(super) fn feedback_tone(feedback: &str) -> Tone {
|
||||
|
||||
pub(super) fn install_status(installed: bool) -> (&'static str, Tone) {
|
||||
if installed {
|
||||
("Installed \u{2713}", Tone::Positive)
|
||||
(tr("Installed \u{2713}"), Tone::Positive)
|
||||
} else {
|
||||
("Not installed", Tone::Neutral)
|
||||
(tr("Not installed"), Tone::Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install_button_label(installed: bool) -> &'static str {
|
||||
if installed {
|
||||
"Update Service"
|
||||
tr("Update Service")
|
||||
} else {
|
||||
"Install Service"
|
||||
tr("Install Service")
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn light_controls_status(configured: bool) -> (&'static str, Tone) {
|
||||
if configured {
|
||||
("Configured \u{2713}", Tone::Positive)
|
||||
(tr("Configured \u{2713}"), Tone::Positive)
|
||||
} else {
|
||||
("Not configured", Tone::Neutral)
|
||||
(tr("Not configured"), Tone::Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn service_status(running: bool, enabled: bool) -> (&'static str, Tone) {
|
||||
match (running, enabled) {
|
||||
(true, true) => ("Running \u{2713}", Tone::Positive),
|
||||
(true, false) => ("Running (not enabled)", Tone::Caution),
|
||||
(false, true) => ("Enabled, not running", Tone::Caution),
|
||||
(false, false) => ("Stopped and disabled", Tone::Neutral),
|
||||
(true, true) => (tr("Running \u{2713}"), Tone::Positive),
|
||||
(true, false) => (tr("Running (not enabled)"), Tone::Caution),
|
||||
(false, true) => (tr("Enabled, not running"), Tone::Caution),
|
||||
(false, false) => (tr("Stopped and disabled"), Tone::Neutral),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn shortcut_placeholder(capability: Option<ShortcutApplyCapability>) -> &'static str {
|
||||
match capability {
|
||||
Some(ShortcutApplyCapability::GnomeCustomShortcut) => "e.g. Super+G or <Super>g",
|
||||
Some(ShortcutApplyCapability::PortalServiceDropIn) => "e.g. Ctrl+Shift+G or <Ctrl><Shift>g",
|
||||
_ => "e.g. Ctrl+Shift+G",
|
||||
Some(ShortcutApplyCapability::GnomeCustomShortcut) => tr("e.g. Super+G or <Super>g"),
|
||||
Some(ShortcutApplyCapability::PortalServiceDropIn) => {
|
||||
tr("e.g. Ctrl+Shift+G or <Ctrl><Shift>g")
|
||||
}
|
||||
_ => tr("e.g. Ctrl+Shift+G"),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn light_controls_details(status: &DaemonRuntimeStatus) -> Option<String> {
|
||||
let path = status.light_controls_config_path.as_deref()?;
|
||||
Some(if status.light_controls_configured {
|
||||
format!("Light controls: configured at {path}")
|
||||
trf("Light controls: configured at {}", &[&path])
|
||||
} else {
|
||||
format!("Light controls include: {path}")
|
||||
trf("Light controls include: {}", &[&path])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -108,7 +114,7 @@ pub(super) fn missing_tools(status: &DaemonRuntimeStatus) -> Option<String> {
|
||||
if !status.gsettings_available {
|
||||
missing.push("gsettings");
|
||||
}
|
||||
(!missing.is_empty()).then(|| format!("Missing tools: {}", missing.join(", ")))
|
||||
(!missing.is_empty()).then(|| trf("Missing tools: {}", &[&missing.join(", ")]))
|
||||
}
|
||||
|
||||
pub(super) fn service_installed(app: &ConfiguratorApp) -> bool {
|
||||
|
||||
@@ -121,15 +121,23 @@ fn overall_status_follows_service_state() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_tone_flags_failures() {
|
||||
assert_eq!(
|
||||
feedback_tone("Background setup action failed"),
|
||||
Tone::Negative
|
||||
);
|
||||
assert_eq!(feedback_tone("gsettings ERROR: nope"), Tone::Negative);
|
||||
assert_eq!(
|
||||
feedback_tone("Background mode status loaded."),
|
||||
Tone::Positive
|
||||
fn feedback_tone_follows_the_outcome_in_every_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::feedback_tone_follows_the_outcome_in_every_language"
|
||||
),
|
||||
|| {
|
||||
use crate::app::daemon_workflow::{ActionFeedback, DaemonFeedback, StatusFeedback};
|
||||
wayscriber::i18n::set_language(wayscriber::domain::Language::SimplifiedChinese);
|
||||
let failed = DaemonFeedback::Action(ActionFeedback::Failed("权限不足".into()));
|
||||
let loaded = DaemonFeedback::Status(StatusFeedback::Loaded);
|
||||
|
||||
// Neither Chinese message spells "failed" or "error".
|
||||
assert!(!failed.text().to_ascii_lowercase().contains("failed"));
|
||||
assert_eq!(feedback_tone(&failed), Tone::Negative);
|
||||
assert_eq!(feedback_tone(&loaded), Tone::Positive);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ mod font;
|
||||
mod laser;
|
||||
mod quick_colors;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::prelude::*;
|
||||
use relm4::{adw, gtk};
|
||||
|
||||
@@ -228,16 +230,15 @@ fn set_error_if_changed(row: &adw::EntryRow, error: Option<String>) {
|
||||
fn validate_f64_range(value: &str, min: f64, max: f64) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some("Expected a numeric value".to_string());
|
||||
return Some(tr("Expected a numeric value").to_string());
|
||||
}
|
||||
match trimmed.parse::<f64>() {
|
||||
Ok(parsed) if (min..=max).contains(&parsed) => None,
|
||||
Ok(_) => Some(format!(
|
||||
"Range: {}-{}",
|
||||
format_float(min),
|
||||
format_float(max)
|
||||
Ok(_) => Some(trf(
|
||||
tr("Range: {}-{}"),
|
||||
&[&format_float(min), &format_float(max)],
|
||||
)),
|
||||
Err(_) => Some("Expected a numeric value".to_string()),
|
||||
Err(_) => Some(tr("Expected a numeric value").to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,12 +246,12 @@ fn validate_f64_range(value: &str, min: f64, max: f64) -> Option<String> {
|
||||
fn validate_usize_range(value: &str, min: usize, max: usize) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some("Expected a whole number".to_string());
|
||||
return Some(tr("Expected a whole number").to_string());
|
||||
}
|
||||
match trimmed.parse::<usize>() {
|
||||
Ok(parsed) if (min..=max).contains(&parsed) => None,
|
||||
Ok(_) => Some(format!("Range: {min}-{max}")),
|
||||
Err(_) => Some("Expected a whole number".to_string()),
|
||||
Ok(_) => Some(trf("Range: {}-{}", &[&min, &max])),
|
||||
Err(_) => Some(tr("Expected a whole number").to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,12 +259,12 @@ fn validate_usize_range(value: &str, min: usize, max: usize) -> Option<String> {
|
||||
fn validate_usize_min(value: &str, min: usize) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some("Expected a whole number".to_string());
|
||||
return Some(tr("Expected a whole number").to_string());
|
||||
}
|
||||
match trimmed.parse::<usize>() {
|
||||
Ok(parsed) if parsed >= min => None,
|
||||
Ok(_) => Some(format!("Minimum: {min}")),
|
||||
Err(_) => Some("Expected a whole number".to_string()),
|
||||
Ok(_) => Some(trf("Minimum: {}", &[&min])),
|
||||
Err(_) => Some(tr("Expected a whole number").to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::color::ColorInput;
|
||||
use crate::models::{ColorMode, ColorPickerId, NamedColorOption, TextField};
|
||||
@@ -11,12 +13,12 @@ use super::{
|
||||
};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Default color", SearchArea::DrawingColor)
|
||||
page.group_in_area(tr("Default color"), SearchArea::DrawingColor)
|
||||
.combo_row(
|
||||
"Color mode",
|
||||
"A palette name or hex string, or explicit RGB components.",
|
||||
tr("Color mode"),
|
||||
tr("A palette name or hex string, or explicit RGB components."),
|
||||
COLOR_MODES.to_vec(),
|
||||
vec!["Named color".to_string(), "RGB color".to_string()],
|
||||
vec![tr("Named color").to_string(), tr("RGB color").to_string()],
|
||||
|app| app.draft.drawing_color.mode,
|
||||
Message::ColorModeChanged,
|
||||
);
|
||||
@@ -25,7 +27,7 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
section_combo_row(
|
||||
page,
|
||||
&named,
|
||||
"Named color",
|
||||
tr("Named color"),
|
||||
NamedColorOption::list(),
|
||||
named_color_labels(),
|
||||
|app| app.draft.drawing_color.selected_named,
|
||||
@@ -34,20 +36,23 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
section_entry_row(
|
||||
page,
|
||||
&named,
|
||||
"Custom color name",
|
||||
tr("Custom color name"),
|
||||
|app| app.draft.drawing_color.name.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingColorName, value),
|
||||
|app| color_name_error(&app.draft.drawing_color, "Unknown color name"),
|
||||
|app| color_name_error(&app.draft.drawing_color, tr("Unknown color name")),
|
||||
);
|
||||
|
||||
// Only in RGB mode: in Named mode the save serializes the named value,
|
||||
// so a visible RGB edit here would be silently discarded.
|
||||
page.group_in_area_when("RGB color", SearchArea::DrawingColor, |app| {
|
||||
page.group_in_area_when(tr("RGB color"), SearchArea::DrawingColor, |app| {
|
||||
app.draft.drawing_color.mode == ColorMode::Rgb
|
||||
});
|
||||
color_row(page, "Custom color", ColorPickerId::DrawingColor, |app| {
|
||||
resolved(app.draft.drawing_color.preview_color())
|
||||
});
|
||||
color_row(
|
||||
page,
|
||||
tr("Custom color"),
|
||||
ColorPickerId::DrawingColor,
|
||||
|app| resolved(app.draft.drawing_color.preview_color()),
|
||||
);
|
||||
}
|
||||
|
||||
/// The error the old view showed under a custom color name that resolves to
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::{EraserModeOption, TextField, ToggleField};
|
||||
|
||||
@@ -6,33 +8,33 @@ use super::super::PageBuilder;
|
||||
use super::{validate_f64_range, validate_usize_min, validate_usize_range};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Drawing defaults", SearchArea::DrawingDefaults)
|
||||
page.group_in_area(tr("Drawing defaults"), SearchArea::DrawingDefaults)
|
||||
.entry_row_validated(
|
||||
"Thickness (px)",
|
||||
tr("Thickness (px)"),
|
||||
|app| app.draft.drawing_default_thickness.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingThickness, value),
|
||||
|app| validate_f64_range(&app.draft.drawing_default_thickness, 1.0, 50.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Font size (pt)",
|
||||
tr("Font size (pt)"),
|
||||
|app| app.draft.drawing_default_font_size.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingFontSize, value),
|
||||
|app| validate_f64_range(&app.draft.drawing_default_font_size, 8.0, 72.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Polygon sides",
|
||||
tr("Polygon sides"),
|
||||
|app| app.draft.drawing_polygon_sides.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingPolygonSides, value),
|
||||
|app| validate_usize_range(&app.draft.drawing_polygon_sides, 3, 12),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Eraser size (px)",
|
||||
tr("Eraser size (px)"),
|
||||
|app| app.draft.drawing_default_eraser_size.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingEraserSize, value),
|
||||
|app| validate_f64_range(&app.draft.drawing_default_eraser_size, 1.0, 50.0),
|
||||
)
|
||||
.combo_row(
|
||||
"Eraser mode",
|
||||
tr("Eraser mode"),
|
||||
"",
|
||||
EraserModeOption::list(),
|
||||
EraserModeOption::list()
|
||||
@@ -43,19 +45,19 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
Message::EraserModeChanged,
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Marker opacity (0.05-0.9)",
|
||||
tr("Marker opacity (0.05-0.9)"),
|
||||
|app| app.draft.drawing_marker_opacity.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingMarkerOpacity, value),
|
||||
|app| validate_f64_range(&app.draft.drawing_marker_opacity, 0.05, 0.9),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Pen smoothing (0-6)",
|
||||
tr("Pen smoothing (0-6)"),
|
||||
|app| app.draft.drawing_pen_smoothing.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingPenSmoothing, value),
|
||||
|app| validate_usize_range(&app.draft.drawing_pen_smoothing, 0, 6),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Shape Pen sensitivity (0-4)",
|
||||
tr("Shape Pen sensitivity (0-4)"),
|
||||
|app| app.draft.drawing_shape_recognition_sensitivity.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingShapeRecognitionSensitivity, value),
|
||||
|app| {
|
||||
@@ -67,49 +69,49 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
},
|
||||
)
|
||||
.switch_row(
|
||||
"Snap Shape Pen to board paper",
|
||||
"Recognized lines, rectangles, ovals, and triangles line up with nearby grid lines and points",
|
||||
tr("Snap Shape Pen to board paper"),
|
||||
tr("Recognized lines, rectangles, ovals, and triangles line up with nearby grid lines and points"),
|
||||
|app| app.draft.drawing_shape_recognition_grid_snap,
|
||||
|value| Message::ToggleChanged(ToggleField::DrawingShapeRecognitionGridSnap, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Name recognized Shape Pen shapes",
|
||||
"A brief chip beside each recognized shape names it and the undo shortcut that keeps the ink",
|
||||
tr("Name recognized Shape Pen shapes"),
|
||||
tr("A brief chip beside each recognized shape names it and the undo shortcut that keeps the ink"),
|
||||
|app| app.draft.drawing_shape_recognition_feedback,
|
||||
|value| Message::ToggleChanged(ToggleField::DrawingShapeRecognitionFeedback, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Undo stack limit",
|
||||
tr("Undo stack limit"),
|
||||
|app| app.draft.drawing_undo_stack_limit.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingUndoStackLimit, value),
|
||||
|app| validate_usize_range(&app.draft.drawing_undo_stack_limit, 10, 1000),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Hit-test tolerance (px)",
|
||||
tr("Hit-test tolerance (px)"),
|
||||
|app| app.draft.drawing_hit_test_tolerance.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingHitTestTolerance, value),
|
||||
|app| validate_f64_range(&app.draft.drawing_hit_test_tolerance, 1.0, 20.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Hit-test threshold",
|
||||
tr("Hit-test threshold"),
|
||||
|app| app.draft.drawing_hit_test_linear_threshold.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingHitTestThreshold, value),
|
||||
|app| validate_usize_min(&app.draft.drawing_hit_test_linear_threshold, 1),
|
||||
)
|
||||
.switch_row(
|
||||
"Enable text background",
|
||||
tr("Enable text background"),
|
||||
"",
|
||||
|app| app.draft.drawing_text_background_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::DrawingTextBackground, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Enable text halo",
|
||||
"Draw a contrasting outline around text",
|
||||
tr("Enable text halo"),
|
||||
tr("Draw a contrasting outline around text"),
|
||||
|app| app.draft.drawing_text_halo_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::DrawingTextHalo, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Start shapes filled",
|
||||
tr("Start shapes filled"),
|
||||
"",
|
||||
|app| app.draft.drawing_default_fill_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::DrawingFillEnabled, value),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::gtk;
|
||||
|
||||
use gtk::prelude::*;
|
||||
@@ -13,7 +15,7 @@ use super::super::PageBuilder;
|
||||
use super::{DRAG_BUTTONS, DRAG_FIELDS, boxed_list, section_combo_row, set_visible_if_changed};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Drag tool mapping", SearchArea::DrawingDragTools);
|
||||
page.group_in_area(tr("Drag tool mapping"), SearchArea::DrawingDragTools);
|
||||
|
||||
let switcher = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
@@ -91,7 +93,7 @@ fn build_drag_button_section(page: &mut PageBuilder, button: DragMouseButton) {
|
||||
section_combo_row(
|
||||
page,
|
||||
&list,
|
||||
&format!("{} color", field.label()),
|
||||
&trf("{} color", &[&field.label()]),
|
||||
colors.clone(),
|
||||
color_labels.clone(),
|
||||
move |app| drag_color_for_field(drag_button_config(app, button), field),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::rc::Rc;
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
@@ -14,15 +16,15 @@ use super::super::{PageBuilder, set_selected_blocked};
|
||||
use super::{boxed_list, conditional_section, icon_button, section_entry_row};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Font", SearchArea::DrawingFont)
|
||||
page.group_in_area(tr("Font"), SearchArea::DrawingFont)
|
||||
.entry_row_validated(
|
||||
"Font family",
|
||||
tr("Font family"),
|
||||
|app| app.draft.drawing_font_family.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingFontFamily, value),
|
||||
|app| validate_installed_family(&app.draft.drawing_font_family),
|
||||
)
|
||||
.combo_row(
|
||||
"Font weight",
|
||||
tr("Font weight"),
|
||||
"",
|
||||
FontWeightOption::list(),
|
||||
FontWeightOption::list()
|
||||
@@ -33,12 +35,12 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
Message::FontWeightOptionSelected,
|
||||
)
|
||||
.entry_row(
|
||||
"Custom or numeric weight",
|
||||
tr("Custom or numeric weight"),
|
||||
|app| app.draft.drawing_font_weight.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingFontWeight, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Font style",
|
||||
tr("Font style"),
|
||||
"",
|
||||
FontStyleOption::list(),
|
||||
FontStyleOption::list()
|
||||
@@ -55,7 +57,7 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
section_entry_row(
|
||||
page,
|
||||
&custom_style,
|
||||
"Custom style",
|
||||
tr("Custom style"),
|
||||
|app| app.draft.drawing_font_style.clone(),
|
||||
|value| Message::TextChanged(TextField::DrawingFontStyle, value),
|
||||
|_app| None,
|
||||
@@ -76,13 +78,13 @@ const NO_SELECTION: u32 = u32::MAX;
|
||||
/// removes both, and lets each row offer every installed family rather than
|
||||
/// asking the user to know what is installed.
|
||||
fn build_font_cycle(page: &mut PageBuilder) {
|
||||
page.group_in_area("Font cycle", SearchArea::DrawingFont);
|
||||
page.group_in_area(tr("Font cycle"), SearchArea::DrawingFont);
|
||||
|
||||
// An activatable ActionRow rather than AdwButtonRow: ButtonRow needs
|
||||
// libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04).
|
||||
let add = adw::ActionRow::builder()
|
||||
.title("Add font")
|
||||
.subtitle("Shift+T steps through this list in order")
|
||||
.title(tr("Add font"))
|
||||
.subtitle(tr("Shift+T steps through this list in order"))
|
||||
.activatable(true)
|
||||
.build();
|
||||
add.add_prefix(>k::Image::from_icon_name("list-add-symbolic"));
|
||||
@@ -93,7 +95,9 @@ fn build_font_cycle(page: &mut PageBuilder) {
|
||||
page.custom(&add);
|
||||
|
||||
let empty = gtk::Label::builder()
|
||||
.label("No fonts listed: Shift+T does nothing until one is added.")
|
||||
.label(tr(
|
||||
"No fonts listed: Shift+T does nothing until one is added.",
|
||||
))
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.margin_top(6)
|
||||
@@ -216,19 +220,19 @@ fn build_font_cycle_row(
|
||||
.build();
|
||||
row.set_list_factory(Some(&family_preview_factory()));
|
||||
|
||||
let up = icon_button("go-up-symbolic", "Move up");
|
||||
let up = icon_button("go-up-symbolic", tr("Move up"));
|
||||
up.set_sensitive(layout.can_move_up);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
up.connect_clicked(move |_| sender.input(Message::FontCycleMoved(index, -1)));
|
||||
}
|
||||
let down = icon_button("go-down-symbolic", "Move down");
|
||||
let down = icon_button("go-down-symbolic", tr("Move down"));
|
||||
down.set_sensitive(layout.can_move_down);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
down.connect_clicked(move |_| sender.input(Message::FontCycleMoved(index, 1)));
|
||||
}
|
||||
let remove = icon_button("user-trash-symbolic", "Remove");
|
||||
let remove = icon_button("user-trash-symbolic", tr("Remove"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
remove.connect_clicked(move |_| sender.input(Message::FontCycleRemoved(index)));
|
||||
@@ -335,8 +339,9 @@ fn validate_installed_family(value: &str) -> Option<String> {
|
||||
if trimmed.is_empty() || family_is_installed(trimmed) {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"\"{trimmed}\" is not installed; text falls back to another font"
|
||||
Some(trf(
|
||||
tr("\"{}\" is not installed; text falls back to another font"),
|
||||
&[&trimmed],
|
||||
))
|
||||
}
|
||||
|
||||
@@ -351,7 +356,7 @@ fn missing_family_note(family: &str) -> Option<String> {
|
||||
if trimmed.is_empty() || family_is_installed(trimmed) {
|
||||
return None;
|
||||
}
|
||||
Some(format!("\"{trimmed}\" is not installed on this system"))
|
||||
Some(trf("\"{}\" is not installed on this system", &[&trimmed]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Laser pointer group: the color, width, and timing of fading presenter ink.
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use wayscriber::config::{LASER_FADE_MS_MAX, LASER_HOLD_MS_MAX, LASER_WIDTH_MAX, LASER_WIDTH_MIN};
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -12,25 +14,30 @@ use super::super::color_rows::color_row;
|
||||
use super::{validate_f64_range, validate_usize_range};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Laser pointer", SearchArea::DrawingLaser);
|
||||
color_row(page, "Ink color (hex)", ColorPickerId::LaserColor, |app| {
|
||||
let [r, g, b, a] = parse_quad_values(&app.draft.laser_color.components);
|
||||
Some((r, g, b, a))
|
||||
});
|
||||
page.group_in_area(tr("Laser pointer"), SearchArea::DrawingLaser);
|
||||
color_row(
|
||||
page,
|
||||
tr("Ink color (hex)"),
|
||||
ColorPickerId::LaserColor,
|
||||
|app| {
|
||||
let [r, g, b, a] = parse_quad_values(&app.draft.laser_color.components);
|
||||
Some((r, g, b, a))
|
||||
},
|
||||
);
|
||||
page.entry_row_validated(
|
||||
"Width (px)",
|
||||
tr("Width (px)"),
|
||||
|app| app.draft.laser_width.clone(),
|
||||
|value| Message::TextChanged(TextField::LaserWidth, value),
|
||||
|app| validate_f64_range(&app.draft.laser_width, LASER_WIDTH_MIN, LASER_WIDTH_MAX),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Stay visible after release (ms)",
|
||||
tr("Stay visible after release (ms)"),
|
||||
|app| app.draft.laser_hold_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::LaserHoldMs, value),
|
||||
|app| validate_usize_range(&app.draft.laser_hold_ms, 0, LASER_HOLD_MS_MAX as usize),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Fade out (ms)",
|
||||
tr("Fade out (ms)"),
|
||||
|app| app.draft.laser_fade_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::LaserFadeMs, value),
|
||||
|app| validate_usize_range(&app.draft.laser_fade_ms, 0, LASER_FADE_MS_MAX as usize),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -17,12 +19,12 @@ use super::{
|
||||
};
|
||||
|
||||
pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.group_in_area("Quick colors", SearchArea::DrawingColor);
|
||||
page.group_in_area(tr("Quick colors"), SearchArea::DrawingColor);
|
||||
|
||||
// An activatable ActionRow rather than AdwButtonRow: ButtonRow needs
|
||||
// libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04).
|
||||
let add = adw::ActionRow::builder()
|
||||
.title("Add color")
|
||||
.title(tr("Add color"))
|
||||
.activatable(true)
|
||||
.build();
|
||||
add.add_prefix(>k::Image::from_icon_name("list-add-symbolic"));
|
||||
@@ -33,8 +35,9 @@ pub(super) fn build(page: &mut PageBuilder) {
|
||||
page.custom(&add);
|
||||
|
||||
let warning = gtk::Label::builder()
|
||||
.label(format!(
|
||||
"Only the first {QUICK_COLOR_RENDER_LIMIT} quick colors are shown in toolbar and radial menus."
|
||||
.label(trf(
|
||||
tr("Only the first {} quick colors are shown in toolbar and radial menus."),
|
||||
&[&QUICK_COLOR_RENDER_LIMIT],
|
||||
))
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
@@ -178,22 +181,22 @@ fn build_quick_color_row(
|
||||
sender: &ComponentSender<ConfiguratorApp>,
|
||||
) -> QuickColorRow {
|
||||
let row = adw::ExpanderRow::builder()
|
||||
.title(format!("Color {}", index + 1))
|
||||
.title(trf("Color {}", &[&(index + 1)]))
|
||||
.build();
|
||||
|
||||
let up = icon_button("go-up-symbolic", "Move up");
|
||||
let up = icon_button("go-up-symbolic", tr("Move up"));
|
||||
up.set_sensitive(layout.can_move_up);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
up.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, -1)));
|
||||
}
|
||||
let down = icon_button("go-down-symbolic", "Move down");
|
||||
let down = icon_button("go-down-symbolic", tr("Move down"));
|
||||
down.set_sensitive(layout.can_move_down);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
down.connect_clicked(move |_| sender.input(Message::QuickColorMoved(index, 1)));
|
||||
}
|
||||
let remove = icon_button("user-trash-symbolic", "Remove");
|
||||
let remove = icon_button("user-trash-symbolic", tr("Remove"));
|
||||
remove.set_sensitive(layout.can_remove);
|
||||
{
|
||||
let sender = sender.clone();
|
||||
@@ -203,7 +206,7 @@ fn build_quick_color_row(
|
||||
row.add_suffix(&down);
|
||||
row.add_suffix(&remove);
|
||||
|
||||
let label = adw::EntryRow::builder().title("Label").build();
|
||||
let label = adw::EntryRow::builder().title(tr("Label")).build();
|
||||
let label_handler = {
|
||||
let sender = sender.clone();
|
||||
label.connect_changed(move |row| {
|
||||
@@ -218,8 +221,8 @@ fn build_quick_color_row(
|
||||
// The mode is part of the fingerprint, so it is selected before the
|
||||
// handler exists and never written again behind the user's back.
|
||||
let mode = combo_row_widget(
|
||||
"Color mode",
|
||||
&["Named or hex".to_string(), "RGB".to_string()],
|
||||
tr("Color mode"),
|
||||
&[tr("Named or hex").to_string(), "RGB".to_string()],
|
||||
);
|
||||
select_if_changed(&mode, &COLOR_MODES, layout.mode);
|
||||
connect_combo(&mode, sender.clone(), COLOR_MODES.to_vec(), move |value| {
|
||||
@@ -228,7 +231,7 @@ fn build_quick_color_row(
|
||||
row.add_row(&mode);
|
||||
|
||||
let named_options = NamedColorOption::list();
|
||||
let named = combo_row_widget("Named color", &named_color_labels());
|
||||
let named = combo_row_widget(tr("Named color"), &named_color_labels());
|
||||
named.set_visible(layout.mode == ColorMode::Named);
|
||||
let named_handler = {
|
||||
let sender = sender.clone();
|
||||
@@ -242,7 +245,7 @@ fn build_quick_color_row(
|
||||
row.add_row(&named);
|
||||
|
||||
let name = adw::EntryRow::builder()
|
||||
.title("Color name or #RRGGBB[AA]")
|
||||
.title(tr("Color name or #RRGGBB[AA]"))
|
||||
.build();
|
||||
name.set_visible(layout.mode == ColorMode::Named);
|
||||
let name_handler = {
|
||||
@@ -257,7 +260,7 @@ fn build_quick_color_row(
|
||||
row.add_row(&name);
|
||||
|
||||
let picker = ColorPickerId::QuickColor(index);
|
||||
let hex = adw::EntryRow::builder().title("Custom color").build();
|
||||
let hex = adw::EntryRow::builder().title(tr("Custom color")).build();
|
||||
hex.set_visible(layout.mode == ColorMode::Rgb);
|
||||
let hex_handler = {
|
||||
let sender = sender.clone();
|
||||
@@ -316,7 +319,7 @@ fn build_quick_color_row(
|
||||
/// nothing, read off the values the row was handed.
|
||||
fn name_error(values: &QuickColorValues<'_>) -> Option<String> {
|
||||
let unresolved = values.preview.is_none() && !values.name.trim().is_empty();
|
||||
unresolved.then(|| "Use a known color name, #RRGGBB, or #RRGGBBAA for alpha".to_string())
|
||||
unresolved.then(|| tr("Use a known color name, #RRGGBB, or #RRGGBBAA for alpha").to_string())
|
||||
}
|
||||
|
||||
fn picker_hex(app: &ConfiguratorApp, id: ColorPickerId) -> &str {
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
//! fields in one row also keeps the group free for the search filter its
|
||||
//! area owns — a second visibility binding on the group would overwrite it.
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::adw;
|
||||
use relm4::prelude::*;
|
||||
|
||||
@@ -27,9 +29,9 @@ const STEPS_RANGE: (u32, u32) = (1, 500);
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::History);
|
||||
|
||||
page.group_in_area("Delays", SearchArea::HistoryMain)
|
||||
page.group_in_area(tr("Delays"), SearchArea::HistoryMain)
|
||||
.entry_row_validated(
|
||||
"Undo all delay (ms)",
|
||||
tr("Undo all delay (ms)"),
|
||||
|app| app.draft.history.undo_all_delay_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::HistoryUndoAllDelayMs, value),
|
||||
|app| {
|
||||
@@ -41,7 +43,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Redo all delay (ms)",
|
||||
tr("Redo all delay (ms)"),
|
||||
|app| app.draft.history.redo_all_delay_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::HistoryRedoAllDelayMs, value),
|
||||
|app| {
|
||||
@@ -53,12 +55,12 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
);
|
||||
|
||||
page.group_in_area("Custom section", SearchArea::HistoryCustom);
|
||||
page.group_in_area(tr("Custom section"), SearchArea::HistoryCustom);
|
||||
let section = custom_section(&mut page);
|
||||
custom_field(
|
||||
&mut page,
|
||||
§ion,
|
||||
"Custom undo delay (ms)",
|
||||
tr("Custom undo delay (ms)"),
|
||||
TextField::HistoryCustomUndoDelayMs,
|
||||
|app| app.draft.history.custom_undo_delay_ms.clone(),
|
||||
DELAY_RANGE,
|
||||
@@ -66,7 +68,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
custom_field(
|
||||
&mut page,
|
||||
§ion,
|
||||
"Custom redo delay (ms)",
|
||||
tr("Custom redo delay (ms)"),
|
||||
TextField::HistoryCustomRedoDelayMs,
|
||||
|app| app.draft.history.custom_redo_delay_ms.clone(),
|
||||
DELAY_RANGE,
|
||||
@@ -74,7 +76,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
custom_field(
|
||||
&mut page,
|
||||
§ion,
|
||||
"Custom undo steps",
|
||||
tr("Custom undo steps"),
|
||||
TextField::HistoryCustomUndoSteps,
|
||||
|app| app.draft.history.custom_undo_steps.clone(),
|
||||
STEPS_RANGE,
|
||||
@@ -82,7 +84,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
custom_field(
|
||||
&mut page,
|
||||
§ion,
|
||||
"Custom redo steps",
|
||||
tr("Custom redo steps"),
|
||||
TextField::HistoryCustomRedoSteps,
|
||||
|app| app.draft.history.custom_redo_steps.clone(),
|
||||
STEPS_RANGE,
|
||||
@@ -96,7 +98,7 @@ fn custom_section(page: &mut PageBuilder) -> adw::ExpanderRow {
|
||||
// Starts folded, the shipped default: a switch that opens agreeing with
|
||||
// the draft keeps the first refresh from echoing a toggle back.
|
||||
let row = adw::ExpanderRow::builder()
|
||||
.title("Enable custom undo/redo section")
|
||||
.title(tr("Enable custom undo/redo section"))
|
||||
.show_enable_switch(true)
|
||||
.enable_expansion(false)
|
||||
.build();
|
||||
|
||||
@@ -6,6 +6,8 @@ mod text_editor;
|
||||
mod toolbar;
|
||||
mod widgets;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -23,8 +25,6 @@ use row::{ManagerRefresh, binding_row};
|
||||
use toolbar::build_chrome;
|
||||
use widgets::{set_accessible_label, set_label, set_sensitive, set_visible};
|
||||
|
||||
const SECTION_DESCRIPTION: &str = "Record a shortcut or sequence, reset one action, or edit the comma-separated list as text. Filter and sort to review many actions at once.";
|
||||
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut bindings: Vec<Binding> = Vec::new();
|
||||
let refresh: ManagerRefresh = Rc::new(RefCell::new(None));
|
||||
@@ -43,7 +43,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
for tab in KeybindingsTabId::ALL {
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title(tab.title())
|
||||
.description(SECTION_DESCRIPTION)
|
||||
.description(tr(
|
||||
"Record a shortcut or sequence, reset one action, or edit the comma-separated list as text. Filter and sort to review many actions at once.",
|
||||
))
|
||||
.build();
|
||||
for field in fields
|
||||
.iter()
|
||||
@@ -133,20 +135,20 @@ fn conflict_banner(
|
||||
.hexpand(true)
|
||||
.build();
|
||||
let replace = gtk::Button::builder()
|
||||
.label("Replace")
|
||||
.label(tr("Replace"))
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
set_accessible_label(&replace, "Replace conflicting shortcuts");
|
||||
set_accessible_label(&replace, tr("Replace conflicting shortcuts"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
replace.connect_clicked(move |_| {
|
||||
sender.input(Message::ShortcutConflictReplaceConfirmed);
|
||||
});
|
||||
}
|
||||
let jump = gtk::Button::with_label("Jump to Conflict");
|
||||
set_accessible_label(&jump, "Jump to conflicting action");
|
||||
let cancel = gtk::Button::builder().label("Cancel").build();
|
||||
set_accessible_label(&cancel, "Cancel shortcut conflict");
|
||||
let jump = gtk::Button::with_label(tr("Jump to Conflict"));
|
||||
set_accessible_label(&jump, tr("Jump to conflicting action"));
|
||||
let cancel = gtk::Button::builder().label(tr("Cancel")).build();
|
||||
set_accessible_label(&cancel, tr("Cancel shortcut conflict"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
cancel.connect_clicked(move |_| {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
@@ -42,7 +44,7 @@ impl RecorderPopover {
|
||||
.max_width_chars(36)
|
||||
.can_focus(true)
|
||||
.build();
|
||||
set_accessible_label(&prompt, "Shortcut recorder");
|
||||
set_accessible_label(&prompt, tr("Shortcut recorder"));
|
||||
|
||||
let hint = gtk::Label::builder()
|
||||
.label(super_consumed_hint())
|
||||
@@ -53,8 +55,8 @@ impl RecorderPopover {
|
||||
.build();
|
||||
set_accessible_label(&hint, super_consumed_hint());
|
||||
|
||||
let cancel = gtk::Button::builder().label("Cancel").build();
|
||||
set_accessible_label(&cancel, "Cancel recording");
|
||||
let cancel = gtk::Button::builder().label(tr("Cancel")).build();
|
||||
set_accessible_label(&cancel, tr("Cancel recording"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
cancel.connect_clicked(move |_| {
|
||||
@@ -62,8 +64,8 @@ impl RecorderPopover {
|
||||
});
|
||||
}
|
||||
|
||||
let finish = gtk::Button::builder().label("Finish").build();
|
||||
set_accessible_label(&finish, "Finish sequence");
|
||||
let finish = gtk::Button::builder().label(tr("Finish")).build();
|
||||
set_accessible_label(&finish, tr("Finish sequence"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
finish.connect_clicked(move |_| {
|
||||
@@ -71,8 +73,8 @@ impl RecorderPopover {
|
||||
});
|
||||
}
|
||||
|
||||
let remove_last = gtk::Button::builder().label("Remove Last Step").build();
|
||||
set_accessible_label(&remove_last, "Remove last sequence step");
|
||||
let remove_last = gtk::Button::builder().label(tr("Remove Last Step")).build();
|
||||
set_accessible_label(&remove_last, tr("Remove last sequence step"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
remove_last.connect_clicked(move |_| {
|
||||
@@ -117,9 +119,9 @@ impl RecorderPopover {
|
||||
let recording = recorder.is_some();
|
||||
set_label(
|
||||
&self.prompt,
|
||||
recorder
|
||||
.map(|recorder| recorder.prompt.as_str())
|
||||
.unwrap_or(""),
|
||||
&recorder
|
||||
.map(ShortcutRecorderState::prompt)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
let sequence = recorder.is_some_and(ShortcutRecorderState::is_sequence);
|
||||
set_visible(&self.finish, sequence);
|
||||
@@ -198,7 +200,9 @@ fn device_hint_label() -> gtk::Label {
|
||||
fn device_hint_text() -> &'static str {
|
||||
#[cfg(feature = "tablet-input")]
|
||||
{
|
||||
"Auxiliary mouse and stylus barrel buttons can be recorded. Left, middle, right, and the stylus tip cannot."
|
||||
tr(
|
||||
"Auxiliary mouse and stylus barrel buttons can be recorded. Left, middle, right, and the stylus tip cannot.",
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "tablet-input"))]
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -47,25 +49,25 @@ pub(super) fn binding_row(
|
||||
.row_spacing(6)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
let add = icon_button("input-keyboard-symbolic", "Add shortcut");
|
||||
let add = icon_button("input-keyboard-symbolic", tr("Add shortcut"));
|
||||
connect_clicked(&add, sender, Message::ShortcutRecordingStarted(field));
|
||||
let record_sequence = gtk::Button::builder()
|
||||
.label("Record Sequence")
|
||||
.label(tr("Record Sequence"))
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
set_accessible_label(&record_sequence, "Record sequence");
|
||||
set_accessible_label(&record_sequence, tr("Record sequence"));
|
||||
set_tooltip(
|
||||
&record_sequence,
|
||||
Some("Record a two- or three-chord keyboard sequence"),
|
||||
Some(tr("Record a two- or three-chord keyboard sequence")),
|
||||
);
|
||||
connect_clicked(
|
||||
&record_sequence,
|
||||
sender,
|
||||
Message::ShortcutSequenceRecordingStarted(field),
|
||||
);
|
||||
let reset = icon_button("view-refresh-symbolic", "Reset to default");
|
||||
let reset = icon_button("view-refresh-symbolic", tr("Reset to default"));
|
||||
connect_clicked(&reset, sender, Message::ShortcutResetRequested(field));
|
||||
let edit = icon_button("document-edit-symbolic", "Edit as text");
|
||||
let edit = icon_button("document-edit-symbolic", tr("Edit as text"));
|
||||
connect_clicked(&edit, sender, Message::ShortcutTextEditStarted(field));
|
||||
|
||||
let controls = gtk::Box::new(gtk::Orientation::Horizontal, 4);
|
||||
@@ -85,10 +87,10 @@ pub(super) fn binding_row(
|
||||
.xalign(1.0)
|
||||
.build();
|
||||
let edit_shortcuts = gtk::Button::builder()
|
||||
.label("Edit Shortcuts")
|
||||
.label(tr("Edit Shortcuts"))
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
set_accessible_label(&edit_shortcuts, "Edit shortcuts");
|
||||
set_accessible_label(&edit_shortcuts, tr("Edit shortcuts"));
|
||||
let compact = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
compact.set_halign(gtk::Align::End);
|
||||
compact.append(&compact_summary);
|
||||
@@ -208,7 +210,7 @@ pub(super) fn binding_row(
|
||||
*seen_chips.borrow_mut() = labels;
|
||||
}
|
||||
let summary_text = if bindings.is_empty() {
|
||||
"Unbound".to_string()
|
||||
tr("Unbound").to_string()
|
||||
} else {
|
||||
serialize_bindings(bindings)
|
||||
};
|
||||
@@ -236,7 +238,7 @@ pub(super) fn binding_row(
|
||||
let caption_text = match &parse_error {
|
||||
Some(message) => message.clone(),
|
||||
None if manager_row.internal_conflict => {
|
||||
"This action lists the same shortcut twice.".to_string()
|
||||
tr("This action lists the same shortcut twice.").to_string()
|
||||
}
|
||||
None => {
|
||||
let default = app
|
||||
@@ -245,9 +247,9 @@ pub(super) fn binding_row(
|
||||
.value_for(field)
|
||||
.unwrap_or_default();
|
||||
if default.trim().is_empty() {
|
||||
"Default: Unbound".to_string()
|
||||
tr("Default: Unbound").to_string()
|
||||
} else {
|
||||
format!("Default: {default}")
|
||||
trf("Default: {}", &[&default])
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -336,7 +338,7 @@ fn shortcut_chip(
|
||||
.css_classes(["shortcut-chip-key"])
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
let remove_label = format!("Remove {label_text}");
|
||||
let remove_label = trf("Remove {}", &[&label_text]);
|
||||
let remove = gtk::Button::builder()
|
||||
.icon_name("edit-clear-symbolic")
|
||||
.tooltip_text(&remove_label)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::{ComponentSender, gtk};
|
||||
|
||||
use gtk::glib::object::IsA;
|
||||
@@ -28,7 +30,7 @@ impl TextEditorPopover {
|
||||
.hexpand(true)
|
||||
.width_chars(28)
|
||||
.build();
|
||||
set_accessible_label(&entry, "Shortcut list");
|
||||
set_accessible_label(&entry, tr("Shortcut list"));
|
||||
let entry_handler = {
|
||||
let sender = sender.clone();
|
||||
entry.connect_changed(move |entry| {
|
||||
@@ -43,18 +45,18 @@ impl TextEditorPopover {
|
||||
.build();
|
||||
|
||||
let apply = gtk::Button::builder()
|
||||
.label("Apply")
|
||||
.label(tr("Apply"))
|
||||
.css_classes(["suggested-action"])
|
||||
.build();
|
||||
set_accessible_label(&apply, "Apply shortcut text");
|
||||
set_accessible_label(&apply, tr("Apply shortcut text"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
apply.connect_clicked(move |_| {
|
||||
sender.input(Message::ShortcutTextEditApplied);
|
||||
});
|
||||
}
|
||||
let cancel = gtk::Button::builder().label("Cancel").build();
|
||||
set_accessible_label(&cancel, "Cancel text editing");
|
||||
let cancel = gtk::Button::builder().label(tr("Cancel")).build();
|
||||
set_accessible_label(&cancel, tr("Cancel text editing"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
cancel.connect_clicked(move |_| {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Filter, sort, category scope, and bulk reset chrome for the shortcut manager.
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{gtk, prelude::*};
|
||||
|
||||
use gtk::prelude::*;
|
||||
@@ -33,7 +35,11 @@ fn category_bar(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> gtk::ScrolledWindow {
|
||||
let row = gtk::Box::new(gtk::Orientation::Horizontal, 6);
|
||||
let all = scope_button("All Categories", sender, Message::ShortcutManagerShowAll);
|
||||
let all = scope_button(
|
||||
tr("All Categories"),
|
||||
sender,
|
||||
Message::ShortcutManagerShowAll,
|
||||
);
|
||||
row.append(&all);
|
||||
let mut category_buttons = Vec::new();
|
||||
for tab in KeybindingsTabId::ALL {
|
||||
@@ -101,7 +107,7 @@ fn actions_bar(
|
||||
.map(|sort| sort.title())
|
||||
.collect();
|
||||
let sort = gtk::DropDown::from_strings(&labels);
|
||||
set_accessible_label(&sort, "Sort shortcuts");
|
||||
set_accessible_label(&sort, tr("Sort shortcuts"));
|
||||
{
|
||||
let sender = sender.clone();
|
||||
sort.connect_selected_notify(move |dropdown| {
|
||||
@@ -112,18 +118,18 @@ fn actions_bar(
|
||||
});
|
||||
}
|
||||
|
||||
let reset_visible = gtk::Button::with_label("Reset Visible");
|
||||
set_accessible_label(&reset_visible, "Reset visible keybindings");
|
||||
let reset_visible = gtk::Button::with_label(tr("Reset Visible"));
|
||||
set_accessible_label(&reset_visible, tr("Reset visible keybindings"));
|
||||
connect_clicked(
|
||||
&reset_visible,
|
||||
sender,
|
||||
Message::ShortcutResetVisibleRequested,
|
||||
);
|
||||
let reset_all = gtk::Button::with_label("Reset All");
|
||||
set_accessible_label(&reset_all, "Reset all keybindings");
|
||||
let reset_all = gtk::Button::with_label(tr("Reset All"));
|
||||
set_accessible_label(&reset_all, tr("Reset all keybindings"));
|
||||
connect_clicked(&reset_all, sender, Message::ShortcutResetAllRequested);
|
||||
let review = gtk::Button::with_label("Review Conflicts");
|
||||
set_accessible_label(&review, "Review shortcut conflicts");
|
||||
let review = gtk::Button::with_label(tr("Review Conflicts"));
|
||||
set_accessible_label(&review, tr("Review shortcut conflicts"));
|
||||
connect_clicked(&review, sender, Message::ShortcutConflictReviewStarted);
|
||||
|
||||
let row = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
@@ -170,23 +176,23 @@ fn reset_banner(
|
||||
bindings: &mut Vec<Binding>,
|
||||
) -> gtk::Revealer {
|
||||
let confirm_visible = gtk::Button::builder()
|
||||
.label("Confirm Reset Visible")
|
||||
.label(tr("Confirm Reset Visible"))
|
||||
.css_classes(["destructive-action"])
|
||||
.build();
|
||||
set_accessible_label(&confirm_visible, "Confirm reset visible");
|
||||
set_accessible_label(&confirm_visible, tr("Confirm reset visible"));
|
||||
connect_clicked(
|
||||
&confirm_visible,
|
||||
sender,
|
||||
Message::ShortcutResetVisibleConfirmed,
|
||||
);
|
||||
let confirm_all = gtk::Button::builder()
|
||||
.label("Confirm Reset All")
|
||||
.label(tr("Confirm Reset All"))
|
||||
.css_classes(["destructive-action"])
|
||||
.build();
|
||||
set_accessible_label(&confirm_all, "Confirm reset all");
|
||||
set_accessible_label(&confirm_all, tr("Confirm reset all"));
|
||||
connect_clicked(&confirm_all, sender, Message::ShortcutResetAllConfirmed);
|
||||
let cancel = gtk::Button::with_label("Cancel");
|
||||
set_accessible_label(&cancel, "Cancel keybinding reset");
|
||||
let cancel = gtk::Button::with_label(tr("Cancel"));
|
||||
set_accessible_label(&cancel, tr("Cancel keybinding reset"));
|
||||
connect_clicked(&cancel, sender, Message::ShortcutResetCanceled);
|
||||
|
||||
let buttons = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
||||
@@ -206,7 +212,7 @@ fn reset_banner(
|
||||
set_visible(&confirm_all, all_armed);
|
||||
set_visible(&cancel, visible_armed || all_armed);
|
||||
if let Some(fields) = app.pending_shortcut_reset_visible_fields() {
|
||||
let label = format!("Confirm Reset Visible ({})", fields.len());
|
||||
let label = trf("Confirm Reset Visible ({})", &[&fields.len()]);
|
||||
if confirm_visible.label().as_deref() != Some(label.as_str()) {
|
||||
confirm_visible.set_label(&label);
|
||||
}
|
||||
|
||||
@@ -96,6 +96,11 @@ pub(super) fn field_canceled(
|
||||
) {
|
||||
let sender = sender.clone();
|
||||
popover.connect_closed(move |_| {
|
||||
// Closed by a page rebuild rather than by the user: the edit is
|
||||
// still the model's, and the rebuilt page reopens it.
|
||||
if super::super::replacing_pages() {
|
||||
return;
|
||||
}
|
||||
sender.input(to_message(field));
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,8 @@ use relm4::{adw, gtk};
|
||||
use adw::prelude::*;
|
||||
use gtk::glib::SignalHandlerId;
|
||||
|
||||
use wayscriber::i18n::trf;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::TabId;
|
||||
|
||||
@@ -46,6 +48,37 @@ use super::state::ConfiguratorApp;
|
||||
/// is handed cannot drift apart.
|
||||
pub(crate) type Binding = Box<dyn FnMut(&ConfiguratorApp, &AppSearchSummary)>;
|
||||
|
||||
thread_local! {
|
||||
static REPLACING_PAGES: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// Marks the pages as being replaced for as long as it lives.
|
||||
///
|
||||
/// A language change rebuilds every page from the model. Tearing the old
|
||||
/// pages down closes any popover they own, and a closing editor popover
|
||||
/// normally reports that as the user canceling the edit. While this guard is
|
||||
/// alive those signals are the rebuild's, not the user's, so they are not
|
||||
/// sent: the model keeps the edit, and the new page reopens it.
|
||||
pub(crate) struct ReplacingPages(());
|
||||
|
||||
impl ReplacingPages {
|
||||
pub(crate) fn begin() -> Self {
|
||||
REPLACING_PAGES.with(|flag| flag.set(true));
|
||||
Self(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ReplacingPages {
|
||||
fn drop(&mut self) {
|
||||
REPLACING_PAGES.with(|flag| flag.set(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the pages are being replaced right now; see [`ReplacingPages`].
|
||||
pub(crate) fn replacing_pages() -> bool {
|
||||
REPLACING_PAGES.with(std::cell::Cell::get)
|
||||
}
|
||||
|
||||
/// Writes editable text the model owns without reporting it as a user edit.
|
||||
///
|
||||
/// Every refresh that writes a control the user can also drive goes through a
|
||||
@@ -83,7 +116,7 @@ pub(crate) struct BuiltPage {
|
||||
/// Stable `GtkStack` child name for a tab (deep links and view sync key off
|
||||
/// the model's `active_tab`, never off these strings).
|
||||
pub(crate) fn stack_name(tab: TabId) -> &'static str {
|
||||
tab.title()
|
||||
tab.stack_name()
|
||||
}
|
||||
|
||||
/// Builds every page in sidebar order.
|
||||
@@ -117,7 +150,10 @@ pub(crate) fn build_all(sender: &ComponentSender<ConfiguratorApp>) -> Vec<(TabId
|
||||
pub(crate) fn validate_u32_range(value: &str, min: u32, max: u32) -> Option<String> {
|
||||
match value.trim().parse::<u32>() {
|
||||
Ok(parsed) if (min..=max).contains(&parsed) => None,
|
||||
_ => Some(format!("Enter a whole number between {min} and {max}.")),
|
||||
_ => Some(trf(
|
||||
"Enter a whole number between {} and {}.",
|
||||
&[&min, &max],
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,3 +420,6 @@ impl PageBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -5,6 +5,8 @@
|
||||
//! `SearchArea`s, and metadata-driven labels/help come from the shared
|
||||
//! `performance_field_metadata` table.
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::prelude::*;
|
||||
use wayscriber::config::{PerformanceFieldId, performance_field_metadata};
|
||||
|
||||
@@ -34,7 +36,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Performance);
|
||||
|
||||
page.group_in_area("Rendering", SearchArea::PerformanceRendering)
|
||||
page.group_in_area(tr("Rendering"), SearchArea::PerformanceRendering)
|
||||
.combo_row(
|
||||
buffer_metadata.label,
|
||||
buffer_metadata.help,
|
||||
@@ -59,7 +61,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
);
|
||||
|
||||
page.group_in_area("Animations", SearchArea::PerformanceAnimations)
|
||||
page.group_in_area(tr("Animations"), SearchArea::PerformanceAnimations)
|
||||
.entry_row_validated(
|
||||
animation_metadata.label,
|
||||
|app| app.draft.performance_ui_animation_fps.clone(),
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
mod color;
|
||||
mod rows;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::adw;
|
||||
use relm4::prelude::*;
|
||||
|
||||
@@ -35,17 +37,17 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let counts: Vec<usize> = (PRESET_SLOTS_MIN..=PRESET_SLOTS_MAX).collect();
|
||||
let count_labels: Vec<String> = counts.iter().map(usize::to_string).collect();
|
||||
page.group_in_area("Preset Slots", SearchArea::PresetControls)
|
||||
page.group_in_area(tr("Preset Slots"), SearchArea::PresetControls)
|
||||
.combo_row(
|
||||
"Visible slots",
|
||||
"How many preset slots the toolbar offers.",
|
||||
tr("Visible slots"),
|
||||
tr("How many preset slots the toolbar offers."),
|
||||
counts,
|
||||
count_labels,
|
||||
|app| app.draft.presets.slot_count,
|
||||
Message::PresetSlotCountChanged,
|
||||
);
|
||||
|
||||
page.group("Slots").group_visible_when(|app| {
|
||||
page.group(tr("Slots")).group_visible_when(|app| {
|
||||
let search = app.search_summary();
|
||||
(1..=PRESET_SLOTS_MAX).any(|slot| slot_visible(app, &search, slot))
|
||||
});
|
||||
@@ -77,7 +79,7 @@ fn slot_visible(app: &ConfiguratorApp, search: &AppSearchSummary, slot: usize) -
|
||||
|
||||
fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
let expander = adw::ExpanderRow::builder()
|
||||
.title(format!("Slot {slot} settings"))
|
||||
.title(trf("Slot {} settings", &[&slot]))
|
||||
.show_enable_switch(true)
|
||||
.build();
|
||||
|
||||
@@ -103,15 +105,15 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
|
||||
let reset = slot_button(
|
||||
page,
|
||||
"Reset",
|
||||
"Restore this slot's default settings",
|
||||
tr("Reset"),
|
||||
tr("Restore this slot's default settings"),
|
||||
Message::PresetResetSlot(slot),
|
||||
);
|
||||
expander.add_suffix(&reset);
|
||||
let duplicate = slot_button(
|
||||
page,
|
||||
"Duplicate",
|
||||
"Copy this slot into the next one",
|
||||
tr("Duplicate"),
|
||||
tr("Copy this slot into the next one"),
|
||||
Message::PresetDuplicateSlot(slot),
|
||||
);
|
||||
// The last slot has nowhere to copy to, exactly as the Iced header's
|
||||
@@ -156,7 +158,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
}
|
||||
|
||||
let subtitle = if !draft.enabled {
|
||||
"Slot disabled. Enable to configure.".to_string()
|
||||
tr("Slot disabled. Enable to configure.").to_string()
|
||||
} else if draft.name.trim().is_empty() {
|
||||
draft.tool.label().to_string()
|
||||
} else {
|
||||
@@ -175,7 +177,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
};
|
||||
|
||||
rows.entry_row(
|
||||
"Label",
|
||||
tr("Label"),
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.name.clone()),
|
||||
move |slot, value| Message::PresetTextChanged(slot, PresetTextField::Name, value),
|
||||
);
|
||||
@@ -183,7 +185,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
let tools = ToolOption::list();
|
||||
let tool_labels = labels_of(&tools, ToolOption::label);
|
||||
rows.combo_row(
|
||||
"Tool",
|
||||
tr("Tool"),
|
||||
tools,
|
||||
tool_labels,
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.tool),
|
||||
@@ -193,12 +195,12 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
build_color_rows(&mut rows, slot);
|
||||
|
||||
rows.entry_row(
|
||||
"Size (px)",
|
||||
tr("Size (px)"),
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.size.clone()),
|
||||
move |slot, value| Message::PresetTextChanged(slot, PresetTextField::Size, value),
|
||||
);
|
||||
rows.entry_row(
|
||||
"Marker opacity (0.05-0.9)",
|
||||
tr("Marker opacity (0.05-0.9)"),
|
||||
move |app| {
|
||||
app.draft
|
||||
.presets
|
||||
@@ -211,7 +213,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
let kinds = PresetEraserKindOption::list();
|
||||
let kind_labels = labels_of(&kinds, PresetEraserKindOption::label);
|
||||
rows.combo_row(
|
||||
"Eraser kind",
|
||||
tr("Eraser kind"),
|
||||
kinds,
|
||||
kind_labels,
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.eraser_kind),
|
||||
@@ -220,18 +222,20 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
let modes = PresetEraserModeOption::list();
|
||||
let mode_labels = labels_of(&modes, PresetEraserModeOption::label);
|
||||
rows.combo_row(
|
||||
"Eraser mode",
|
||||
tr("Eraser mode"),
|
||||
modes,
|
||||
mode_labels,
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.eraser_mode),
|
||||
Message::PresetEraserModeChanged,
|
||||
);
|
||||
|
||||
rows.override_row("Fill enabled", PresetToggleField::FillEnabled, move |app| {
|
||||
app.draft.presets.slot(slot).map(|draft| draft.fill_enabled)
|
||||
});
|
||||
rows.override_row(
|
||||
"Text background",
|
||||
tr("Fill enabled"),
|
||||
PresetToggleField::FillEnabled,
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.fill_enabled),
|
||||
);
|
||||
rows.override_row(
|
||||
tr("Text background"),
|
||||
PresetToggleField::TextBackgroundEnabled,
|
||||
move |app| {
|
||||
app.draft
|
||||
@@ -242,7 +246,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
);
|
||||
|
||||
rows.entry_row(
|
||||
"Font size (pt)",
|
||||
tr("Font size (pt)"),
|
||||
move |app| {
|
||||
app.draft
|
||||
.presets
|
||||
@@ -252,7 +256,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
move |slot, value| Message::PresetTextChanged(slot, PresetTextField::FontSize, value),
|
||||
);
|
||||
rows.entry_row(
|
||||
"Arrow length (px)",
|
||||
tr("Arrow length (px)"),
|
||||
move |app| {
|
||||
app.draft
|
||||
.presets
|
||||
@@ -262,7 +266,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ArrowLength, value),
|
||||
);
|
||||
rows.entry_row(
|
||||
"Arrow angle (deg)",
|
||||
tr("Arrow angle (deg)"),
|
||||
move |app| {
|
||||
app.draft
|
||||
.presets
|
||||
@@ -272,7 +276,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
move |slot, value| Message::PresetTextChanged(slot, PresetTextField::ArrowAngle, value),
|
||||
);
|
||||
rows.override_row(
|
||||
"Arrow head at end",
|
||||
tr("Arrow head at end"),
|
||||
PresetToggleField::ArrowHeadAtEnd,
|
||||
move |app| {
|
||||
app.draft
|
||||
@@ -282,7 +286,7 @@ fn build_slot(page: &mut PageBuilder, slot: usize) {
|
||||
},
|
||||
);
|
||||
rows.override_row(
|
||||
"Show status bar",
|
||||
tr("Show status bar"),
|
||||
PresetToggleField::ShowStatusBar,
|
||||
move |app| {
|
||||
app.draft
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::rows::{SlotBuilder, color_swatch, labels_of};
|
||||
use relm4::adw::prelude::*;
|
||||
|
||||
@@ -10,9 +12,9 @@ use super::super::validate_u32_range;
|
||||
/// rows the chosen mode edits.
|
||||
pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) {
|
||||
let mode_row = rows.combo_row(
|
||||
"Color mode",
|
||||
tr("Color mode"),
|
||||
vec![ColorMode::Named, ColorMode::Rgb],
|
||||
vec!["Named".to_string(), "RGB".to_string()],
|
||||
vec![tr("Named").to_string(), "RGB".to_string()],
|
||||
move |app| app.draft.presets.slot(slot).map(|draft| draft.color.mode),
|
||||
Message::PresetColorModeChanged,
|
||||
);
|
||||
@@ -48,7 +50,7 @@ pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) {
|
||||
let named = NamedColorOption::list();
|
||||
let named_labels = labels_of(&named, NamedColorOption::label);
|
||||
let named_row = rows.combo_row(
|
||||
"Named color",
|
||||
tr("Named color"),
|
||||
named,
|
||||
named_labels,
|
||||
move |app| {
|
||||
@@ -67,7 +69,7 @@ pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) {
|
||||
});
|
||||
|
||||
let custom_name = rows.entry_row_validated(
|
||||
"Custom color name",
|
||||
tr("Custom color name"),
|
||||
move |app| {
|
||||
app.draft
|
||||
.presets
|
||||
@@ -81,7 +83,7 @@ pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) {
|
||||
return None;
|
||||
}
|
||||
(color.preview_color().is_none() && !color.name.trim().is_empty())
|
||||
.then(|| "Unknown color name.".to_string())
|
||||
.then(|| tr("Unknown color name.").to_string())
|
||||
},
|
||||
);
|
||||
rows.visible_when(&custom_name, move |app| {
|
||||
@@ -91,9 +93,9 @@ pub(super) fn build_color_rows(rows: &mut SlotBuilder<'_>, slot: usize) {
|
||||
});
|
||||
|
||||
for (component, title) in [
|
||||
(0usize, "Red (0-255)"),
|
||||
(1, "Green (0-255)"),
|
||||
(2, "Blue (0-255)"),
|
||||
(0usize, tr("Red (0-255)")),
|
||||
(1, tr("Green (0-255)")),
|
||||
(2, tr("Blue (0-255)")),
|
||||
] {
|
||||
let row = rows.entry_row_validated(
|
||||
title,
|
||||
|
||||
@@ -29,6 +29,8 @@ mod section;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::prelude::*;
|
||||
use relm4::{adw, gtk};
|
||||
|
||||
@@ -57,15 +59,15 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
.map(|option| option.label().to_string())
|
||||
.collect();
|
||||
|
||||
page.group_in_area("Render Profiles", SearchArea::RenderProfilesGeneral)
|
||||
page.group_in_area(tr("Render Profiles"), SearchArea::RenderProfilesGeneral)
|
||||
.switch_row(
|
||||
"Preview canvas",
|
||||
tr("Preview canvas"),
|
||||
"",
|
||||
|app| app.draft.render_profiles.apply_to_canvas,
|
||||
Message::RenderProfileApplyCanvasChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Preview UI",
|
||||
tr("Preview UI"),
|
||||
"",
|
||||
|app| app.draft.render_profiles.apply_to_ui,
|
||||
Message::RenderProfileApplyUiChanged,
|
||||
@@ -74,7 +76,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
add_active_row(&mut page);
|
||||
|
||||
page.combo_row(
|
||||
"Canvas export profile",
|
||||
tr("Canvas export profile"),
|
||||
"",
|
||||
export_options,
|
||||
export_labels,
|
||||
@@ -91,7 +93,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
/// The startup profile: `Off` plus one entry per profile id.
|
||||
fn add_active_row(page: &mut PageBuilder) {
|
||||
let row = adw::ComboRow::builder().title("Startup profile").build();
|
||||
let row = adw::ComboRow::builder()
|
||||
.title(tr("Startup profile"))
|
||||
.build();
|
||||
row.set_model(Some(>k::StringList::new(&[])));
|
||||
let handler = {
|
||||
let sender = page.sender();
|
||||
@@ -130,7 +134,7 @@ fn add_active_row(page: &mut PageBuilder) {
|
||||
/// mode was `Named profile`.
|
||||
fn add_export_profile_row(page: &mut PageBuilder) {
|
||||
let row = adw::ComboRow::builder()
|
||||
.title("Named export profile")
|
||||
.title(tr("Named export profile"))
|
||||
.build();
|
||||
row.set_model(Some(>k::StringList::new(&[])));
|
||||
// Hidden until the export mode asks for it, so it cannot flash on the
|
||||
@@ -163,7 +167,7 @@ fn add_export_profile_row(page: &mut PageBuilder) {
|
||||
|
||||
fn add_add_button(page: &mut PageBuilder) {
|
||||
let button = gtk::Button::builder()
|
||||
.label("Add profile")
|
||||
.label(tr("Add profile"))
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_top(12)
|
||||
.build();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::rows::{connect_button, plain_row, row_content_box, side_label};
|
||||
use relm4::{ComponentSender, gtk};
|
||||
|
||||
@@ -63,7 +65,7 @@ pub(super) fn build_mapping_row(
|
||||
content.append(&to.swatch);
|
||||
|
||||
let remove = gtk::Button::builder()
|
||||
.label("Remove")
|
||||
.label(tr("Remove"))
|
||||
.valign(gtk::Align::Center)
|
||||
.halign(gtk::Align::End)
|
||||
.hexpand(true)
|
||||
@@ -138,7 +140,7 @@ pub(super) fn build_add_mapping_row(
|
||||
) -> gtk::ListBoxRow {
|
||||
let content = row_content_box();
|
||||
let button = gtk::Button::builder()
|
||||
.label("Add mapping")
|
||||
.label(tr("Add mapping"))
|
||||
.halign(gtk::Align::Start)
|
||||
.build();
|
||||
connect_button(&button, Message::RenderProfileMappingAdd(index), sender);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -18,14 +20,14 @@ pub(super) fn build_header_row(
|
||||
content.append(title);
|
||||
|
||||
let duplicate = gtk::Button::builder()
|
||||
.label("Duplicate")
|
||||
.label(tr("Duplicate"))
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
connect_button(&duplicate, Message::RenderProfileDuplicate(index), sender);
|
||||
content.append(&duplicate);
|
||||
|
||||
let remove = gtk::Button::builder()
|
||||
.label("Delete")
|
||||
.label(tr("Delete"))
|
||||
.valign(gtk::Align::Center)
|
||||
.css_classes(["destructive-action"])
|
||||
.build();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::mapping::{MappingRow, build_add_mapping_row, build_mapping_row};
|
||||
use super::rows::{build_header_row, build_text_row};
|
||||
use relm4::{ComponentSender, gtk};
|
||||
@@ -67,11 +69,16 @@ fn build_section(
|
||||
.build();
|
||||
section.append(&build_header_row(index, &title, sender));
|
||||
|
||||
let id = build_text_row("Profile id", index, RenderProfileTextField::Id, sender);
|
||||
let id = build_text_row(tr("Profile id"), index, RenderProfileTextField::Id, sender);
|
||||
id.row.set_visible(layout.controls);
|
||||
section.append(&id.row);
|
||||
|
||||
let name = build_text_row("Display name", index, RenderProfileTextField::Name, sender);
|
||||
let name = build_text_row(
|
||||
tr("Display name"),
|
||||
index,
|
||||
RenderProfileTextField::Name,
|
||||
sender,
|
||||
);
|
||||
name.row.set_visible(layout.controls);
|
||||
section.append(&name.row);
|
||||
|
||||
@@ -89,7 +96,7 @@ fn build_section(
|
||||
|
||||
let refresh: ProfileRowRefresh = Box::new(move |values| {
|
||||
let heading = if values.name.trim().is_empty() {
|
||||
"Profile"
|
||||
tr("Profile")
|
||||
} else {
|
||||
values.name.trim()
|
||||
};
|
||||
|
||||
@@ -8,6 +8,8 @@ mod card;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::gtk;
|
||||
use relm4::prelude::*;
|
||||
|
||||
@@ -22,24 +24,30 @@ use super::super::PageBuilder;
|
||||
use card::{BoundCatalogCard, rebuild_items};
|
||||
|
||||
pub(super) fn add(page: &mut PageBuilder) {
|
||||
page.group_in_area("Saved Sessions", SearchArea::SessionCatalog);
|
||||
page.group_in_area(tr("Saved Sessions"), SearchArea::SessionCatalog);
|
||||
|
||||
let sender = page.sender();
|
||||
let body = column_box();
|
||||
let refresh = message_button("Refresh", &sender, Message::SessionCatalogRefreshRequested);
|
||||
let refresh = message_button(
|
||||
tr("Refresh"),
|
||||
&sender,
|
||||
Message::SessionCatalogRefreshRequested,
|
||||
);
|
||||
let toolbar = row_box();
|
||||
toolbar.append(&refresh);
|
||||
body.append(&toolbar);
|
||||
body.append(&hint_label(
|
||||
body.append(&hint_label(tr(
|
||||
"Clear Tool State applies config defaults without deleting boards.",
|
||||
));
|
||||
body.append(&hint_label("Clear Saved Data removes saved session files."));
|
||||
)));
|
||||
body.append(&hint_label(tr(
|
||||
"Clear Saved Data removes saved session files.",
|
||||
)));
|
||||
|
||||
let blocker_label = warning_label("");
|
||||
body.append(&blocker_label);
|
||||
let loading_label = body_label("Loading sessions...");
|
||||
let loading_label = body_label(tr("Loading sessions..."));
|
||||
body.append(&loading_label);
|
||||
let empty_label = body_label("No named sessions in the catalog yet.");
|
||||
let empty_label = body_label(tr("No named sessions in the catalog yet."));
|
||||
body.append(&empty_label);
|
||||
|
||||
let list = gtk::Box::builder()
|
||||
@@ -134,9 +142,9 @@ fn catalog_layout(app: &ConfiguratorApp) -> CatalogLayout {
|
||||
display_name: item.display_name.clone(),
|
||||
path_label: item.path_label.clone(),
|
||||
canonical_path_label: item.canonical_path_label.clone(),
|
||||
created_label: item.created_label.clone(),
|
||||
last_opened_label: item.last_opened_label.clone(),
|
||||
last_saved_label: item.last_saved_label.clone(),
|
||||
created_label: item.created_label(),
|
||||
last_opened_label: item.last_opened_label(),
|
||||
last_saved_label: item.last_saved_label(),
|
||||
artifacts: item.artifacts.status_label(),
|
||||
})
|
||||
.collect(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Construction and in-place refresh of one saved-session catalog card.
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::gtk;
|
||||
use relm4::prelude::*;
|
||||
|
||||
@@ -92,19 +94,19 @@ fn item_card(
|
||||
|
||||
content.append(&caption_label(&item.path_label));
|
||||
if let Some(canonical) = item.canonical_path_label.as_deref() {
|
||||
content.append(&caption_label(&format!("Canonical: {canonical}")));
|
||||
content.append(&caption_label(&trf("Canonical: {}", &[&canonical])));
|
||||
}
|
||||
let times = row_box();
|
||||
times.append(&hint_label(&format!("Created: {}", item.created_label)));
|
||||
times.append(&hint_label(&format!("Opened: {}", item.last_opened_label)));
|
||||
times.append(&hint_label(&format!("Saved: {}", item.last_saved_label)));
|
||||
times.append(&hint_label(&trf("Created: {}", &[&item.created_label])));
|
||||
times.append(&hint_label(&trf("Opened: {}", &[&item.last_opened_label])));
|
||||
times.append(&hint_label(&trf("Saved: {}", &[&item.last_saved_label])));
|
||||
content.append(×);
|
||||
|
||||
let rename = input_row(
|
||||
InputRow {
|
||||
id: &item.id,
|
||||
placeholder: "Display name",
|
||||
button_label: "Save Name",
|
||||
placeholder: tr("Display name"),
|
||||
button_label: tr("Save Name"),
|
||||
on_input: Message::SessionCatalogRenameInputChanged,
|
||||
request: Message::SessionCatalogRenameRequested(item.id.clone()),
|
||||
},
|
||||
@@ -114,8 +116,8 @@ fn item_card(
|
||||
let duplicate = input_row(
|
||||
InputRow {
|
||||
id: &item.id,
|
||||
placeholder: "Duplicate target path",
|
||||
button_label: "Duplicate",
|
||||
placeholder: tr("Duplicate target path"),
|
||||
button_label: tr("Duplicate"),
|
||||
on_input: Message::SessionCatalogDuplicateInputChanged,
|
||||
request: Message::SessionCatalogDuplicateRequested(item.id.clone()),
|
||||
},
|
||||
@@ -125,8 +127,8 @@ fn item_card(
|
||||
let move_row = input_row(
|
||||
InputRow {
|
||||
id: &item.id,
|
||||
placeholder: "Move target path",
|
||||
button_label: "Move",
|
||||
placeholder: tr("Move target path"),
|
||||
button_label: tr("Move"),
|
||||
on_input: Message::SessionCatalogMoveInputChanged,
|
||||
request: Message::SessionCatalogMoveRequested(item.id.clone()),
|
||||
},
|
||||
@@ -136,13 +138,13 @@ fn item_card(
|
||||
|
||||
let actions = row_box();
|
||||
let reveal = message_button(
|
||||
"Reveal File",
|
||||
tr("Reveal File"),
|
||||
sender,
|
||||
Message::SessionCatalogRevealRequested(item.id.clone()),
|
||||
);
|
||||
actions.append(&reveal);
|
||||
let tool_state = message_button(
|
||||
"Clear Tool State",
|
||||
tr("Clear Tool State"),
|
||||
sender,
|
||||
Message::SessionCatalogClearToolStateRequested(item.id.clone()),
|
||||
);
|
||||
@@ -151,7 +153,7 @@ fn item_card(
|
||||
|
||||
let danger = row_box();
|
||||
let clear = message_button(
|
||||
"Clear Saved Data",
|
||||
tr("Clear Saved Data"),
|
||||
sender,
|
||||
Message::SessionCatalogClearRequested(item.id.clone()),
|
||||
);
|
||||
@@ -162,14 +164,14 @@ fn item_card(
|
||||
// pending id swaps which one is visible.
|
||||
let confirm = row_box();
|
||||
let confirm_button = message_button(
|
||||
"Confirm Clear",
|
||||
tr("Confirm Clear"),
|
||||
sender,
|
||||
Message::SessionCatalogClearConfirmed(item.id.clone()),
|
||||
);
|
||||
confirm_button.add_css_class("destructive-action");
|
||||
confirm.append(&confirm_button);
|
||||
let cancel_button = message_button(
|
||||
"Cancel",
|
||||
tr("Cancel"),
|
||||
sender,
|
||||
Message::SessionCatalogClearCanceled(item.id.clone()),
|
||||
);
|
||||
@@ -178,7 +180,7 @@ fn item_card(
|
||||
danger.append(&confirm);
|
||||
|
||||
let forget = message_button(
|
||||
"Forget",
|
||||
tr("Forget"),
|
||||
sender,
|
||||
Message::SessionCatalogForgetRequested(item.id.clone()),
|
||||
);
|
||||
|
||||
@@ -11,9 +11,9 @@ fn test_item(id: &str, display_name: &str) -> SessionCatalogItem {
|
||||
path: PathBuf::from(format!("/tmp/{id}.wayscriber-session")),
|
||||
path_label: format!("/tmp/{id}.wayscriber-session"),
|
||||
canonical_path_label: None,
|
||||
created_label: "2026-01-01 10:00".to_string(),
|
||||
last_opened_label: "Never".to_string(),
|
||||
last_saved_label: "Never".to_string(),
|
||||
created_at_millis: 1_767_261_600_000,
|
||||
last_opened_at_millis: None,
|
||||
last_saved_at_millis: None,
|
||||
artifacts: SessionArtifactSummary {
|
||||
primary_exists: true,
|
||||
backup_exists: false,
|
||||
@@ -176,3 +176,29 @@ fn catalog_focus_fallback_leaves_the_tab_chain_when_busy_work_finishes() {
|
||||
"ordinary idle browsing does not add or manage a structural tab stop"
|
||||
);
|
||||
}
|
||||
|
||||
/// The catalog can load before the window's language is known, and the
|
||||
/// language can change while the list is up: the "Never" a card shows is
|
||||
/// chosen when the layout is drawn, and a new language rebuilds the card.
|
||||
#[test]
|
||||
fn card_labels_follow_the_language_the_list_is_drawn_in() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::card_labels_follow_the_language_the_list_is_drawn_in"
|
||||
),
|
||||
|| {
|
||||
use wayscriber::domain::Language;
|
||||
let app = app_with_items(vec![test_item("one", "First")]);
|
||||
let english = catalog_layout(&app);
|
||||
assert_eq!(english.items[0].last_opened_label, "Never");
|
||||
|
||||
wayscriber::i18n::set_language(Language::SimplifiedChinese);
|
||||
let chinese = catalog_layout(&app);
|
||||
|
||||
assert_eq!(chinese.items[0].last_opened_label, "从不");
|
||||
assert_eq!(chinese.items[0].last_saved_label, "从不");
|
||||
assert_ne!(chinese, english, "a new language rebuilds the card");
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Session persistence, storage, and autosave settings.
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -11,47 +13,47 @@ use super::super::super::search::SearchArea;
|
||||
use super::super::{PageBuilder, set_text_blocked};
|
||||
|
||||
pub(super) fn add(page: &mut PageBuilder) {
|
||||
page.group_in_area("Session Persistence", SearchArea::SessionPersistence)
|
||||
page.group_in_area(tr("Session Persistence"), SearchArea::SessionPersistence)
|
||||
.switch_row(
|
||||
"Persist transparent mode drawings",
|
||||
tr("Persist transparent mode drawings"),
|
||||
"",
|
||||
|app| app.draft.session_persist_transparent,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionPersistTransparent, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Persist whiteboard mode drawings",
|
||||
tr("Persist whiteboard mode drawings"),
|
||||
"",
|
||||
|app| app.draft.session_persist_whiteboard,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionPersistWhiteboard, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Persist blackboard mode drawings",
|
||||
tr("Persist blackboard mode drawings"),
|
||||
"",
|
||||
|app| app.draft.session_persist_blackboard,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionPersistBlackboard, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Persist undo/redo history",
|
||||
tr("Persist undo/redo history"),
|
||||
"",
|
||||
|app| app.draft.session_persist_history,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionPersistHistory, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Restore tool state on startup",
|
||||
tr("Restore tool state on startup"),
|
||||
"",
|
||||
|app| app.draft.session_restore_tool_state,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionRestoreToolState, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Per-output persistence",
|
||||
tr("Per-output persistence"),
|
||||
"",
|
||||
|app| app.draft.session_per_output,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionPerOutput, value),
|
||||
);
|
||||
|
||||
page.group_in_area("Storage", SearchArea::SessionPersistence)
|
||||
page.group_in_area(tr("Storage"), SearchArea::SessionPersistence)
|
||||
.combo_row(
|
||||
"Storage mode",
|
||||
tr("Storage mode"),
|
||||
"",
|
||||
SessionStorageModeOption::list(),
|
||||
option_labels(SessionStorageModeOption::list(), |option| option.label()),
|
||||
@@ -60,7 +62,7 @@ pub(super) fn add(page: &mut PageBuilder) {
|
||||
);
|
||||
custom_directory_row(page);
|
||||
page.combo_row(
|
||||
"Compression",
|
||||
tr("Compression"),
|
||||
"",
|
||||
SessionCompressionOption::list(),
|
||||
option_labels(SessionCompressionOption::list(), |option| option.label()),
|
||||
@@ -68,55 +70,55 @@ pub(super) fn add(page: &mut PageBuilder) {
|
||||
Message::SessionCompressionChanged,
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Max shapes per frame",
|
||||
tr("Max shapes per frame"),
|
||||
|app| app.draft.session_max_shapes_per_frame.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionMaxShapesPerFrame, value),
|
||||
|app| validate_whole_number(&app.draft.session_max_shapes_per_frame, 1, u64::MAX),
|
||||
)
|
||||
.entry_row(
|
||||
"Max persisted undo depth (blank = runtime limit)",
|
||||
tr("Max persisted undo depth (blank = runtime limit)"),
|
||||
|app| app.draft.session_max_persisted_undo_depth.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionMaxPersistedUndoDepth, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Max file size (MB)",
|
||||
tr("Max file size (MB)"),
|
||||
|app| app.draft.session_max_file_size_mb.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionMaxFileSizeMb, value),
|
||||
|app| validate_whole_number(&app.draft.session_max_file_size_mb, 1, 1024),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Auto-compress threshold (KB)",
|
||||
tr("Auto-compress threshold (KB)"),
|
||||
|app| app.draft.session_auto_compress_threshold_kb.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionAutoCompressThresholdKb, value),
|
||||
|app| validate_whole_number(&app.draft.session_auto_compress_threshold_kb, 1, u64::MAX),
|
||||
)
|
||||
.entry_row(
|
||||
"Backup retention count",
|
||||
tr("Backup retention count"),
|
||||
|app| app.draft.session_backup_retention.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionBackupRetention, value),
|
||||
);
|
||||
|
||||
page.group_in_area("Autosave", SearchArea::SessionPersistence)
|
||||
page.group_in_area(tr("Autosave"), SearchArea::SessionPersistence)
|
||||
.switch_row(
|
||||
"Enable autosave",
|
||||
tr("Enable autosave"),
|
||||
"",
|
||||
|app| app.draft.session_autosave_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::SessionAutosaveEnabled, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Autosave idle (ms)",
|
||||
tr("Autosave idle (ms)"),
|
||||
|app| app.draft.session_autosave_idle_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionAutosaveIdleMs, value),
|
||||
|app| validate_whole_number(&app.draft.session_autosave_idle_ms, 1000, u64::MAX),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Autosave interval (ms)",
|
||||
tr("Autosave interval (ms)"),
|
||||
|app| app.draft.session_autosave_interval_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionAutosaveIntervalMs, value),
|
||||
|app| validate_whole_number(&app.draft.session_autosave_interval_ms, 1000, u64::MAX),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Autosave failure backoff (ms)",
|
||||
tr("Autosave failure backoff (ms)"),
|
||||
|app| app.draft.session_autosave_failure_backoff_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::SessionAutosaveFailureBackoffMs, value),
|
||||
|app| {
|
||||
@@ -139,7 +141,9 @@ fn option_labels<O: Copy>(options: Vec<O>, label: impl Fn(&O) -> &'static str) -
|
||||
/// The custom directory only applies to one storage mode, so it is a row the
|
||||
/// mode shows rather than a row that is always there and usually inert.
|
||||
fn custom_directory_row(page: &mut PageBuilder) {
|
||||
let row = adw::EntryRow::builder().title("Custom directory").build();
|
||||
let row = adw::EntryRow::builder()
|
||||
.title(tr("Custom directory"))
|
||||
.build();
|
||||
let handler = {
|
||||
let sender = page.sender();
|
||||
row.connect_changed(move |row| {
|
||||
@@ -166,15 +170,15 @@ fn custom_directory_row(page: &mut PageBuilder) {
|
||||
/// only the minimum, matching the legacy view's two validators.
|
||||
fn validate_whole_number(value: &str, min: u64, max: u64) -> Option<String> {
|
||||
let Ok(parsed) = value.trim().parse::<u64>() else {
|
||||
return Some("Expected a whole number".to_string());
|
||||
return Some(tr("Expected a whole number").to_string());
|
||||
};
|
||||
if (min..=max).contains(&parsed) {
|
||||
return None;
|
||||
}
|
||||
Some(if max == u64::MAX {
|
||||
format!("Minimum: {min}")
|
||||
trf("Minimum: {}", &[&min])
|
||||
} else {
|
||||
format!("Range: {min}-{max}")
|
||||
trf("Range: {}-{}", &[&min, &max])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//! Compiled only with the `tablet-input` feature, the same gate the draft
|
||||
//! fields and both pressure messages sit behind.
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::prelude::*;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -30,35 +32,35 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Tablet);
|
||||
|
||||
page.group_in_area("Stylus", SearchArea::Tablet)
|
||||
page.group_in_area(tr("Stylus"), SearchArea::Tablet)
|
||||
.switch_row(
|
||||
"Enable tablet input",
|
||||
tr("Enable tablet input"),
|
||||
"",
|
||||
|app| app.draft.tablet_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::TabletEnabled, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Enable pressure-to-thickness",
|
||||
"Freehand Pen strokes only",
|
||||
tr("Enable pressure-to-thickness"),
|
||||
tr("Freehand Pen strokes only"),
|
||||
|app| app.draft.tablet_pressure_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::TabletPressureEnabled, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Auto-switch to eraser",
|
||||
tr("Auto-switch to eraser"),
|
||||
"",
|
||||
|app| app.draft.tablet_auto_eraser_switch,
|
||||
|value| Message::ToggleChanged(ToggleField::TabletAutoEraserSwitch, value),
|
||||
);
|
||||
|
||||
page.group_in_area("Pressure", SearchArea::Tablet)
|
||||
page.group_in_area(tr("Pressure"), SearchArea::Tablet)
|
||||
.entry_row_validated(
|
||||
"Min thickness",
|
||||
tr("Min thickness"),
|
||||
|app| app.draft.tablet_min_thickness.clone(),
|
||||
|value| Message::TextChanged(TextField::TabletMinThickness, value),
|
||||
|app| validate_f64_range(&app.draft.tablet_min_thickness, 1.0, 50.0),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Max thickness",
|
||||
tr("Max thickness"),
|
||||
|app| app.draft.tablet_max_thickness.clone(),
|
||||
|value| Message::TextChanged(TextField::TabletMaxThickness, value),
|
||||
|app| validate_f64_range(&app.draft.tablet_max_thickness, 1.0, 50.0),
|
||||
@@ -66,18 +68,18 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
// The Iced view showed a "Minimum: 0" hint here but never marked the
|
||||
// field in error; the config clamps whatever arrives.
|
||||
.entry_row(
|
||||
"Pressure variation threshold",
|
||||
tr("Pressure variation threshold"),
|
||||
|app| app.draft.tablet_pressure_variation_threshold.clone(),
|
||||
|value| Message::TextChanged(TextField::TabletPressureVariationThreshold, value),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Pressure thickness scale step",
|
||||
tr("Pressure thickness scale step"),
|
||||
|app| app.draft.tablet_pressure_thickness_scale_step.clone(),
|
||||
|value| Message::TextChanged(TextField::TabletPressureScaleStep, value),
|
||||
|app| validate_f64_range(&app.draft.tablet_pressure_thickness_scale_step, 0.0, 1.0),
|
||||
)
|
||||
.combo_row(
|
||||
"Pressure thickness edit mode",
|
||||
tr("Pressure thickness edit mode"),
|
||||
"",
|
||||
edit_modes,
|
||||
edit_mode_labels,
|
||||
@@ -85,7 +87,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::TabletPressureEditModeChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Pressure thickness entry mode",
|
||||
tr("Pressure thickness entry mode"),
|
||||
"",
|
||||
entry_modes,
|
||||
entry_mode_labels,
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use wayscriber::domain::Language;
|
||||
|
||||
use super::validate_u32_range;
|
||||
|
||||
#[test]
|
||||
fn a_whole_number_in_range_is_accepted_at_both_bounds() {
|
||||
for value in ["1", "30", "60", " 60 "] {
|
||||
assert_eq!(validate_u32_range(value, 1, 60), None, "{value:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_invalid_whole_number_names_the_actual_bounds() {
|
||||
for value in ["0", "61", "-1", "2.5", "", "many"] {
|
||||
assert_eq!(
|
||||
validate_u32_range(value, 1, 60).as_deref(),
|
||||
Some("Enter a whole number between 1 and 60."),
|
||||
"{value:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bounds_survive_translation() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(module_path!(), "::the_bounds_survive_translation"),
|
||||
|| {
|
||||
wayscriber::i18n::set_language(Language::SimplifiedChinese);
|
||||
assert_eq!(
|
||||
validate_u32_range("256", 0, 255).as_deref(),
|
||||
Some("请输入 0 到 255 之间的整数。")
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ mod status_bar;
|
||||
mod toolbar;
|
||||
mod toolbar_visibility;
|
||||
|
||||
use wayscriber::i18n::trf;
|
||||
|
||||
use relm4::gtk;
|
||||
use relm4::prelude::*;
|
||||
|
||||
@@ -66,7 +68,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let built = build_ui_tab(sender, tab);
|
||||
bindings.extend(built.bindings);
|
||||
built.widget.set_vexpand(false);
|
||||
let stack_page = stack.add_titled(&built.widget, Some(tab.title()), tab.title());
|
||||
let stack_page = stack.add_titled(&built.widget, Some(tab.stack_name()), tab.title());
|
||||
stack_pages.push((tab, stack_page));
|
||||
}
|
||||
{
|
||||
@@ -75,7 +77,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let Some(name) = stack.visible_child_name() else {
|
||||
return;
|
||||
};
|
||||
if let Some(tab) = UiTabId::ALL.into_iter().find(|tab| tab.title() == name) {
|
||||
if let Some(tab) = UiTabId::from_stack_name(&name) {
|
||||
sender.input(Message::UiTabSelected(tab));
|
||||
}
|
||||
});
|
||||
@@ -123,7 +125,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
stack.set_visible(any_visible);
|
||||
}
|
||||
|
||||
let name = app.active_ui_tab.title();
|
||||
let name = app.active_ui_tab.stack_name();
|
||||
if stack.visible_child_name().as_deref() != Some(name) {
|
||||
stack.set_visible_child_name(name);
|
||||
}
|
||||
@@ -207,6 +209,6 @@ fn quad_color(components: &[String; 4]) -> ResolvedColor {
|
||||
fn validate_f64_range(value: &str, min: f64, max: f64) -> Option<String> {
|
||||
match value.trim().parse::<f64>() {
|
||||
Ok(parsed) if parsed.is_finite() && (min..=max).contains(&parsed) => None,
|
||||
_ => Some(format!("Enter a number between {min} and {max}.")),
|
||||
_ => Some(trf("Enter a number between {} and {}.", &[&min, &max])),
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
use wayscriber::config::{
|
||||
CLICK_HIGHLIGHT_DURATION_MAX_MS, CLICK_HIGHLIGHT_DURATION_MIN_MS, CLICK_HIGHLIGHT_OUTLINE_MAX,
|
||||
@@ -15,35 +17,35 @@ use super::{quad_color, validate_f64_range};
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Click Highlight")
|
||||
page.group(tr("Click Highlight"))
|
||||
.switch_row(
|
||||
"Enable click highlight",
|
||||
tr("Enable click highlight"),
|
||||
"",
|
||||
|app| app.draft.click_highlight_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::UiClickHighlightEnabled, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show ring while highlight tool is active",
|
||||
tr("Show ring while highlight tool is active"),
|
||||
"",
|
||||
|app| app.draft.click_highlight_show_on_highlight_tool,
|
||||
|value| Message::ToggleChanged(ToggleField::UiClickHighlightShowOnHighlightTool, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Link highlight color to current pen",
|
||||
tr("Link highlight color to current pen"),
|
||||
"",
|
||||
|app| app.draft.click_highlight_use_pen_color,
|
||||
|value| Message::ToggleChanged(ToggleField::UiClickHighlightUsePenColor, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Force on when entering light mode",
|
||||
tr("Force on when entering light mode"),
|
||||
"",
|
||||
|app| app.draft.click_highlight_force_in_light_mode,
|
||||
|value| Message::ToggleChanged(ToggleField::UiClickHighlightForceInLightMode, value),
|
||||
);
|
||||
|
||||
page.group("Ring")
|
||||
page.group(tr("Ring"))
|
||||
.entry_row_validated(
|
||||
"Radius",
|
||||
tr("Radius"),
|
||||
|app| app.draft.click_highlight_radius.clone(),
|
||||
|value| Message::TextChanged(TextField::HighlightRadius, value),
|
||||
|app| {
|
||||
@@ -55,7 +57,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Outline thickness",
|
||||
tr("Outline thickness"),
|
||||
|app| app.draft.click_highlight_outline_thickness.clone(),
|
||||
|value| Message::TextChanged(TextField::HighlightOutlineThickness, value),
|
||||
|app| {
|
||||
@@ -67,7 +69,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Duration (ms)",
|
||||
tr("Duration (ms)"),
|
||||
|app| app.draft.click_highlight_duration_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::HighlightDurationMs, value),
|
||||
|app| {
|
||||
@@ -79,16 +81,16 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
},
|
||||
);
|
||||
|
||||
page.group("Colors");
|
||||
page.group(tr("Colors"));
|
||||
color_row(
|
||||
&mut page,
|
||||
"Fill (hex)",
|
||||
tr("Fill (hex)"),
|
||||
ColorPickerId::HighlightFill,
|
||||
|app| quad_color(&app.draft.click_highlight_fill_color.components),
|
||||
);
|
||||
color_row(
|
||||
&mut page,
|
||||
"Outline (hex)",
|
||||
tr("Outline (hex)"),
|
||||
ColorPickerId::HighlightOutline,
|
||||
|app| quad_color(&app.draft.click_highlight_outline_color.components),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use crate::messages::Message;
|
||||
use crate::models::{ReducedMotionOption, TabId, TextField, ToggleField, UiThemeOption};
|
||||
use crate::models::{
|
||||
LanguageOption, ReducedMotionOption, TabId, TextField, ToggleField, UiThemeOption,
|
||||
};
|
||||
|
||||
use super::super::super::search::SearchArea;
|
||||
use super::super::super::state::ConfiguratorApp;
|
||||
@@ -11,68 +15,77 @@ use super::options;
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let (themes, theme_labels) = options(UiThemeOption::list(), |value| value.label());
|
||||
let (motions, motion_labels) = options(ReducedMotionOption::list(), |value| value.label());
|
||||
let (languages, language_labels) = options(LanguageOption::list(), |value| value.label());
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
page.group_in_area("General UI", SearchArea::UiGeneral)
|
||||
page.group_in_area(tr("General UI"), SearchArea::UiGeneral)
|
||||
.combo_row(
|
||||
"Theme",
|
||||
"\"Auto\" uses dark. \"Light\" supports the command palette, including shortcut editing and tooltips; other popups still use dark.",
|
||||
tr("Theme"),
|
||||
tr("\"Auto\" uses dark. \"Light\" supports the command palette, including shortcut editing and tooltips; other popups still use dark."),
|
||||
themes,
|
||||
theme_labels,
|
||||
|app| app.draft.ui_theme,
|
||||
Message::UiThemeChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Reduced motion",
|
||||
"\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now.",
|
||||
tr("Reduced motion"),
|
||||
tr("\"On\" disables UI animations. \"Auto\" follows the system preference in a future release and keeps full motion for now."),
|
||||
motions,
|
||||
motion_labels,
|
||||
|app| app.draft.ui_reduced_motion,
|
||||
Message::UiReducedMotionChanged,
|
||||
)
|
||||
.combo_row(
|
||||
tr("Language"),
|
||||
tr("Previews here at once; Save keeps it. The overlay and the About window use it the next time they open, background mode after it restarts. \"Automatic\" follows your desktop locale."),
|
||||
languages,
|
||||
language_labels,
|
||||
|app| app.draft.ui_language,
|
||||
Message::UiLanguageChanged,
|
||||
)
|
||||
.entry_row(
|
||||
"Preferred output (GNOME fallback)",
|
||||
tr("Preferred output (GNOME fallback)"),
|
||||
|app| app.draft.ui_preferred_output.clone(),
|
||||
|value| Message::TextChanged(TextField::UiPreferredOutput, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Use fullscreen xdg fallback",
|
||||
"Applies to the GNOME xdg-shell fallback overlay.",
|
||||
tr("Use fullscreen xdg fallback"),
|
||||
tr("Applies to the GNOME xdg-shell fallback overlay."),
|
||||
|app| app.draft.ui_xdg_fullscreen,
|
||||
|value| Message::ToggleChanged(ToggleField::UiXdgFullscreen, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Keep open on xdg focus loss",
|
||||
tr("Keep open on xdg focus loss"),
|
||||
"",
|
||||
|app| app.draft.ui_xdg_keep_on_focus_loss,
|
||||
|value| Message::ToggleChanged(ToggleField::UiXdgKeepOnFocusLoss, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Enable context menu",
|
||||
tr("Enable context menu"),
|
||||
"",
|
||||
|app| app.draft.ui_context_menu_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::UiContextMenuEnabled, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show capabilities warning toast",
|
||||
tr("Show capabilities warning toast"),
|
||||
"",
|
||||
|app| app.draft.ui_show_capabilities_warning,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowCapabilitiesWarning, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show automatic guidance and tips",
|
||||
"Controls first-run guidance, discovery tips, and shortcut coaching. Replay Tour still runs the first-run cards on request.",
|
||||
tr("Show automatic guidance and tips"),
|
||||
tr("Controls first-run guidance, discovery tips, and shortcut coaching. Replay Tour still runs the first-run cards on request."),
|
||||
|app| app.draft.ui_show_onboarding_hints,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowOnboardingHints, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show shape size readout",
|
||||
"Shows rectangle and ellipse preview dimensions in logical board pixels, and the shape Shape Pen has recognized. Separate from the region-capture size readout.",
|
||||
tr("Show shape size readout"),
|
||||
tr("Shows rectangle and ellipse preview dimensions in logical board pixels, and the shape Shape Pen has recognized. Separate from the region-capture size readout."),
|
||||
|app| app.draft.ui_show_shape_size_readout,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowShapeSizeReadout, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Command palette toast (ms)",
|
||||
tr("Command palette toast (ms)"),
|
||||
|app| app.draft.ui_command_palette_toast_duration_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::UiCommandPaletteToastDurationMs, value),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -11,51 +13,54 @@ use super::quad_color;
|
||||
pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Help Overlay").switch_row(
|
||||
"Filter sections by enabled features",
|
||||
page.group(tr("Help Overlay")).switch_row(
|
||||
tr("Filter sections by enabled features"),
|
||||
"",
|
||||
|app| app.draft.help_context_filter,
|
||||
|value| Message::ToggleChanged(ToggleField::UiHelpOverlayContextFilter, value),
|
||||
);
|
||||
|
||||
page.group("Help Overlay Style");
|
||||
page.group(tr("Help Overlay Style"));
|
||||
color_row(
|
||||
&mut page,
|
||||
"Background (hex)",
|
||||
tr("Background (hex)"),
|
||||
ColorPickerId::HelpBg,
|
||||
|app| quad_color(&app.draft.help_bg_color.components),
|
||||
);
|
||||
color_row(
|
||||
&mut page,
|
||||
"Border (hex)",
|
||||
tr("Border (hex)"),
|
||||
ColorPickerId::HelpBorder,
|
||||
|app| quad_color(&app.draft.help_border_color.components),
|
||||
);
|
||||
color_row(&mut page, "Text (hex)", ColorPickerId::HelpText, |app| {
|
||||
quad_color(&app.draft.help_text_color.components)
|
||||
});
|
||||
color_row(
|
||||
&mut page,
|
||||
tr("Text (hex)"),
|
||||
ColorPickerId::HelpText,
|
||||
|app| quad_color(&app.draft.help_text_color.components),
|
||||
);
|
||||
page.entry_row(
|
||||
"Font family",
|
||||
tr("Font family"),
|
||||
|app| app.draft.help_font_family.clone(),
|
||||
|value| Message::TextChanged(TextField::HelpFontFamily, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Font size",
|
||||
tr("Font size"),
|
||||
|app| app.draft.help_font_size.clone(),
|
||||
|value| Message::TextChanged(TextField::HelpFontSize, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Line height",
|
||||
tr("Line height"),
|
||||
|app| app.draft.help_line_height.clone(),
|
||||
|value| Message::TextChanged(TextField::HelpLineHeight, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Padding",
|
||||
tr("Padding"),
|
||||
|app| app.draft.help_padding.clone(),
|
||||
|value| Message::TextChanged(TextField::HelpPadding, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Border width",
|
||||
tr("Border width"),
|
||||
|app| app.draft.help_border_width.clone(),
|
||||
|value| Message::TextChanged(TextField::HelpBorderWidth, value),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -14,26 +16,26 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Input HUD")
|
||||
page.group(tr("Input HUD"))
|
||||
.custom(¬e(
|
||||
"Show a live row of keystroke and click chips for demos and screencasts.",
|
||||
tr("Show a live row of keystroke and click chips for demos and screencasts."),
|
||||
))
|
||||
.switch_row(
|
||||
"Enable input HUD",
|
||||
tr("Enable input HUD"),
|
||||
"",
|
||||
|app| app.draft.input_hud_enabled,
|
||||
|value| Message::ToggleChanged(ToggleField::UiInputHudEnabled, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Input source",
|
||||
"\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also shows input that goes to other apps; it needs a build with the input-monitor feature and read access to /dev/input (usually `input` group membership), and it sees every keystroke on the seat - including passwords typed elsewhere.",
|
||||
tr("Input source"),
|
||||
tr("\"Overlay only\" shows what Wayscriber itself receives. \"System-wide\" also shows input that goes to other apps; it needs a build with the input-monitor feature and read access to /dev/input (usually `input` group membership), and it sees every keystroke on the seat - including passwords typed elsewhere."),
|
||||
modes,
|
||||
mode_labels,
|
||||
|app| app.draft.input_hud_mode,
|
||||
Message::InputHudModeChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Screen position",
|
||||
tr("Screen position"),
|
||||
"",
|
||||
positions,
|
||||
position_labels,
|
||||
@@ -41,45 +43,45 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::InputHudPositionChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Show mouse buttons and scroll",
|
||||
tr("Show mouse buttons and scroll"),
|
||||
"",
|
||||
|app| app.draft.input_hud_show_mouse,
|
||||
|value| Message::ToggleChanged(ToggleField::UiInputHudShowMouse, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show bare modifier taps",
|
||||
tr("Show bare modifier taps"),
|
||||
"",
|
||||
|app| app.draft.input_hud_show_bare_modifiers,
|
||||
|value| Message::ToggleChanged(ToggleField::UiInputHudShowBareModifiers, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Combine repeats into a counter",
|
||||
tr("Combine repeats into a counter"),
|
||||
"",
|
||||
|app| app.draft.input_hud_combine_repeats,
|
||||
|value| Message::ToggleChanged(ToggleField::UiInputHudCombineRepeats, value),
|
||||
);
|
||||
|
||||
page.group("Chips")
|
||||
page.group(tr("Chips"))
|
||||
.entry_row_validated(
|
||||
"Hold (ms)",
|
||||
tr("Hold (ms)"),
|
||||
|app| app.draft.input_hud_display_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::InputHudDisplayMs, value),
|
||||
|app| validate_u32_range(&app.draft.input_hud_display_ms, 200, 30_000),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Fade (ms)",
|
||||
tr("Fade (ms)"),
|
||||
|app| app.draft.input_hud_fade_ms.clone(),
|
||||
|value| Message::TextChanged(TextField::InputHudFadeMs, value),
|
||||
|app| validate_u32_range(&app.draft.input_hud_fade_ms, 0, 5_000),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Max chips",
|
||||
tr("Max chips"),
|
||||
|app| app.draft.input_hud_max_entries.clone(),
|
||||
|value| Message::TextChanged(TextField::InputHudMaxEntries, value),
|
||||
|app| validate_u32_range(&app.draft.input_hud_max_entries, 1, 16),
|
||||
)
|
||||
.entry_row_validated(
|
||||
"Font size",
|
||||
tr("Font size"),
|
||||
|app| app.draft.input_hud_font_size.clone(),
|
||||
|value| Message::TextChanged(TextField::InputHudFontSize, value),
|
||||
|app| validate_f64_range(&app.draft.input_hud_font_size, 6.0, 72.0),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -15,22 +17,24 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Presenter Mode")
|
||||
.custom(¬e("Customize what presenter mode changes when toggled."))
|
||||
page.group(tr("Presenter Mode"))
|
||||
.custom(¬e(tr(
|
||||
"Customize what presenter mode changes when toggled.",
|
||||
)))
|
||||
.switch_row(
|
||||
"Hide status bar",
|
||||
tr("Hide status bar"),
|
||||
"",
|
||||
|app| app.draft.presenter_hide_status_bar,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterHideStatusBar, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Hide toolbars",
|
||||
tr("Hide toolbars"),
|
||||
"",
|
||||
|app| app.draft.presenter_hide_toolbars,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterHideToolbars, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Top toolbar while presenting",
|
||||
tr("Top toolbar while presenting"),
|
||||
"",
|
||||
toolbar_modes,
|
||||
toolbar_mode_labels,
|
||||
@@ -38,31 +42,31 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::PresenterToolbarModeChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Hide tool preview",
|
||||
tr("Hide tool preview"),
|
||||
"",
|
||||
|app| app.draft.presenter_hide_tool_preview,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterHideToolPreview, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Close help overlay on entry",
|
||||
tr("Close help overlay on entry"),
|
||||
"",
|
||||
|app| app.draft.presenter_close_help_overlay,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterCloseHelpOverlay, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Force click highlights on",
|
||||
tr("Force click highlights on"),
|
||||
"",
|
||||
|app| app.draft.presenter_enable_click_highlight,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterEnableClickHighlight, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Force input HUD on",
|
||||
tr("Force input HUD on"),
|
||||
"",
|
||||
|app| app.draft.presenter_enable_input_hud,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterEnableInputHud, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Tool behavior",
|
||||
tr("Tool behavior"),
|
||||
"",
|
||||
behaviors,
|
||||
behavior_labels,
|
||||
@@ -70,7 +74,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::PresenterToolBehaviorChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Show enter/exit toast",
|
||||
tr("Show enter/exit toast"),
|
||||
"",
|
||||
|app| app.draft.presenter_show_toast,
|
||||
|value| Message::ToggleChanged(ToggleField::PresenterShowToast, value),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -13,115 +15,115 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Status Bar")
|
||||
page.group(tr("Status Bar"))
|
||||
.switch_row(
|
||||
"Show status bar",
|
||||
tr("Show status bar"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_bar,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusBar, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Clickable status bar segments",
|
||||
tr("Clickable status bar segments"),
|
||||
"",
|
||||
|app| app.draft.ui_status_bar_interactive,
|
||||
|value| Message::ToggleChanged(ToggleField::UiStatusBarInteractive, value),
|
||||
);
|
||||
|
||||
page.group("Contents")
|
||||
page.group(tr("Contents"))
|
||||
.switch_row(
|
||||
"Show active output",
|
||||
"Appears while two or more outputs are connected",
|
||||
tr("Show active output"),
|
||||
tr("Appears while two or more outputs are connected"),
|
||||
|app| app.draft.ui_active_output_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::UiActiveOutputBadge, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show active output with a single output",
|
||||
tr("Show active output with a single output"),
|
||||
"",
|
||||
|app| app.draft.ui_active_output_badge_always,
|
||||
|value| Message::ToggleChanged(ToggleField::UiActiveOutputBadgeAlways, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show selection dimensions",
|
||||
tr("Show selection dimensions"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_selection_info,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusSelectionInfo, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show board label",
|
||||
tr("Show board label"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_board_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusBoardBadge, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show page counter",
|
||||
tr("Show page counter"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_page_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusPageBadge, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show current color",
|
||||
tr("Show current color"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_color,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusColor, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show active tool",
|
||||
tr("Show active tool"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_tool,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusTool, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show tool size",
|
||||
tr("Show tool size"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_size,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusSize, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show context indicators",
|
||||
tr("Show context indicators"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_context_indicators,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusContextIndicators, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show toolbar hint while toolbars are hidden",
|
||||
tr("Show toolbar hint while toolbars are hidden"),
|
||||
"",
|
||||
|app| app.draft.ui_show_toolbar_hint,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowToolbarHint, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show Help shortcut",
|
||||
tr("Show Help shortcut"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_help,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusHelp, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show About and version",
|
||||
tr("Show About and version"),
|
||||
"",
|
||||
|app| app.draft.ui_show_status_about,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowStatusAbout, value),
|
||||
);
|
||||
|
||||
page.group("Additional Badges")
|
||||
page.group(tr("Additional Badges"))
|
||||
.switch_row(
|
||||
"Show board/page badge",
|
||||
tr("Show board/page badge"),
|
||||
"",
|
||||
|app| app.draft.ui_show_floating_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowFloatingBadge, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Also show badge with status bar",
|
||||
tr("Also show badge with status bar"),
|
||||
"",
|
||||
|app| app.draft.ui_show_page_badge_with_status_bar,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowPageBadgeWithStatusBar, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show frozen badge",
|
||||
tr("Show frozen badge"),
|
||||
"",
|
||||
|app| app.draft.ui_show_frozen_badge,
|
||||
|value| Message::ToggleChanged(ToggleField::UiShowFrozenBadge, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Status bar position",
|
||||
tr("Status bar position"),
|
||||
"",
|
||||
positions,
|
||||
position_labels,
|
||||
@@ -129,31 +131,31 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::StatusPositionChanged,
|
||||
);
|
||||
|
||||
page.group("Status Bar Style");
|
||||
page.group(tr("Status Bar Style"));
|
||||
color_row(
|
||||
&mut page,
|
||||
"Background (hex)",
|
||||
tr("Background (hex)"),
|
||||
ColorPickerId::StatusBarBg,
|
||||
|app| quad_color(&app.draft.status_bar_bg_color.components),
|
||||
);
|
||||
color_row(
|
||||
&mut page,
|
||||
"Text (hex)",
|
||||
tr("Text (hex)"),
|
||||
ColorPickerId::StatusBarText,
|
||||
|app| quad_color(&app.draft.status_bar_text_color.components),
|
||||
);
|
||||
page.entry_row(
|
||||
"Font size",
|
||||
tr("Font size"),
|
||||
|app| app.draft.status_font_size.clone(),
|
||||
|value| Message::TextChanged(TextField::StatusFontSize, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Padding",
|
||||
tr("Padding"),
|
||||
|app| app.draft.status_padding.clone(),
|
||||
|value| Message::TextChanged(TextField::StatusPadding, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Dot radius",
|
||||
tr("Dot radius"),
|
||||
|app| app.draft.status_dot_radius.clone(),
|
||||
|value| Message::TextChanged(TextField::StatusDotRadius, value),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use relm4::ComponentSender;
|
||||
|
||||
use crate::messages::Message;
|
||||
@@ -25,21 +27,21 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let mut page = PageBuilder::new(sender, TabId::Ui);
|
||||
|
||||
page.group("Toolbar").custom(¬e(
|
||||
"These settings are configured defaults. Toolbar pin, position, display form, item visibility/order, and board pins changed in the overlay are saved separately as runtime preferences.",
|
||||
page.group(tr("Toolbar")).custom(¬e(
|
||||
tr("These settings are configured defaults. Toolbar pin, position, display form, item visibility/order, and board pins changed in the overlay are saved separately as runtime preferences."),
|
||||
));
|
||||
|
||||
page.group("Layout")
|
||||
page.group(tr("Layout"))
|
||||
.combo_row(
|
||||
"Layout mode",
|
||||
"Simple: core pens. Regular: adds Shape Pen, Line, Arrow, presets. Advanced: shapes inline, multi-step undo.",
|
||||
tr("Layout mode"),
|
||||
tr("Simple: core pens. Regular: adds Shape Pen, Line, Arrow, presets. Advanced: shapes inline, multi-step undo."),
|
||||
layout_modes,
|
||||
layout_labels,
|
||||
|app| app.draft.ui_toolbar_layout_mode,
|
||||
Message::ToolbarLayoutModeChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Zoom chip",
|
||||
tr("Zoom chip"),
|
||||
"",
|
||||
zoom_chips,
|
||||
zoom_chip_labels,
|
||||
@@ -47,13 +49,13 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ToolbarZoomChipDisplayChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Show zoom chip",
|
||||
tr("Show zoom chip"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_zoom_chip,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomChip, value),
|
||||
)
|
||||
.combo_row(
|
||||
"Shortcut edit click",
|
||||
tr("Shortcut edit click"),
|
||||
"",
|
||||
rebinds,
|
||||
rebind_labels,
|
||||
@@ -61,121 +63,121 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
Message::ToolbarRebindModifierChanged,
|
||||
)
|
||||
.combo_row(
|
||||
"Stroke controls",
|
||||
"How the style pill shows pen smoothing and Shape Pen sensitivity. Panel: one Pen feel button opening meters with a live smoothing preview. Meters or Steppers: inline controls.",
|
||||
tr("Stroke controls"),
|
||||
tr("How the style pill shows pen smoothing and Shape Pen sensitivity. Panel: one Pen feel button opening meters with a live smoothing preview. Meters or Steppers: inline controls."),
|
||||
stroke_styles,
|
||||
stroke_style_labels,
|
||||
|app| app.draft.ui_toolbar_stroke_controls,
|
||||
Message::ToolbarStrokeControlsChanged,
|
||||
)
|
||||
.switch_row(
|
||||
"Configured default: pin top toolbar",
|
||||
tr("Configured default: pin top toolbar"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_top_pinned,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarTopPinned, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Use icon-only buttons",
|
||||
tr("Use icon-only buttons"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_use_icons,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarUseIcons, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Hide toolbar when idle",
|
||||
"Hide the top bar a few seconds after you stop using it; it returns when the pointer comes near. Turn off to keep it always visible.",
|
||||
tr("Hide toolbar when idle"),
|
||||
tr("Hide the top bar a few seconds after you stop using it; it returns when the pointer comes near. Turn off to keep it always visible."),
|
||||
|app| app.draft.ui_toolbar_idle_fade,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarIdleFade, value),
|
||||
);
|
||||
|
||||
page.group("Sections")
|
||||
page.group(tr("Sections"))
|
||||
.switch_row(
|
||||
"Show extended colors",
|
||||
tr("Show extended colors"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_more_colors,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowMoreColors, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show presets",
|
||||
tr("Show presets"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_presets,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowPresets, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show actions",
|
||||
tr("Show actions"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_actions_section,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsSection, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show zoom actions",
|
||||
tr("Show zoom actions"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_zoom_actions,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowZoomActions, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show advanced actions",
|
||||
tr("Show advanced actions"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_actions_advanced,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowActionsAdvanced, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show pages section",
|
||||
tr("Show pages section"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_pages_section,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowPagesSection, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show boards section",
|
||||
tr("Show boards section"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_boards_section,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowBoardsSection, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show multi-step undo/redo",
|
||||
tr("Show multi-step undo/redo"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_step_section,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowStepSection, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Always show text controls",
|
||||
"Applies while the toolbar's Adapt to tool setting is off; otherwise font controls follow tools that draw text.",
|
||||
tr("Always show text controls"),
|
||||
tr("Applies while the toolbar's Adapt to tool setting is off; otherwise font controls follow tools that draw text."),
|
||||
|app| app.draft.ui_toolbar_show_text_controls,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowTextControls, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show delay sliders",
|
||||
tr("Show delay sliders"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_delay_sliders,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowDelaySliders, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show marker opacity controls",
|
||||
tr("Show marker opacity controls"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_marker_opacity_section,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowMarkerOpacitySection, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show tool preview bubble",
|
||||
tr("Show tool preview bubble"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_tool_preview,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarShowToolPreview, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Show preset action toasts",
|
||||
tr("Show preset action toasts"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_show_preset_toasts,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarPresetToasts, value),
|
||||
)
|
||||
.switch_row(
|
||||
"Force inline toolbars",
|
||||
tr("Force inline toolbars"),
|
||||
"",
|
||||
|app| app.draft.ui_toolbar_force_inline,
|
||||
|value| Message::ToggleChanged(ToggleField::UiToolbarForceInline, value),
|
||||
);
|
||||
|
||||
page.group("Mode overrides").combo_row(
|
||||
"Edit mode",
|
||||
"Overrides below apply to the mode selected here; \"Default\" keeps the mode preset.",
|
||||
page.group(tr("Mode overrides")).combo_row(
|
||||
tr("Edit mode"),
|
||||
tr("Overrides below apply to the mode selected here; \"Default\" keeps the mode preset."),
|
||||
override_modes,
|
||||
override_mode_labels,
|
||||
|app| app.override_mode,
|
||||
@@ -202,19 +204,19 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
);
|
||||
}
|
||||
|
||||
page.group("Placement offsets")
|
||||
page.group(tr("Placement offsets"))
|
||||
.entry_row(
|
||||
"Top offset X (px)",
|
||||
tr("Top offset X (px)"),
|
||||
|app| app.draft.ui_toolbar_top_offset.clone(),
|
||||
|value| Message::TextChanged(TextField::ToolbarTopOffset, value),
|
||||
)
|
||||
.entry_row(
|
||||
"Top offset Y (px)",
|
||||
tr("Top offset Y (px)"),
|
||||
|app| app.draft.ui_toolbar_top_offset_y.clone(),
|
||||
|value| Message::TextChanged(TextField::ToolbarTopOffsetY, value),
|
||||
)
|
||||
.custom(¬e(
|
||||
"Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag.",
|
||||
tr("Configured defaults. Dragging a toolbar in the overlay saves that position as a runtime preference; editing a value here takes over from the saved drag."),
|
||||
));
|
||||
|
||||
page.finish()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use relm4::{ComponentSender, adw, gtk};
|
||||
|
||||
use adw::prelude::*;
|
||||
@@ -55,9 +57,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
// not know.
|
||||
let unknown_notice = note("");
|
||||
unknown_notice.set_visible(false);
|
||||
page.group("Toolbar Visibility")
|
||||
page.group(tr("Toolbar Visibility"))
|
||||
.custom(¬e(
|
||||
"These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Enabled items are shown; section toggles and mode overrides can still hide them.",
|
||||
tr("These are configured visibility defaults. Overlay customizations are stored separately as runtime preferences. Enabled items are shown; section toggles and mode overrides can still hide them."),
|
||||
))
|
||||
.custom(&unknown_notice);
|
||||
|
||||
@@ -73,10 +75,9 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let id = definition.id;
|
||||
let row = adw::SwitchRow::builder()
|
||||
.title(definition.label)
|
||||
.subtitle(format!(
|
||||
"{} - built-in default: {}",
|
||||
id.as_str(),
|
||||
visibility_label(!built_in.is_hidden(id))
|
||||
.subtitle(trf(
|
||||
tr("{} - built-in default: {}"),
|
||||
&[&id.as_str(), &visibility_label(!built_in.is_hidden(id))],
|
||||
))
|
||||
.build();
|
||||
let handler = {
|
||||
@@ -109,7 +110,7 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
let mut reset = None;
|
||||
if let Some(group) = section.order_group {
|
||||
let button = gtk::Button::builder()
|
||||
.label("Restore built-in order")
|
||||
.label(tr("Restore built-in order"))
|
||||
.halign(gtk::Align::End)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
@@ -142,7 +143,10 @@ pub(super) fn build(sender: &ComponentSender<ConfiguratorApp>) -> BuiltPage {
|
||||
|
||||
let unknown = resolved.unknown_hidden.len() + resolved.unknown_shown.len();
|
||||
let notice_text = if unknown > 0 {
|
||||
format!("Preserving {unknown} unknown toolbar item id(s) from config.")
|
||||
trf(
|
||||
"Preserving {} unknown toolbar item id(s) from config.",
|
||||
&[&unknown],
|
||||
)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
@@ -225,15 +229,16 @@ fn item_sections(built_in: &ResolvedToolbarItems) -> Vec<ItemSection> {
|
||||
|
||||
let order_group = configurator_order_group(definition);
|
||||
let title = match order_group {
|
||||
Some((_, label)) => format!(
|
||||
"{}: {} (reorderable)",
|
||||
surface_label(definition.surface),
|
||||
label
|
||||
Some((_, label)) => trf(
|
||||
tr("{}: {} (reorderable)"),
|
||||
&[&surface_label(definition.surface), &label],
|
||||
),
|
||||
None => format!(
|
||||
"{}: {}",
|
||||
surface_label(definition.surface),
|
||||
category_label(definition.category)
|
||||
None => trf(
|
||||
tr("{}: {}"),
|
||||
&[
|
||||
&surface_label(definition.surface),
|
||||
&category_label(definition.category),
|
||||
],
|
||||
),
|
||||
};
|
||||
|
||||
@@ -303,9 +308,9 @@ fn move_button(
|
||||
delta: isize,
|
||||
) -> gtk::Button {
|
||||
let (icon, tooltip) = if delta < 0 {
|
||||
("go-up-symbolic", "Move up")
|
||||
("go-up-symbolic", tr("Move up"))
|
||||
} else {
|
||||
("go-down-symbolic", "Move down")
|
||||
("go-down-symbolic", tr("Move down"))
|
||||
};
|
||||
let button = gtk::Button::builder()
|
||||
.icon_name(icon)
|
||||
@@ -326,21 +331,21 @@ fn visibility_label(visible: bool) -> &'static str {
|
||||
|
||||
fn surface_label(surface: ToolbarItemSurface) -> &'static str {
|
||||
match surface {
|
||||
ToolbarItemSurface::Top => "Top toolbar",
|
||||
ToolbarItemSurface::Popover => "Popover sections",
|
||||
ToolbarItemSurface::Top => tr("Top toolbar"),
|
||||
ToolbarItemSurface::Popover => tr("Popover sections"),
|
||||
}
|
||||
}
|
||||
|
||||
fn category_label(category: ToolbarItemCategory) -> &'static str {
|
||||
match category {
|
||||
ToolbarItemCategory::Chrome => "Toolbar controls",
|
||||
ToolbarItemCategory::Tool => "Tools",
|
||||
ToolbarItemCategory::Utility => "Utilities",
|
||||
ToolbarItemCategory::Group => "Sections",
|
||||
ToolbarItemCategory::Action => "Actions",
|
||||
ToolbarItemCategory::Page => "Pages",
|
||||
ToolbarItemCategory::Board => "Boards",
|
||||
ToolbarItemCategory::Setting => "Settings",
|
||||
ToolbarItemCategory::Session => "Sessions",
|
||||
ToolbarItemCategory::Chrome => tr("Toolbar controls"),
|
||||
ToolbarItemCategory::Tool => tr("Tools"),
|
||||
ToolbarItemCategory::Utility => tr("Utilities"),
|
||||
ToolbarItemCategory::Group => tr("Sections"),
|
||||
ToolbarItemCategory::Action => tr("Actions"),
|
||||
ToolbarItemCategory::Page => tr("Pages"),
|
||||
ToolbarItemCategory::Board => tr("Boards"),
|
||||
ToolbarItemCategory::Setting => tr("Settings"),
|
||||
ToolbarItemCategory::Session => tr("Sessions"),
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ pub(super) fn build_search_summary(app: &ConfiguratorApp) -> AppSearchSummary {
|
||||
|
||||
fn tab_summary(app: &ConfiguratorApp, tab: TabId) -> Option<TabSearchSummary> {
|
||||
let query = &app.search_query;
|
||||
let direct_title_match = query.matches_text(tab.title());
|
||||
// The title as displayed, and its English source in every language.
|
||||
let direct_title_match = query.matches_parts([tab.title(), tab.source_title()]);
|
||||
let alias_match = tab_aliases(tab)
|
||||
.iter()
|
||||
.any(|alias| query.matches_text(alias));
|
||||
@@ -165,6 +166,7 @@ fn performance_matches(query: &SearchQuery, summary: &mut TabSearchSummary) {
|
||||
),
|
||||
] {
|
||||
let mut parts = vec![identity];
|
||||
parts.extend(area.section_titles());
|
||||
for metadata in PERFORMANCE_FIELD_METADATA
|
||||
.iter()
|
||||
.filter(|metadata| metadata.group == group)
|
||||
@@ -187,7 +189,8 @@ fn ui_matches(query: &SearchQuery, summary: &mut TabSearchSummary) {
|
||||
UI_GENERAL_TERMS,
|
||||
);
|
||||
for tab in UiTabId::ALL {
|
||||
let identity_parts = std::iter::once(tab.title())
|
||||
let identity_parts = [tab.title(), tab.source_title()]
|
||||
.into_iter()
|
||||
.chain(ui_tab_aliases(tab).iter().copied())
|
||||
.collect::<Vec<_>>();
|
||||
let field_terms = ui_tab_terms(tab);
|
||||
@@ -364,7 +367,10 @@ fn session_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut Tab
|
||||
for item in &app.session_catalog.items {
|
||||
let text = format!(
|
||||
"session saved recent catalog {} {} {} {:?}",
|
||||
item.display_name, item.path_label, item.created_label, item.path,
|
||||
item.display_name,
|
||||
item.path_label,
|
||||
item.created_label(),
|
||||
item.path,
|
||||
);
|
||||
if query.matches_text(&text) {
|
||||
summary.add_area(SearchArea::SessionCatalog);
|
||||
@@ -375,7 +381,8 @@ fn session_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut Tab
|
||||
|
||||
fn keybinding_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSearchSummary) {
|
||||
for tab in KeybindingsTabId::ALL {
|
||||
if query.matches_parts_scoped_to_tab(TabId::Keybindings, [tab.title()]) {
|
||||
if query.matches_parts_scoped_to_tab(TabId::Keybindings, [tab.title(), tab.source_title()])
|
||||
{
|
||||
summary.add_direct_keybinding_tab(tab);
|
||||
}
|
||||
}
|
||||
@@ -386,13 +393,18 @@ fn keybinding_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut
|
||||
.value_for(entry.field)
|
||||
.unwrap_or("");
|
||||
let mut text = format!(
|
||||
"keybindings keybinding shortcut hotkey keyboard shortcut list {} {} {} {}",
|
||||
"keybindings keybinding shortcut hotkey keyboard shortcut list {} {} {} {} {}",
|
||||
keybinding_tab(entry.field).title(),
|
||||
keybinding_tab(entry.field).source_title(),
|
||||
entry.field.label(),
|
||||
entry.field.field_key(),
|
||||
entry.value,
|
||||
);
|
||||
if let Some(meta) = action_meta(entry.field.action()) {
|
||||
// The English label, so an English search finds the row in
|
||||
// every language; `field.label()` above is translated.
|
||||
text.push(' ');
|
||||
text.push_str(meta.label);
|
||||
text.push(' ');
|
||||
text.push_str(meta.description);
|
||||
text.push(' ');
|
||||
@@ -415,7 +427,9 @@ fn add_area_if(
|
||||
area: SearchArea,
|
||||
terms: &[&str],
|
||||
) {
|
||||
if query.matches_parts_scoped_to_tab(tab, terms.iter().copied()) {
|
||||
let titles = area.section_titles();
|
||||
let parts = terms.iter().copied().chain(titles.iter().copied());
|
||||
if query.matches_parts_scoped_to_tab(tab, parts) {
|
||||
summary.add_area(area);
|
||||
}
|
||||
}
|
||||
@@ -436,10 +450,13 @@ impl ScopedSearchQuery for SearchQuery {
|
||||
parts: impl IntoIterator<Item = &'a str>,
|
||||
) -> bool {
|
||||
let parts = parts.into_iter().collect::<Vec<_>>();
|
||||
self.matches_parts(std::iter::once(tab.title()).chain(parts.iter().copied()))
|
||||
|| tab_scope_aliases(tab).iter().any(|alias| {
|
||||
self.matches_parts(std::iter::once(*alias).chain(parts.iter().copied()))
|
||||
})
|
||||
self.matches_parts(
|
||||
[tab.title(), tab.source_title()]
|
||||
.into_iter()
|
||||
.chain(parts.iter().copied()),
|
||||
) || tab_scope_aliases(tab)
|
||||
.iter()
|
||||
.any(|alias| self.matches_parts(std::iter::once(*alias).chain(parts.iter().copied())))
|
||||
}
|
||||
|
||||
fn matches_any_raw_text(&self, values: &[&str]) -> bool {
|
||||
|
||||
@@ -191,6 +191,11 @@ pub(super) const UI_GENERAL_TERMS: &[&str] = &[
|
||||
"theme",
|
||||
"dark mode",
|
||||
"light mode",
|
||||
"language",
|
||||
"ui language",
|
||||
"locale",
|
||||
"english",
|
||||
"simplified chinese",
|
||||
"reduced motion",
|
||||
"motion",
|
||||
"disable animations",
|
||||
|
||||
@@ -677,6 +677,8 @@ fn exact_pdf_background_field_label_matches_pdf_section() {
|
||||
#[test]
|
||||
fn exact_general_ui_field_labels_match_general_ui_section() {
|
||||
for query in [
|
||||
"language",
|
||||
"simplified chinese",
|
||||
"focus loss",
|
||||
"show automatic guidance and tips",
|
||||
"onboarding",
|
||||
@@ -835,9 +837,9 @@ fn catalog_item(id: &str) -> SessionCatalogItem {
|
||||
path: PathBuf::from("/tmp/lecture.wayscriber-session"),
|
||||
path_label: "/tmp/lecture.wayscriber-session".to_string(),
|
||||
canonical_path_label: None,
|
||||
created_label: "now".to_string(),
|
||||
last_opened_label: "Never".to_string(),
|
||||
last_saved_label: "Never".to_string(),
|
||||
created_at_millis: 1_767_261_600_000,
|
||||
last_opened_at_millis: None,
|
||||
last_saved_at_millis: None,
|
||||
artifacts: SessionArtifactSummary {
|
||||
primary_exists: false,
|
||||
backup_exists: false,
|
||||
@@ -849,3 +851,38 @@ fn catalog_item(id: &str) -> SessionCatalogItem {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Decision D4: in a translated UI, English terms still find what they
|
||||
/// found, and titles as displayed find their tab, sub-tab and section.
|
||||
#[test]
|
||||
fn chinese_ui_search_matches_english_terms_and_displayed_titles() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::chinese_ui_search_matches_english_terms_and_displayed_titles"
|
||||
),
|
||||
|| {
|
||||
wayscriber::i18n::set_language(wayscriber::domain::Language::SimplifiedChinese);
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let tabs_for = |app: &mut ConfiguratorApp, query: &str| {
|
||||
app.search_query = SearchQuery::new(query);
|
||||
let summary = app.search_summary();
|
||||
TabId::ALL
|
||||
.into_iter()
|
||||
.filter(|tab| summary.tab(*tab).is_some())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
|
||||
// English: a tab title, a sub-tab title, and an action label.
|
||||
assert!(tabs_for(&mut app, "Render Profiles").contains(&TabId::RenderProfiles));
|
||||
assert!(tabs_for(&mut app, "Presenter Mode").contains(&TabId::Ui));
|
||||
assert!(tabs_for(&mut app, "Clear Canvas").contains(&TabId::Keybindings));
|
||||
// As displayed: a tab title, a sub-tab title, a section title, and a
|
||||
// translated action label.
|
||||
assert!(tabs_for(&mut app, "渲染配置").contains(&TabId::RenderProfiles));
|
||||
assert!(tabs_for(&mut app, "演示模式").contains(&TabId::Ui));
|
||||
assert!(tabs_for(&mut app, "激光指示").contains(&TabId::Drawing));
|
||||
assert!(tabs_for(&mut app, "清空画布").contains(&TabId::Keybindings));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use crate::models::{
|
||||
KeybindingField, KeybindingsTabId, SearchQuery, TabId, UiTabId, keybinding_tab,
|
||||
};
|
||||
@@ -31,6 +33,50 @@ pub(crate) enum SearchArea {
|
||||
Tablet,
|
||||
}
|
||||
|
||||
impl SearchArea {
|
||||
/// The section titles this area's groups show, in the current UI
|
||||
/// language, so a search for a title as displayed finds its section.
|
||||
/// English search terms match in every language through the term lists;
|
||||
/// these add the translated titles on top. Keep them in step with the
|
||||
/// group titles the pages build.
|
||||
pub(super) fn section_titles(self) -> Vec<&'static str> {
|
||||
match self {
|
||||
SearchArea::DrawingColor => {
|
||||
vec![tr("Default color"), tr("RGB color"), tr("Quick colors")]
|
||||
}
|
||||
SearchArea::DrawingDefaults => vec![tr("Drawing defaults")],
|
||||
SearchArea::DrawingLaser => vec![tr("Laser pointer")],
|
||||
SearchArea::DrawingDragTools => vec![tr("Drag tool mapping")],
|
||||
SearchArea::DrawingFont => vec![tr("Font"), tr("Font cycle")],
|
||||
SearchArea::PresetControls => vec![tr("Preset Slots")],
|
||||
SearchArea::Arrow => vec![tr("Arrow Settings")],
|
||||
SearchArea::HistoryMain => vec![tr("Delays")],
|
||||
SearchArea::HistoryCustom => vec![tr("Custom section")],
|
||||
SearchArea::PerformanceRendering => vec![tr("Rendering")],
|
||||
SearchArea::PerformanceAnimations => vec![tr("Animations")],
|
||||
SearchArea::UiGeneral => vec![tr("General UI")],
|
||||
SearchArea::BoardsGeneral => vec![tr("Boards")],
|
||||
SearchArea::RenderProfilesGeneral => vec![tr("Render Profiles")],
|
||||
SearchArea::CaptureFiles => vec![tr("Capture")],
|
||||
SearchArea::CaptureRegion => vec![tr("Region picker")],
|
||||
SearchArea::CapturePdf => vec![tr("PDF export")],
|
||||
SearchArea::DaemonStatus => vec![tr("Background Mode"), tr("Details")],
|
||||
SearchArea::DaemonService => vec![
|
||||
tr("Step 1: Install the service"),
|
||||
tr("Step 3: Start the service"),
|
||||
],
|
||||
SearchArea::DaemonShortcut => vec![tr("Step 2: Set your shortcut")],
|
||||
SearchArea::DaemonLightControls => vec![tr("Light passthrough controls")],
|
||||
SearchArea::SessionPersistence => {
|
||||
vec![tr("Session Persistence"), tr("Storage"), tr("Autosave")]
|
||||
}
|
||||
SearchArea::SessionCatalog => vec![tr("Saved Sessions")],
|
||||
#[cfg(feature = "tablet-input")]
|
||||
SearchArea::Tablet => vec![tr("Stylus"), tr("Pressure")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct AppSearchSummary {
|
||||
pub(super) query: SearchQuery,
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::io::ErrorKind;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
use wayscriber::session::try_lock_exclusive;
|
||||
|
||||
use crate::models::{
|
||||
@@ -34,9 +35,9 @@ pub(super) async fn forget_session_catalog_entry(
|
||||
let items = load_session_catalog_sync()?;
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
if removed {
|
||||
"Forgot session metadata.".to_string()
|
||||
tr("Forgot session metadata.").to_string()
|
||||
} else {
|
||||
"Session was already absent from the catalog.".to_string()
|
||||
tr("Session was already absent from the catalog.").to_string()
|
||||
},
|
||||
items,
|
||||
))
|
||||
@@ -57,9 +58,9 @@ pub(super) async fn rename_session_catalog_entry(
|
||||
let items = load_session_catalog_sync()?;
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
if let Some(entry) = renamed {
|
||||
format!("Renamed session to {}.", entry.display_name)
|
||||
trf("Renamed session to {}.", &[&entry.display_name])
|
||||
} else {
|
||||
"Session was already absent from the catalog.".to_string()
|
||||
tr("Session was already absent from the catalog.").to_string()
|
||||
},
|
||||
items,
|
||||
))
|
||||
@@ -75,7 +76,7 @@ pub(super) async fn reveal_session_catalog_entry(
|
||||
reveal_path_parent(&item.path)?;
|
||||
let items = load_session_catalog_sync()?;
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
format!("Opened folder for {}.", item.display_name),
|
||||
trf("Opened folder for {}.", &[&item.display_name]),
|
||||
items,
|
||||
))
|
||||
})
|
||||
@@ -99,9 +100,9 @@ pub(super) async fn clear_session_catalog_entry(
|
||||
let items = load_session_catalog_sync()?;
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
if outcome.removed_any() {
|
||||
format!("Cleared saved data for {}.", item.display_name)
|
||||
trf("Cleared saved data for {}.", &[&item.display_name])
|
||||
} else {
|
||||
format!("No saved data found for {}.", item.display_name)
|
||||
trf("No saved data found for {}.", &[&item.display_name])
|
||||
},
|
||||
items,
|
||||
))
|
||||
@@ -154,7 +155,7 @@ fn find_session_catalog_item(id: &str) -> Result<SessionCatalogItem, String> {
|
||||
load_session_catalog_sync()?
|
||||
.into_iter()
|
||||
.find(|item| item.id == id)
|
||||
.ok_or_else(|| "Session is no longer in the catalog.".to_string())
|
||||
.ok_or_else(|| tr("Session is no longer in the catalog.").to_string())
|
||||
}
|
||||
|
||||
fn named_session_options_for_catalog_item(
|
||||
@@ -176,7 +177,7 @@ fn ensure_session_config_available_for_destructive_action(
|
||||
) -> Result<(), String> {
|
||||
if loaded.section_failed("session") {
|
||||
return Err(
|
||||
"Clear saved tool state is disabled because config.toml [session] could not be read; fix the section and retry"
|
||||
tr("Clear saved tool state is disabled because config.toml [session] could not be read; fix the section and retry")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -189,19 +190,23 @@ fn clear_tool_state_catalog_message(
|
||||
) -> String {
|
||||
match outcome {
|
||||
wayscriber::session::ClearToolStateOutcome::NoSession => {
|
||||
format!("No saved session file found for {display_name}.")
|
||||
trf("No saved session file found for {}.", &[&display_name])
|
||||
}
|
||||
wayscriber::session::ClearToolStateOutcome::NoToolState => {
|
||||
format!("No saved tool state found for {display_name}.")
|
||||
trf("No saved tool state found for {}.", &[&display_name])
|
||||
}
|
||||
wayscriber::session::ClearToolStateOutcome::Cleared {
|
||||
preserved_board_data: true,
|
||||
} => format!(
|
||||
"Cleared saved tool state for {display_name}. Boards and history were preserved."
|
||||
} => trf(
|
||||
tr("Cleared saved tool state for {}. Boards and history were preserved."),
|
||||
&[&display_name],
|
||||
),
|
||||
wayscriber::session::ClearToolStateOutcome::Cleared {
|
||||
preserved_board_data: false,
|
||||
} => format!("Cleared saved tool state for {display_name}. No board data was present."),
|
||||
} => trf(
|
||||
"Cleared saved tool state for {}. No board data was present.",
|
||||
&[&display_name],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,16 +216,16 @@ fn reveal_path_parent(path: &Path) -> Result<(), String> {
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.unwrap_or_else(|| Path::new("."));
|
||||
if !parent.exists() {
|
||||
return Err(format!(
|
||||
"Session folder does not exist: {}",
|
||||
parent.display()
|
||||
return Err(trf(
|
||||
tr("Session folder does not exist: {}"),
|
||||
&[&parent.display()],
|
||||
));
|
||||
}
|
||||
|
||||
Command::new("xdg-open")
|
||||
.arg(parent)
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to launch xdg-open: {err}"))?;
|
||||
.map_err(|err| trf("failed to launch xdg-open: {}", &[&err]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -240,8 +245,8 @@ impl RuntimeLockKind {
|
||||
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daemon => "daemon",
|
||||
Self::Overlay => "overlay",
|
||||
Self::Daemon => tr("daemon"),
|
||||
Self::Overlay => tr("overlay"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::{DaemonRuntimeStatus, SessionCatalogActionResult, SessionCatalogOperation};
|
||||
@@ -30,7 +32,7 @@ fn duplicate_session_catalog_entry_sync(
|
||||
.iter()
|
||||
.find(|item| item.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "Session is no longer in the catalog.".to_string())?;
|
||||
.ok_or_else(|| tr("Session is no longer in the catalog.").to_string())?;
|
||||
let _reservation = reserve_inactive_session_operation(SessionCatalogOperation::Duplicate)?;
|
||||
if let Some(blocker) = service_status_blocker(Some(status), SessionCatalogOperation::Duplicate)
|
||||
{
|
||||
@@ -47,10 +49,9 @@ fn duplicate_session_catalog_entry_sync(
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
return Ok(SessionCatalogActionResult::warning(
|
||||
format!(
|
||||
"Duplicated {} to {}, but failed to update the session catalog: {err}",
|
||||
item.display_name,
|
||||
outcome.target.display()
|
||||
trf(
|
||||
"Duplicated {} to {}, but failed to update the session catalog: {}",
|
||||
&[&item.display_name, &outcome.target.display(), &err],
|
||||
),
|
||||
initial_items,
|
||||
));
|
||||
@@ -60,20 +61,18 @@ fn duplicate_session_catalog_entry_sync(
|
||||
Ok(items) => items,
|
||||
Err(err) => {
|
||||
return Ok(SessionCatalogActionResult::warning(
|
||||
format!(
|
||||
"Duplicated {} to {}, but failed to reload the session catalog: {err}",
|
||||
item.display_name,
|
||||
Path::new(&entry.path).display()
|
||||
trf(
|
||||
"Duplicated {} to {}, but failed to reload the session catalog: {}",
|
||||
&[&item.display_name, &Path::new(&entry.path).display(), &err],
|
||||
),
|
||||
initial_items,
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
format!(
|
||||
trf(
|
||||
"Duplicated {} to {}.",
|
||||
item.display_name,
|
||||
Path::new(&entry.path).display()
|
||||
&[&item.display_name, &Path::new(&entry.path).display()],
|
||||
),
|
||||
items,
|
||||
))
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::models::{
|
||||
@@ -31,7 +33,7 @@ fn move_session_catalog_entry_sync(
|
||||
.iter()
|
||||
.find(|item| item.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| "Session is no longer in the catalog.".to_string())?;
|
||||
.ok_or_else(|| tr("Session is no longer in the catalog.").to_string())?;
|
||||
reject_catalog_target_collision(&initial_items, id, target)?;
|
||||
|
||||
let _reservation = reserve_inactive_session_operation(SessionCatalogOperation::Move)?;
|
||||
@@ -49,7 +51,7 @@ fn move_session_catalog_entry_sync(
|
||||
&item,
|
||||
&outcome,
|
||||
initial_items,
|
||||
"the original catalog entry disappeared before metadata could be updated"
|
||||
tr("the original catalog entry disappeared before metadata could be updated")
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -66,10 +68,9 @@ fn move_session_catalog_entry_sync(
|
||||
Ok(items) => items,
|
||||
Err(err) => {
|
||||
return Ok(SessionCatalogActionResult::warning(
|
||||
format!(
|
||||
"Moved {} to {}, but failed to reload the session catalog: {err}",
|
||||
item.display_name,
|
||||
Path::new(&entry.path).display()
|
||||
trf(
|
||||
"Moved {} to {}, but failed to reload the session catalog: {}",
|
||||
&[&item.display_name, &Path::new(&entry.path).display(), &err],
|
||||
),
|
||||
initial_items,
|
||||
));
|
||||
@@ -77,10 +78,9 @@ fn move_session_catalog_entry_sync(
|
||||
};
|
||||
|
||||
Ok(SessionCatalogActionResult::success(
|
||||
format!(
|
||||
trf(
|
||||
"Moved {} to {}.",
|
||||
item.display_name,
|
||||
Path::new(&entry.path).display()
|
||||
&[&item.display_name, &Path::new(&entry.path).display()],
|
||||
),
|
||||
items,
|
||||
))
|
||||
@@ -93,16 +93,20 @@ fn rollback_moved_artifacts_after_catalog_failure(
|
||||
reason: String,
|
||||
) -> Result<SessionCatalogActionResult, String> {
|
||||
match wayscriber::session::rollback_named_session_non_lock_artifacts_move(outcome) {
|
||||
Ok(_) => Err(format!(
|
||||
"Move Session failed because {reason}; rolled back moved files for {}.",
|
||||
item.display_name
|
||||
Ok(_) => Err(trf(
|
||||
"Move Session failed because {}; rolled back moved files for {}.",
|
||||
&[&reason, &item.display_name],
|
||||
)),
|
||||
Err(rollback_err) => Ok(SessionCatalogActionResult::warning(
|
||||
format!(
|
||||
"Moved {} to {}, but {reason}; rollback to {} also failed: {rollback_err:#}",
|
||||
item.display_name,
|
||||
outcome.target.display(),
|
||||
outcome.source.display()
|
||||
trf(
|
||||
"Moved {} to {}, but {}; rollback to {} also failed: {}",
|
||||
&[
|
||||
&item.display_name,
|
||||
&outcome.target.display(),
|
||||
&reason,
|
||||
&outcome.source.display(),
|
||||
&rollback_err,
|
||||
],
|
||||
),
|
||||
initial_items,
|
||||
)),
|
||||
@@ -118,9 +122,9 @@ fn reject_catalog_target_collision(
|
||||
item.id != source_id
|
||||
&& wayscriber::session::catalog::session_paths_match(&item.path, target)
|
||||
}) {
|
||||
return Err(format!(
|
||||
return Err(trf(
|
||||
"Move Session target is already in the catalog as {}.",
|
||||
existing.display_name
|
||||
&[&existing.display_name],
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
|
||||
@@ -187,9 +187,9 @@ fn session_artifact_status_reports_size_when_present() {
|
||||
path: PathBuf::from("/tmp/lecture.wayscriber-session"),
|
||||
path_label: "/tmp/lecture.wayscriber-session".to_string(),
|
||||
canonical_path_label: None,
|
||||
created_label: "now".to_string(),
|
||||
last_opened_label: "Never".to_string(),
|
||||
last_saved_label: "Never".to_string(),
|
||||
created_at_millis: 1_767_261_600_000,
|
||||
last_opened_at_millis: None,
|
||||
last_saved_at_millis: None,
|
||||
artifacts: crate::models::session::SessionArtifactSummary {
|
||||
primary_exists: true,
|
||||
backup_exists: false,
|
||||
|
||||
@@ -141,6 +141,7 @@ mod tests {
|
||||
|
||||
/// The app as the launcher would start it, after its first config load.
|
||||
fn app_launched_with(values: &[&str]) -> (ConfiguratorApp, TempDir, std::path::PathBuf) {
|
||||
crate::test_env::sanitize_language_environment();
|
||||
let (dir, path) = config_file();
|
||||
let (mut app, _effects) =
|
||||
ConfiguratorApp::new_app_with_startup(StartupRequest::from_args(args(values)));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wayscriber::config::{Config, MigrationPreview, PRESET_SLOTS_MAX};
|
||||
@@ -88,20 +90,40 @@ impl ConfirmationPrompt {
|
||||
pub(crate) fn message(self) -> &'static str {
|
||||
match self {
|
||||
ConfirmationPrompt::LeaveDraft => {
|
||||
"You have unsaved changes. Save them, discard them, or cancel to keep editing."
|
||||
}
|
||||
ConfirmationPrompt::DefaultsReset => {
|
||||
"Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue."
|
||||
}
|
||||
ConfirmationPrompt::SessionClear => {
|
||||
"Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue."
|
||||
}
|
||||
ConfirmationPrompt::ShortcutResetVisible => {
|
||||
"Reset Visible restores the currently listed keybindings to their defaults. Press \"Confirm Reset Visible\" to continue. Nothing is written until you Save."
|
||||
}
|
||||
ConfirmationPrompt::ShortcutResetAll => {
|
||||
"Reset All restores every keybinding to its built-in default. Press \"Confirm Reset All\" to continue. Nothing is written until you Save."
|
||||
tr("You have unsaved changes. Save them, discard them, or cancel to keep editing.")
|
||||
}
|
||||
ConfirmationPrompt::DefaultsReset => tr(
|
||||
"Defaults will replace the current draft with built-in defaults. Press \"Confirm Defaults\" to continue.",
|
||||
),
|
||||
ConfirmationPrompt::SessionClear => tr(
|
||||
"Clear saved data removes the selected session primary and non-lock sidecars. Press Confirm Clear to continue.",
|
||||
),
|
||||
ConfirmationPrompt::ShortcutResetVisible => tr(
|
||||
"Reset Visible restores the currently listed keybindings to their defaults. Press \"Confirm Reset Visible\" to continue. Nothing is written until you Save.",
|
||||
),
|
||||
ConfirmationPrompt::ShortcutResetAll => tr(
|
||||
"Reset All restores every keybinding to its built-in default. Press \"Confirm Reset All\" to continue. Nothing is written until you Save.",
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Work in progress that owns the status line until it finishes.
|
||||
///
|
||||
/// Typed for the same reason as [`ConfirmationPrompt`]: completion has to
|
||||
/// recognize its own notice in every language, and the notice re-renders when
|
||||
/// the language changes underneath it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum ProgressNotice {
|
||||
LoadingConfiguration,
|
||||
LoadingSessions,
|
||||
}
|
||||
|
||||
impl ProgressNotice {
|
||||
pub(crate) fn message(self) -> &'static str {
|
||||
match self {
|
||||
ProgressNotice::LoadingConfiguration => tr("Loading configuration..."),
|
||||
ProgressNotice::LoadingSessions => tr("Loading sessions..."),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +135,7 @@ pub(crate) enum StatusMessage {
|
||||
Success(String),
|
||||
Error(String),
|
||||
Warning(String),
|
||||
Progress(ProgressNotice),
|
||||
Confirmation(ConfirmationPrompt),
|
||||
}
|
||||
|
||||
@@ -137,6 +160,10 @@ impl StatusMessage {
|
||||
StatusMessage::Warning(message.into())
|
||||
}
|
||||
|
||||
pub(crate) fn progress(notice: ProgressNotice) -> Self {
|
||||
StatusMessage::Progress(notice)
|
||||
}
|
||||
|
||||
pub(crate) fn confirmation(prompt: ConfirmationPrompt) -> Self {
|
||||
StatusMessage::Confirmation(prompt)
|
||||
}
|
||||
@@ -145,6 +172,13 @@ impl StatusMessage {
|
||||
matches!(self, StatusMessage::Confirmation(current) if *current == prompt)
|
||||
}
|
||||
|
||||
pub(crate) fn is_progress(&self, notice: ProgressNotice) -> bool {
|
||||
matches!(self, StatusMessage::Progress(current) if *current == notice)
|
||||
}
|
||||
|
||||
/// What the status strip shows. Production code renders through the view;
|
||||
/// tests read the text directly.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn text(&self) -> Option<&str> {
|
||||
match self {
|
||||
StatusMessage::Idle => None,
|
||||
@@ -152,6 +186,7 @@ impl StatusMessage {
|
||||
| StatusMessage::Success(text)
|
||||
| StatusMessage::Error(text)
|
||||
| StatusMessage::Warning(text) => Some(text.as_str()),
|
||||
StatusMessage::Progress(notice) => Some(notice.message()),
|
||||
StatusMessage::Confirmation(prompt) => Some(prompt.message()),
|
||||
}
|
||||
}
|
||||
@@ -166,6 +201,9 @@ impl StatusMessage {
|
||||
StatusMessage::Info(text)
|
||||
| StatusMessage::Success(text)
|
||||
| StatusMessage::Warning(text) => StatusMessage::warning(format!("{text}\n{note}")),
|
||||
StatusMessage::Progress(notice) => {
|
||||
StatusMessage::warning(format!("{}\n{note}", notice.message()))
|
||||
}
|
||||
StatusMessage::Confirmation(prompt) => {
|
||||
StatusMessage::warning(format!("{}\n{note}", prompt.message()))
|
||||
}
|
||||
@@ -176,12 +214,24 @@ impl StatusMessage {
|
||||
}
|
||||
|
||||
impl ConfiguratorApp {
|
||||
/// Draws the window in the draft's language.
|
||||
///
|
||||
/// Every transition that replaces the draft (a successful load, Defaults,
|
||||
/// a finished save) and a pick in the Language row call this, before they
|
||||
/// build their status text, so that text is in the language the window is
|
||||
/// about to draw. A transition that fails keeps the language it found.
|
||||
/// The shell notices the change and rebuilds its pages in that language.
|
||||
pub(crate) fn apply_draft_language(&self) {
|
||||
wayscriber::i18n::set_language(self.draft.ui_language.to_language());
|
||||
}
|
||||
|
||||
/// The app as a launch with no destination.
|
||||
///
|
||||
/// Test-only: the binary always has a parsed launch request to pass, even
|
||||
/// when it is the empty one.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn new_app() -> (Self, Vec<Effect>) {
|
||||
crate::test_env::sanitize_language_environment();
|
||||
Self::new_app_with_startup(StartupRequest::default())
|
||||
}
|
||||
|
||||
@@ -198,7 +248,7 @@ impl ConfiguratorApp {
|
||||
baseline,
|
||||
defaults,
|
||||
document: super::document_workflow::DocumentWorkflow::loading(),
|
||||
status: StatusMessage::info("Loading configuration..."),
|
||||
status: StatusMessage::progress(ProgressNotice::LoadingConfiguration),
|
||||
active_tab: TabId::Daemon,
|
||||
active_ui_tab: UiTabId::Toolbar,
|
||||
active_keybindings_tab: KeybindingsTabId::General,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::super::super::effects::Effect;
|
||||
use super::super::super::state::{
|
||||
ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, StatusMessage,
|
||||
@@ -42,7 +44,8 @@ impl ConfiguratorApp {
|
||||
self.sync_all_color_picker_hex();
|
||||
self.clear_defaults_confirmation();
|
||||
self.clear_shortcut_editing();
|
||||
self.status = StatusMessage::info("Loaded default configuration (not saved).");
|
||||
self.apply_draft_language();
|
||||
self.status = StatusMessage::info(tr("Loaded default configuration (not saved)."));
|
||||
self.refresh_dirty_flag();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use wayscriber::config::ConfigDocument;
|
||||
|
||||
use crate::models::ConfigDraft;
|
||||
@@ -24,15 +26,19 @@ impl ConfiguratorApp {
|
||||
self.clear_defaults_confirmation();
|
||||
self.clear_shortcut_editing();
|
||||
self.refresh_migration_preview(&document);
|
||||
self.apply_draft_language();
|
||||
self.status = match repair_warning {
|
||||
None => config_document_status(&document, "Configuration loaded from disk."),
|
||||
None => {
|
||||
config_document_status(&document, tr("Configuration loaded from disk."))
|
||||
}
|
||||
// Valid TOML with values that did not map: the draft is
|
||||
// the file minus those entries, not a page of defaults.
|
||||
Some(_) if !document.section_errors().is_empty() => {
|
||||
salvaged_document_status(&document)
|
||||
}
|
||||
Some(warning) => StatusMessage::warning(format!(
|
||||
"The configuration could not be parsed, so built-in defaults were loaded for repair. Saving will create a backup before replacing the unreadable configuration with this draft. Unknown settings are retained only when the TOML structure is parseable and they can be separated safely; malformed TOML content remains only in the backup.\n{warning}"
|
||||
Some(warning) => StatusMessage::warning(trf(
|
||||
"The configuration could not be parsed, so built-in defaults were loaded for repair. Saving will create a backup before replacing the unreadable configuration with this draft. Unknown settings are retained only when the TOML structure is parseable and they can be separated safely; malformed TOML content remains only in the backup.\n{}",
|
||||
&[&warning],
|
||||
)),
|
||||
};
|
||||
// Last, so everything above reads the document by reference and
|
||||
@@ -42,7 +48,7 @@ impl ConfiguratorApp {
|
||||
Err(err) => {
|
||||
self.document.finish_load(None);
|
||||
self.status =
|
||||
StatusMessage::error(format!("Failed to load config from disk: {err}"));
|
||||
StatusMessage::error(trf("Failed to load config from disk: {}", &[&err]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,7 +66,7 @@ impl ConfiguratorApp {
|
||||
pub(in crate::app::update) fn begin_config_reload(&mut self) -> Vec<Effect> {
|
||||
if self.document.begin_reload() {
|
||||
self.clear_defaults_confirmation();
|
||||
self.status = StatusMessage::info("Reloading configuration...");
|
||||
self.status = StatusMessage::info(tr("Reloading configuration..."));
|
||||
return vec![Effect::LoadConfig];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use wayscriber::config::ConfigDocument;
|
||||
|
||||
use super::super::super::effects::Effect;
|
||||
@@ -32,18 +34,18 @@ impl ConfiguratorApp {
|
||||
let applied = result.changed;
|
||||
let kept = result.kept;
|
||||
let label = if applied == 1 {
|
||||
"shortcut update"
|
||||
tr("shortcut update")
|
||||
} else {
|
||||
"shortcut updates"
|
||||
tr("shortcut updates")
|
||||
};
|
||||
let mut message = format!("Applied {applied} {label} to the draft.");
|
||||
let mut message = trf("Applied {} {} to the draft.", &[&applied, &label]);
|
||||
if !kept.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
" Kept your edit to {}.",
|
||||
list_with_overflow(&kept, ", ")
|
||||
&[&list_with_overflow(&kept, ", ")],
|
||||
));
|
||||
}
|
||||
message.push_str(" Nothing is written until you press Save.");
|
||||
message.push_str(tr(" Nothing is written until you press Save."));
|
||||
self.status = StatusMessage::info(message);
|
||||
self.refresh_dirty_flag();
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use crate::app::document_workflow::LeaveAction;
|
||||
use wayscriber::config::{Config, ConfigDocument, ConfigValidationReport, SaveValidationError};
|
||||
|
||||
@@ -38,21 +40,22 @@ impl ConfiguratorApp {
|
||||
}
|
||||
|
||||
if self.shortcuts.editor().is_some() || self.shortcuts.recorder().is_some() {
|
||||
self.status =
|
||||
StatusMessage::error("Apply or cancel the unfinished shortcut edit before saving.");
|
||||
self.status = StatusMessage::error(tr(
|
||||
"Apply or cancel the unfinished shortcut edit before saving.",
|
||||
));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if self.shortcuts.conflict().is_some() {
|
||||
self.status = StatusMessage::error("Resolve the shortcut conflict before saving.");
|
||||
self.status = StatusMessage::error(tr("Resolve the shortcut conflict before saving."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Validate against the loaded document before starting any transfer.
|
||||
let Some(document) = self.document.loaded() else {
|
||||
self.status = StatusMessage::error(
|
||||
self.status = StatusMessage::error(tr(
|
||||
"Configuration has not loaded successfully. Reload before saving.",
|
||||
);
|
||||
));
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
@@ -61,7 +64,7 @@ impl ConfiguratorApp {
|
||||
let Some(document) = self.document.begin_save(validation, continuation) else {
|
||||
return Vec::new();
|
||||
};
|
||||
self.status = StatusMessage::info("Saving configuration...");
|
||||
self.status = StatusMessage::info(tr("Saving configuration..."));
|
||||
vec![Effect::SaveConfig {
|
||||
document: Box::new(document),
|
||||
config: Box::new(config),
|
||||
@@ -73,8 +76,9 @@ impl ConfiguratorApp {
|
||||
.map(|err| format!("{}: {}", err.field, err.message))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.status = StatusMessage::error(format!(
|
||||
"Cannot save due to validation errors:\n{message}"
|
||||
self.status = StatusMessage::error(trf(
|
||||
"Cannot save due to validation errors:\n{}",
|
||||
&[&message],
|
||||
));
|
||||
Vec::new()
|
||||
}
|
||||
@@ -121,9 +125,10 @@ impl ConfiguratorApp {
|
||||
// against it: an applied migration leaves nothing to propose,
|
||||
// and an unrelated save leaves the same proposal standing.
|
||||
self.refresh_migration_preview(&saved_document);
|
||||
let mut msg = "Configuration saved successfully.".to_string();
|
||||
self.apply_draft_language();
|
||||
let mut msg = tr("Configuration saved successfully.").to_string();
|
||||
if let Some(path) = &backup {
|
||||
msg.push_str(&format!("\nBackup created at {}", path.display()));
|
||||
msg.push_str(&trf("\nBackup created at {}", &[&path.display()]));
|
||||
}
|
||||
let mut status = config_document_status(&saved_document, &msg);
|
||||
let completion = self
|
||||
@@ -146,10 +151,10 @@ impl ConfiguratorApp {
|
||||
let restored = document.is_some();
|
||||
self.document
|
||||
.save_failed(document.map(|document| *document));
|
||||
let mut message = format!("Failed to save configuration: {err}");
|
||||
let mut message = trf("Failed to save configuration: {}", &[&err]);
|
||||
if !restored {
|
||||
message.push_str(
|
||||
"\nThe loaded configuration did not come back from the failed write. Reload before saving again.",
|
||||
tr("\nThe loaded configuration did not come back from the failed write. Reload before saving again."),
|
||||
);
|
||||
}
|
||||
self.status = StatusMessage::error(message);
|
||||
|
||||
@@ -2,6 +2,7 @@ use wayscriber::config::{
|
||||
ConfigDiagnosticKind, ConfigDocument, ConfigValidationReport, InvalidKeybinding,
|
||||
KeybindingConflictResolution, MigrationPreview,
|
||||
};
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use super::super::super::state::StatusMessage;
|
||||
|
||||
@@ -16,10 +17,15 @@ const SHOWN_DIAGNOSTICS: usize = 8;
|
||||
/// not a color.
|
||||
pub(super) fn invalid_color_hex_message(count: usize) -> String {
|
||||
if count == 1 {
|
||||
return "1 color field does not hold a color. Enter #RRGGBB or #RRGGBBAA before saving."
|
||||
.to_string();
|
||||
return tr(
|
||||
"1 color field does not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.",
|
||||
)
|
||||
.to_string();
|
||||
}
|
||||
format!("{count} color fields do not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.")
|
||||
trf(
|
||||
"{} color fields do not hold a color. Enter #RRGGBB or #RRGGBBAA before saving.",
|
||||
&[&count],
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn config_document_status(document: &ConfigDocument, success: &str) -> StatusMessage {
|
||||
@@ -53,36 +59,44 @@ pub(super) fn config_document_status(document: &ConfigDocument, success: &str) -
|
||||
}
|
||||
|
||||
if !unknown.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
"\nUnrecognized settings were preserved: {}.",
|
||||
list_with_overflow(&borrowed(&unknown), ", ")
|
||||
&[&list_with_overflow(&borrowed(&unknown), ", ")],
|
||||
));
|
||||
}
|
||||
if !retired.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
"\nRetired toolbar settings were preserved but are no longer used and can be removed: {}.",
|
||||
list_with_overflow(&borrowed(&retired), ", ")
|
||||
&[
|
||||
&list_with_overflow(&borrowed(&retired), ", "),
|
||||
],
|
||||
));
|
||||
}
|
||||
if !invalid.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
"\nShortcuts that could not be parsed are ignored for the running session; the file still has them: {}.",
|
||||
list_with_overflow(&borrowed(&invalid), "; ")
|
||||
&[
|
||||
&list_with_overflow(&borrowed(&invalid), "; "),
|
||||
],
|
||||
));
|
||||
}
|
||||
if !conflicts.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
"\nConflicting shortcuts were resolved for the running session only; the file still has them: {}.",
|
||||
list_with_overflow(&borrowed(&conflicts), "; ")
|
||||
&[
|
||||
&list_with_overflow(&borrowed(&conflicts), "; "),
|
||||
],
|
||||
));
|
||||
}
|
||||
// Its own sentence, and the last one: nothing in the file is wrong here.
|
||||
// An action this configuration never mentions was offered a shortcut this
|
||||
// build added, and the configuration already spends that key.
|
||||
if !skipped_defaults.is_empty() {
|
||||
message.push_str(&format!(
|
||||
message.push_str(&trf(
|
||||
"\nNew default shortcuts stayed inactive because this configuration already uses those keys: {}.",
|
||||
list_with_overflow(&borrowed(&skipped_defaults), "; ")
|
||||
&[
|
||||
&list_with_overflow(&borrowed(&skipped_defaults), "; "),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -102,9 +116,9 @@ pub(super) fn salvaged_document_status(document: &ConfigDocument) -> StatusMessa
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let message = format!(
|
||||
let message = trf(
|
||||
"Some settings could not be read, so the draft shows their built-in defaults: {}. Saving creates a backup, then replaces only those settings with this draft; the rest of the file is kept as written.",
|
||||
list_with_overflow(&borrowed(&unreadable), "; ")
|
||||
&[&list_with_overflow(&borrowed(&unreadable), "; ")],
|
||||
);
|
||||
match config_document_status(document, &message) {
|
||||
StatusMessage::Success(text) => StatusMessage::warning(text),
|
||||
@@ -146,18 +160,20 @@ pub(super) fn save_validation_note(validation: &ConfigValidationReport) -> Optio
|
||||
|
||||
let mut note = String::new();
|
||||
if !invalid.is_empty() {
|
||||
note.push_str(&format!(
|
||||
note.push_str(&trf(
|
||||
"Shortcuts that could not be parsed were left out of the saved configuration: {}.",
|
||||
list_with_overflow(&borrowed(&invalid), "; ")
|
||||
&[&list_with_overflow(&borrowed(&invalid), "; ")],
|
||||
));
|
||||
}
|
||||
if !conflicts.is_empty() {
|
||||
if !note.is_empty() {
|
||||
note.push('\n');
|
||||
}
|
||||
note.push_str(&format!(
|
||||
note.push_str(&trf(
|
||||
"Shortcuts two actions claimed were settled before saving, and the saved configuration keeps that outcome: {}.",
|
||||
list_with_overflow(&borrowed(&conflicts), "; ")
|
||||
&[
|
||||
&list_with_overflow(&borrowed(&conflicts), "; "),
|
||||
],
|
||||
));
|
||||
}
|
||||
Some(note)
|
||||
@@ -184,7 +200,7 @@ pub(super) fn list_with_overflow(entries: &[&str], separator: &str) -> String {
|
||||
.join(separator);
|
||||
match entries.len().saturating_sub(SHOWN_DIAGNOSTICS) {
|
||||
0 => shown,
|
||||
remaining => format!("{shown}{separator}and {remaining} more"),
|
||||
remaining => trf("{}{}and {} more", &[&shown, &separator, &remaining]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,19 +212,23 @@ pub(super) fn list_with_overflow(entries: &[&str], separator: &str) -> String {
|
||||
/// is the one thing this flow exists to avoid.
|
||||
pub(crate) fn migration_offer_text(preview: &MigrationPreview) -> String {
|
||||
let mut lines = vec![
|
||||
"Configuration update available".to_string(),
|
||||
format!(
|
||||
"Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.",
|
||||
preview.proposed_revision()
|
||||
tr("Configuration update available").to_string(),
|
||||
trf(
|
||||
tr(
|
||||
"Shortcut defaults changed since this configuration was written. Applying updates this draft only; nothing reaches the file until you press Save, which also records revision {}.",
|
||||
),
|
||||
&[&preview.proposed_revision()],
|
||||
),
|
||||
];
|
||||
for change in preview.changes() {
|
||||
lines.push(format!(
|
||||
"{} ({}): {} → {}",
|
||||
change.action_label(),
|
||||
change.config_key(),
|
||||
binding_summary(change.before()),
|
||||
binding_summary(change.after()),
|
||||
lines.push(trf(
|
||||
tr("{} ({}): {} → {}"),
|
||||
&[
|
||||
&change.action_label(),
|
||||
&change.config_key(),
|
||||
&binding_summary(change.before()),
|
||||
&binding_summary(change.after()),
|
||||
],
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
@@ -216,7 +236,7 @@ pub(crate) fn migration_offer_text(preview: &MigrationPreview) -> String {
|
||||
|
||||
fn binding_summary(bindings: &[String]) -> String {
|
||||
if bindings.is_empty() {
|
||||
return "unbound".to_string();
|
||||
return tr("unbound").to_string();
|
||||
}
|
||||
bindings.join(", ")
|
||||
}
|
||||
@@ -9,6 +9,8 @@ use crate::app::state::{ConfiguratorApp, StatusMessage};
|
||||
use crate::models::{ColorPickerId, KeybindingField, ToggleField};
|
||||
use crate::test_temp::TempDir;
|
||||
|
||||
mod language;
|
||||
|
||||
fn status_contains(status: &StatusMessage, needle: &str) -> bool {
|
||||
status.text().is_some_and(|text| text.contains(needle))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! The window language follows the draft through every transition that
|
||||
//! replaces it, and through none that fails or is canceled.
|
||||
//!
|
||||
//! Each test runs in its own process (`in_language_child`), so the language
|
||||
//! it selects reaches no other test. The override and locale variables are
|
||||
//! cleared there, so `auto` resolves to English.
|
||||
|
||||
use wayscriber::domain::Language;
|
||||
use wayscriber::i18n;
|
||||
|
||||
use super::{app_with_config_file, load_config_file, save_draft, status_contains};
|
||||
use crate::app::effects::Effect;
|
||||
use crate::models::LanguageOption;
|
||||
|
||||
const ENGLISH_FILE: &str = "[ui]\nlanguage = \"en\"\n";
|
||||
const CHINESE_FILE: &str = "[ui]\nlanguage = \"zh-CN\"\n";
|
||||
|
||||
#[test]
|
||||
fn the_first_load_applies_the_saved_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::the_first_load_applies_the_saved_language"
|
||||
),
|
||||
|| {
|
||||
let (app, _dir, _path) = app_with_config_file(CHINESE_FILE);
|
||||
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::SimplifiedChinese);
|
||||
// The status is built after the language changes, so it is Chinese too.
|
||||
assert!(status_contains(&app.status, "已从磁盘加载配置。"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_a_language_previews_it_without_writing() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::selecting_a_language_previews_it_without_writing"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, path) = app_with_config_file(ENGLISH_FILE);
|
||||
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::SimplifiedChinese);
|
||||
assert!(app.is_dirty);
|
||||
assert_eq!(super::read_config(&path), ENGLISH_FILE);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_successful_reload_applies_the_loaded_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::a_successful_reload_applies_the_loaded_language"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, path) = app_with_config_file(ENGLISH_FILE);
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
|
||||
load_config_file(&mut app, &path);
|
||||
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::English);
|
||||
assert_eq!(i18n::language(), Language::English);
|
||||
assert!(status_contains(
|
||||
&app.status,
|
||||
"Configuration loaded from disk."
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_reload_keeps_the_preview() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(module_path!(), "::a_failed_reload_keeps_the_preview"),
|
||||
|| {
|
||||
let (mut app, _dir, _path) = app_with_config_file(ENGLISH_FILE);
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
let _ = app.handle_reload_requested();
|
||||
let _ = app.discard_before_leave();
|
||||
|
||||
let _ = app.handle_config_loaded(Err("unreadable".to_string()));
|
||||
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::SimplifiedChinese);
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
assert!(status_contains(&app.status, "unreadable"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn confirmed_defaults_apply_the_default_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::confirmed_defaults_apply_the_default_language"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, _path) = app_with_config_file(CHINESE_FILE);
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
|
||||
let _ = app.handle_reset_to_defaults_requested();
|
||||
let _ = app.handle_reset_to_defaults_confirmed();
|
||||
|
||||
// The default is `auto`, which this process resolves to English.
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::Auto);
|
||||
assert_eq!(i18n::language(), Language::English);
|
||||
assert!(status_contains(
|
||||
&app.status,
|
||||
"Loaded default configuration (not saved)."
|
||||
));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_canceled_defaults_request_keeps_the_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::a_canceled_defaults_request_keeps_the_language"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, _path) = app_with_config_file(CHINESE_FILE);
|
||||
|
||||
let _ = app.handle_reset_to_defaults_requested();
|
||||
let _ = app.handle_reset_to_defaults_canceled();
|
||||
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_save_applies_the_saved_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::a_finished_save_applies_the_saved_language"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, path) = app_with_config_file(ENGLISH_FILE);
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
// Leave the preview and come back, so the save itself is what applies it.
|
||||
i18n::set_language(Language::English);
|
||||
|
||||
let _ = save_draft(&mut app);
|
||||
|
||||
assert!(super::read_config(&path).contains("language = \"zh-CN\""));
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
assert!(status_contains(&app.status, "配置已成功保存。"));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceling_the_leave_prompt_keeps_the_preview() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::canceling_the_leave_prompt_keeps_the_preview"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, _path) = app_with_config_file(ENGLISH_FILE);
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
|
||||
let _ = app.handle_reload_requested();
|
||||
assert!(app.pending_leave().is_some());
|
||||
let _ = app.cancel_leave();
|
||||
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::SimplifiedChinese);
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discarding_to_reload_applies_the_document_that_loads() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::discarding_to_reload_applies_the_document_that_loads"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _dir, path) = app_with_config_file(ENGLISH_FILE);
|
||||
let _ = app.handle_ui_language_changed(LanguageOption::SimplifiedChinese);
|
||||
let _ = app.handle_reload_requested();
|
||||
|
||||
let effects = app.discard_before_leave();
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadConfig]));
|
||||
// Another editor saved in the meantime: what loads is what counts.
|
||||
std::fs::write(&path, CHINESE_FILE).expect("rewrite config");
|
||||
load_config_file(&mut app, &path);
|
||||
|
||||
assert_eq!(app.draft.ui_language, LanguageOption::SimplifiedChinese);
|
||||
assert_eq!(i18n::language(), Language::SimplifiedChinese);
|
||||
assert!(!app.is_dirty);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ impl ConfiguratorApp {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::daemon_workflow::{ActionFeedback, DaemonFeedback};
|
||||
use crate::models::{
|
||||
DesktopEnvironment, LightShortcutApplyCapability, ShortcutApplyCapability, ShortcutBackend,
|
||||
};
|
||||
@@ -72,7 +73,7 @@ mod tests {
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text)
|
||||
.map(DaemonFeedback::text)
|
||||
.unwrap_or_default()
|
||||
.contains("Background setup action failed")
|
||||
);
|
||||
@@ -86,9 +87,9 @@ mod tests {
|
||||
fn status_loaded_does_not_clear_daemon_busy() {
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let _ = app.handle_daemon_action_requested(DaemonAction::RestartService);
|
||||
app.daemon.feedback = Some(crate::app::daemon_workflow::DaemonFeedback::Action(
|
||||
"Installing/updating background service...".to_string(),
|
||||
));
|
||||
app.daemon.feedback = Some(DaemonFeedback::Action(ActionFeedback::Pending(
|
||||
DaemonAction::InstallOrUpdateService,
|
||||
)));
|
||||
let status = test_status(DesktopEnvironment::Kde, None);
|
||||
|
||||
app.daemon.latest_status_request_id = 9;
|
||||
@@ -99,7 +100,8 @@ mod tests {
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text),
|
||||
.map(DaemonFeedback::text)
|
||||
.as_deref(),
|
||||
Some("Installing/updating background service...")
|
||||
);
|
||||
}
|
||||
@@ -117,7 +119,7 @@ mod tests {
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text)
|
||||
.map(DaemonFeedback::text)
|
||||
.unwrap_or_default()
|
||||
.contains("Background setup action failed: boom")
|
||||
);
|
||||
@@ -145,9 +147,9 @@ mod tests {
|
||||
let _ = app.handle_daemon_action_completed(Err("boom".to_string()));
|
||||
let preserved_request_id = app.daemon.latest_status_request_id;
|
||||
let _ = app.handle_daemon_action_requested(DaemonAction::RestartService);
|
||||
app.daemon.feedback = Some(crate::app::daemon_workflow::DaemonFeedback::Action(
|
||||
"Restarting background service...".to_string(),
|
||||
));
|
||||
app.daemon.feedback = Some(DaemonFeedback::Action(ActionFeedback::Pending(
|
||||
DaemonAction::RestartService,
|
||||
)));
|
||||
|
||||
let _ = app.handle_daemon_status_loaded(
|
||||
preserved_request_id,
|
||||
@@ -158,7 +160,8 @@ mod tests {
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text),
|
||||
.map(DaemonFeedback::text)
|
||||
.as_deref(),
|
||||
Some("Restarting background service...")
|
||||
);
|
||||
}
|
||||
@@ -193,6 +196,7 @@ mod tests {
|
||||
let _ = app.handle_daemon_action_completed(Ok(DaemonActionResult {
|
||||
status: new_status.clone(),
|
||||
message: "refresh complete".to_string(),
|
||||
partial_failure: false,
|
||||
}));
|
||||
|
||||
let _ = app.handle_daemon_status_loaded(old_request_id, Ok(old_status));
|
||||
@@ -209,8 +213,55 @@ mod tests {
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(crate::app::daemon_workflow::DaemonFeedback::text),
|
||||
.map(DaemonFeedback::text)
|
||||
.as_deref(),
|
||||
Some("refresh complete")
|
||||
);
|
||||
}
|
||||
|
||||
/// The panel's styling follows the outcome the setup step reports, not
|
||||
/// its words: a written config whose reload failed is a failure in every
|
||||
/// language, and a plain success never is.
|
||||
#[test]
|
||||
fn action_outcomes_keep_their_styling() {
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let outcome = |app: &mut ConfiguratorApp, result| {
|
||||
let _ = app.handle_daemon_action_requested(DaemonAction::ApplyLightControls);
|
||||
let _ = app.handle_daemon_action_completed(result);
|
||||
app.daemon
|
||||
.feedback
|
||||
.as_ref()
|
||||
.map(|feedback| (feedback.is_failure(), feedback.text()))
|
||||
.expect("feedback")
|
||||
};
|
||||
let status = || test_status(DesktopEnvironment::Hyprland, None);
|
||||
|
||||
let partial = outcome(
|
||||
&mut app,
|
||||
Ok(DaemonActionResult {
|
||||
status: status(),
|
||||
message: "已写入;hyprctl 重新加载失败:compositor unavailable".to_string(),
|
||||
partial_failure: true,
|
||||
}),
|
||||
);
|
||||
let complete = outcome(
|
||||
&mut app,
|
||||
Ok(DaemonActionResult {
|
||||
status: status(),
|
||||
message: "Wrote Hyprland light controls; reloaded Hyprland".to_string(),
|
||||
partial_failure: false,
|
||||
}),
|
||||
);
|
||||
let fatal = outcome(&mut app, Err("cannot write config".to_string()));
|
||||
|
||||
assert_eq!(
|
||||
partial,
|
||||
(
|
||||
true,
|
||||
"已写入;hyprctl 重新加载失败:compositor unavailable".to_string()
|
||||
)
|
||||
);
|
||||
assert!(!complete.0, "{complete:?}");
|
||||
assert!(fatal.0, "{fatal:?}");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
use wayscriber::config::{ToolbarItemId, ToolbarItemOrderGroup};
|
||||
|
||||
use crate::models::{
|
||||
InputHudModeOption, InputHudPositionOption, OverrideOption, ReducedMotionOption,
|
||||
StatusPositionOption, ToolbarLayoutModeOption, ToolbarOverrideField,
|
||||
InputHudModeOption, InputHudPositionOption, LanguageOption, OverrideOption,
|
||||
ReducedMotionOption, StatusPositionOption, ToolbarLayoutModeOption, ToolbarOverrideField,
|
||||
ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiThemeOption, ZoomChipDisplayOption,
|
||||
};
|
||||
|
||||
@@ -60,6 +60,19 @@ impl ConfiguratorApp {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(in crate::app::update) fn handle_ui_language_changed(
|
||||
&mut self,
|
||||
option: LanguageOption,
|
||||
) -> Vec<Effect> {
|
||||
self.status = StatusMessage::idle();
|
||||
self.draft.ui_language = option;
|
||||
// A preview: the whole window redraws in the new language now, and
|
||||
// nothing is written until Save.
|
||||
self.apply_draft_language();
|
||||
self.refresh_dirty_flag();
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub(in crate::app::update) fn handle_toolbar_layout_mode_changed(
|
||||
&mut self,
|
||||
option: ToolbarLayoutModeOption,
|
||||
|
||||
@@ -208,6 +208,7 @@ impl ConfiguratorApp {
|
||||
Message::UiReducedMotionChanged(option) => {
|
||||
self.handle_ui_reduced_motion_changed(option)
|
||||
}
|
||||
Message::UiLanguageChanged(option) => self.handle_ui_language_changed(option),
|
||||
Message::ToolbarLayoutModeChanged(option) => {
|
||||
self.handle_toolbar_layout_mode_changed(option)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use crate::models::{SessionCatalogActionResult, SessionCatalogItem, SessionCatalogOperation};
|
||||
|
||||
use super::super::effects::Effect;
|
||||
use super::super::state::{
|
||||
ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, StatusMessage,
|
||||
ConfiguratorApp, ConfirmationPrompt, PendingConfirmation, ProgressNotice, StatusMessage,
|
||||
};
|
||||
|
||||
impl ConfiguratorApp {
|
||||
@@ -16,11 +18,8 @@ impl ConfiguratorApp {
|
||||
Ok(items) => {
|
||||
self.clear_session_confirmation();
|
||||
self.session_catalog.replace_items(items);
|
||||
if matches!(self.status, StatusMessage::Info(_))
|
||||
&& self
|
||||
.status_text()
|
||||
.is_some_and(|message| message.contains("Loading sessions"))
|
||||
{
|
||||
// Only the catalog's own notice: newer feedback stays.
|
||||
if self.status.is_progress(ProgressNotice::LoadingSessions) {
|
||||
self.status = StatusMessage::idle();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +27,7 @@ impl ConfiguratorApp {
|
||||
self.session_catalog.is_loading = false;
|
||||
self.session_catalog.busy = false;
|
||||
self.status =
|
||||
StatusMessage::error(format!("Failed to load session catalog: {err}"));
|
||||
StatusMessage::error(trf("Failed to load session catalog: {}", &[&err]));
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
@@ -40,7 +39,7 @@ impl ConfiguratorApp {
|
||||
}
|
||||
self.session_catalog.is_loading = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Loading sessions...");
|
||||
self.status = StatusMessage::progress(ProgressNotice::LoadingSessions);
|
||||
vec![Effect::LoadSessionCatalog]
|
||||
}
|
||||
|
||||
@@ -50,7 +49,7 @@ impl ConfiguratorApp {
|
||||
}
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Forgetting session metadata...");
|
||||
self.status = StatusMessage::info(tr("Forgetting session metadata..."));
|
||||
vec![Effect::ForgetSessionEntry { id }]
|
||||
}
|
||||
|
||||
@@ -68,18 +67,18 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(item) = self.session_catalog.item(&id) else {
|
||||
self.status = StatusMessage::error("Session is no longer in the catalog.");
|
||||
self.status = StatusMessage::error(tr("Session is no longer in the catalog."));
|
||||
return Vec::new();
|
||||
};
|
||||
let display_name = self.session_catalog.rename_value(&id, &item.display_name);
|
||||
if display_name.trim().is_empty() {
|
||||
self.status = StatusMessage::error("Session display name cannot be empty.");
|
||||
self.status = StatusMessage::error(tr("Session display name cannot be empty."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Renaming session...");
|
||||
self.status = StatusMessage::info(tr("Renaming session..."));
|
||||
vec![Effect::RenameSessionEntry { id, display_name }]
|
||||
}
|
||||
|
||||
@@ -103,18 +102,18 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(item) = self.session_catalog.item(&id) else {
|
||||
self.status = StatusMessage::error("Session is no longer in the catalog.");
|
||||
self.status = StatusMessage::error(tr("Session is no longer in the catalog."));
|
||||
return Vec::new();
|
||||
};
|
||||
let target = self.session_catalog.duplicate_value(&id, &item.path);
|
||||
if target.trim().is_empty() {
|
||||
self.status = StatusMessage::error("Duplicate Session target cannot be empty.");
|
||||
self.status = StatusMessage::error(tr("Duplicate Session target cannot be empty."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Duplicating session...");
|
||||
self.status = StatusMessage::info(tr("Duplicating session..."));
|
||||
vec![Effect::DuplicateSessionEntry {
|
||||
id,
|
||||
target: PathBuf::from(target),
|
||||
@@ -141,18 +140,18 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(item) = self.session_catalog.item(&id) else {
|
||||
self.status = StatusMessage::error("Session is no longer in the catalog.");
|
||||
self.status = StatusMessage::error(tr("Session is no longer in the catalog."));
|
||||
return Vec::new();
|
||||
};
|
||||
let target = self.session_catalog.move_value(&id, &item.path);
|
||||
if target.trim().is_empty() {
|
||||
self.status = StatusMessage::error("Move Session target cannot be empty.");
|
||||
self.status = StatusMessage::error(tr("Move Session target cannot be empty."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Moving session...");
|
||||
self.status = StatusMessage::info(tr("Moving session..."));
|
||||
vec![Effect::MoveSessionEntry {
|
||||
id,
|
||||
target: PathBuf::from(target),
|
||||
@@ -165,7 +164,7 @@ impl ConfiguratorApp {
|
||||
}
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Opening session folder...");
|
||||
self.status = StatusMessage::info(tr("Opening session folder..."));
|
||||
vec![Effect::RevealSessionEntry { id }]
|
||||
}
|
||||
|
||||
@@ -183,13 +182,13 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.session_catalog.item(&id).is_none() {
|
||||
self.status = StatusMessage::error("Session is no longer in the catalog.");
|
||||
self.status = StatusMessage::error(tr("Session is no longer in the catalog."));
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
self.session_catalog.busy = true;
|
||||
self.clear_session_confirmation();
|
||||
self.status = StatusMessage::info("Clearing saved tool state...");
|
||||
self.status = StatusMessage::info(tr("Clearing saved tool state..."));
|
||||
vec![Effect::ClearSessionToolState { id }]
|
||||
}
|
||||
|
||||
@@ -204,7 +203,7 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
}
|
||||
if self.session_catalog.item(&id).is_none() {
|
||||
self.status = StatusMessage::error("Session is no longer in the catalog.");
|
||||
self.status = StatusMessage::error(tr("Session is no longer in the catalog."));
|
||||
return Vec::new();
|
||||
}
|
||||
self.pending_confirmation = Some(PendingConfirmation::SessionClear(id));
|
||||
@@ -225,7 +224,7 @@ impl ConfiguratorApp {
|
||||
// too, which from here is redundant rather than load-bearing.
|
||||
self.clear_session_confirmation();
|
||||
self.session_catalog.busy = true;
|
||||
self.status = StatusMessage::info("Clearing saved session data...");
|
||||
self.status = StatusMessage::info(tr("Clearing saved session data..."));
|
||||
vec![Effect::ClearSessionEntry { id }]
|
||||
}
|
||||
|
||||
@@ -265,10 +264,6 @@ impl ConfiguratorApp {
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn status_text(&self) -> Option<&str> {
|
||||
self.status.text()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -5,6 +5,7 @@ use crate::models::{
|
||||
DesktopEnvironment, LightShortcutApplyCapability, SessionCatalogState, ShortcutApplyCapability,
|
||||
ShortcutBackend,
|
||||
};
|
||||
use wayscriber::domain::Language;
|
||||
use wayscriber::env_vars::XDG_RUNTIME_DIR_ENV;
|
||||
|
||||
fn catalog_item(id: &str, display_name: &str) -> SessionCatalogItem {
|
||||
@@ -14,9 +15,9 @@ fn catalog_item(id: &str, display_name: &str) -> SessionCatalogItem {
|
||||
path: PathBuf::from(format!("/tmp/{id}.wayscriber-session")),
|
||||
path_label: format!("/tmp/{id}.wayscriber-session"),
|
||||
canonical_path_label: None,
|
||||
created_label: "now".to_string(),
|
||||
last_opened_label: "Never".to_string(),
|
||||
last_saved_label: "Never".to_string(),
|
||||
created_at_millis: 1_767_261_600_000,
|
||||
last_opened_at_millis: None,
|
||||
last_saved_at_millis: None,
|
||||
artifacts: crate::models::session::SessionArtifactSummary {
|
||||
primary_exists: false,
|
||||
backup_exists: false,
|
||||
@@ -121,6 +122,73 @@ fn catalog_load_preserves_a_defaults_confirmation() {
|
||||
assert!(app.defaults_reset_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_load_clears_its_notice_in_every_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::a_finished_load_clears_its_notice_in_every_language"
|
||||
),
|
||||
|| {
|
||||
for language in [Language::English, Language::SimplifiedChinese] {
|
||||
wayscriber::i18n::set_language(language);
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
|
||||
let effects = app.handle_session_catalog_refresh_requested();
|
||||
assert!(matches!(effects.as_slice(), [Effect::LoadSessionCatalog]));
|
||||
assert_eq!(app.status.text(), Some(tr("Loading sessions...")));
|
||||
let _ = app.handle_session_catalog_loaded(Ok(vec![catalog_item("s-1", "Lecture")]));
|
||||
|
||||
assert!(matches!(app.status, StatusMessage::Idle), "{language:?}");
|
||||
assert!(!app.session_catalog.is_loading);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_loading_notice_is_drawn_in_the_current_language() {
|
||||
crate::test_env::in_language_child(
|
||||
concat!(
|
||||
module_path!(),
|
||||
"::the_loading_notice_is_drawn_in_the_current_language"
|
||||
),
|
||||
|| {
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let _ = app.handle_session_catalog_refresh_requested();
|
||||
assert_eq!(app.status.text(), Some("Loading sessions..."));
|
||||
|
||||
wayscriber::i18n::set_language(Language::SimplifiedChinese);
|
||||
|
||||
assert_eq!(app.status.text(), Some("正在加载会话..."));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_load_keeps_newer_feedback() {
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let _ = app.handle_session_catalog_refresh_requested();
|
||||
app.status = StatusMessage::warning("Saved with warnings.");
|
||||
|
||||
let _ = app.handle_session_catalog_loaded(Ok(Vec::new()));
|
||||
|
||||
assert!(matches!(app.status, StatusMessage::Warning(_)));
|
||||
assert!(status_contains(&app.status, "Saved with warnings."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_load_stops_loading_and_shows_the_error() {
|
||||
let (mut app, _effects) = ConfiguratorApp::new_app();
|
||||
let _ = app.handle_session_catalog_refresh_requested();
|
||||
|
||||
let _ = app.handle_session_catalog_loaded(Err("catalog unreadable".to_string()));
|
||||
|
||||
assert!(!app.session_catalog.is_loading);
|
||||
assert!(matches!(app.status, StatusMessage::Error(_)));
|
||||
assert!(status_contains(&app.status, "catalog unreadable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_request_blocks_without_daemon_status() {
|
||||
let temp = crate::test_temp::tempdir().unwrap();
|
||||
|
||||
@@ -4,15 +4,16 @@
|
||||
mod tests;
|
||||
|
||||
use wayscriber::config::Shortcut;
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use crate::app::search::keybinding_row_visible;
|
||||
use crate::app::state::{ConfirmationPrompt, PendingConfirmation};
|
||||
use crate::models::keybindings::{
|
||||
AppendOutcome, KeyboardModifiers, RecordedDevice, RecordedKeyboard, RecorderDeviceKind,
|
||||
ShortcutManagerFilter, ShortcutManagerSort, ShortcutManagerSummary, ShortcutRecorderState,
|
||||
ShortcutTextEditor, append_binding, apply_recorded_replace, apply_text_replace,
|
||||
next_review_conflict, normalize_button_event, normalize_key_event, other_claimants,
|
||||
parse_keybindings, remove_binding, reset_field, reset_fields, sequence_keyboard_only_message,
|
||||
RecorderRejection, ShortcutManagerFilter, ShortcutManagerSort, ShortcutManagerSummary,
|
||||
ShortcutRecorderState, ShortcutTextEditor, append_binding, apply_recorded_replace,
|
||||
apply_text_replace, next_review_conflict, normalize_button_event, normalize_key_event,
|
||||
other_claimants, parse_keybindings, remove_binding, reset_field, reset_fields,
|
||||
text_conflicts_for,
|
||||
};
|
||||
use crate::models::{KeybindingField, keybinding_tab};
|
||||
@@ -56,12 +57,12 @@ impl ConfiguratorApp {
|
||||
return Vec::new();
|
||||
};
|
||||
match normalize_key_event(keyval, modifiers) {
|
||||
RecordedKeyboard::Pending { preview } => {
|
||||
recorder.apply_pending_preview(preview);
|
||||
RecordedKeyboard::Pending { modifiers } => {
|
||||
recorder.show_pending(modifiers);
|
||||
Vec::new()
|
||||
}
|
||||
RecordedKeyboard::Unsupported { message } => {
|
||||
recorder.prompt = message;
|
||||
RecordedKeyboard::Unsupported { reason } => {
|
||||
recorder.reject(reason);
|
||||
Vec::new()
|
||||
}
|
||||
RecordedKeyboard::Chord(binding) => {
|
||||
@@ -88,13 +89,13 @@ impl ConfiguratorApp {
|
||||
};
|
||||
let field = recorder.field;
|
||||
match normalize_button_event(button, kind, modifiers) {
|
||||
RecordedDevice::Unsupported { message } => {
|
||||
recorder.prompt = message;
|
||||
RecordedDevice::Unsupported { reason } => {
|
||||
recorder.reject(reason);
|
||||
Vec::new()
|
||||
}
|
||||
RecordedDevice::Trigger(trigger) => {
|
||||
if recorder.is_sequence() {
|
||||
recorder.prompt = sequence_keyboard_only_message().to_string();
|
||||
recorder.reject(RecorderRejection::SequenceKeyboardOnly);
|
||||
return Vec::new();
|
||||
}
|
||||
self.shortcuts.set_recorder(None);
|
||||
@@ -361,7 +362,7 @@ impl ConfiguratorApp {
|
||||
&self.defaults.keybindings,
|
||||
&fields,
|
||||
);
|
||||
self.status = StatusMessage::info("Reset visible keybindings (not saved).");
|
||||
self.status = StatusMessage::info(tr("Reset visible keybindings (not saved)."));
|
||||
self.refresh_dirty_flag();
|
||||
Vec::new()
|
||||
}
|
||||
@@ -385,7 +386,7 @@ impl ConfiguratorApp {
|
||||
self.pending_confirmation = None;
|
||||
self.draft.keybindings = self.defaults.keybindings.clone();
|
||||
self.clear_shortcut_editing();
|
||||
self.status = StatusMessage::info("Reset all keybindings (not saved).");
|
||||
self.status = StatusMessage::info(tr("Reset all keybindings (not saved)."));
|
||||
self.refresh_dirty_flag();
|
||||
Vec::new()
|
||||
}
|
||||
@@ -400,7 +401,7 @@ impl ConfiguratorApp {
|
||||
pub(super) fn handle_shortcut_conflict_review_started(&mut self) -> Vec<Effect> {
|
||||
if !self.shortcut_manager_summary().has_conflicts() {
|
||||
self.shortcuts.review = false;
|
||||
self.status = StatusMessage::info("No shortcut conflicts to review.");
|
||||
self.status = StatusMessage::info(tr("No shortcut conflicts to review."));
|
||||
return Vec::new();
|
||||
}
|
||||
self.shortcuts.review = true;
|
||||
@@ -422,11 +423,11 @@ impl ConfiguratorApp {
|
||||
claimants,
|
||||
},
|
||||
));
|
||||
self.status = StatusMessage::info("Review the next conflicting shortcut.");
|
||||
self.status = StatusMessage::info(tr("Review the next conflicting shortcut."));
|
||||
}
|
||||
None => {
|
||||
self.shortcuts.review = false;
|
||||
self.status = StatusMessage::success("No remaining shortcut conflicts.");
|
||||
self.status = StatusMessage::success(tr("No remaining shortcut conflicts."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +466,7 @@ fn sequence_recording_rejects_device_buttons() {
|
||||
assert!(
|
||||
app.shortcuts
|
||||
.recorder()
|
||||
.is_some_and(|recorder| recorder.prompt.contains("keyboard-only"))
|
||||
.is_some_and(|recorder| recorder.prompt().contains("keyboard-only"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,16 +7,17 @@ use crate::models::{
|
||||
ColorPickerId, DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption,
|
||||
DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption,
|
||||
FontWeightOption, InputHudModeOption, InputHudPositionOption, KeybindingField,
|
||||
KeybindingsTabId, KeyboardModifiers, NamedColorOption, OverrideOption, PdfFitModeOption,
|
||||
PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption,
|
||||
PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption,
|
||||
PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField,
|
||||
RecorderDeviceKind, ReducedMotionOption, RegionPickerOption, RenderProfileExportOption,
|
||||
RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult,
|
||||
SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter,
|
||||
ShortcutManagerSort, StatusPositionOption, TabId, TextField, ToggleField, ToolOption,
|
||||
ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption,
|
||||
ToolbarStrokeControlsOption, UiTabId, UiThemeOption, ZoomChipDisplayOption,
|
||||
KeybindingsTabId, KeyboardModifiers, LanguageOption, NamedColorOption, OverrideOption,
|
||||
PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption,
|
||||
PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption,
|
||||
PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField,
|
||||
PresetToggleField, RecorderDeviceKind, ReducedMotionOption, RegionPickerOption,
|
||||
RenderProfileExportOption, RenderProfileMappingSide, RenderProfileTextField,
|
||||
SessionCatalogActionResult, SessionCatalogItem, SessionCompressionOption,
|
||||
SessionStorageModeOption, ShortcutManagerFilter, ShortcutManagerSort, StatusPositionOption,
|
||||
TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField,
|
||||
ToolbarRebindModifierOption, ToolbarStrokeControlsOption, UiTabId, UiThemeOption,
|
||||
ZoomChipDisplayOption,
|
||||
};
|
||||
#[cfg(feature = "tablet-input")]
|
||||
use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption};
|
||||
@@ -131,6 +132,7 @@ pub enum Message {
|
||||
InputHudPositionChanged(InputHudPositionOption),
|
||||
UiThemeChanged(UiThemeOption),
|
||||
UiReducedMotionChanged(ReducedMotionOption),
|
||||
UiLanguageChanged(LanguageOption),
|
||||
ToolbarLayoutModeChanged(ToolbarLayoutModeOption),
|
||||
ToolbarZoomChipDisplayChanged(ZoomChipDisplayOption),
|
||||
ToolbarRebindModifierChanged(ToolbarRebindModifierOption),
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn hex_from_rgb(rgb: [f64; 3]) -> String {
|
||||
"#{:02X}{:02X}{:02X}",
|
||||
to_u8(rgb[0]),
|
||||
to_u8(rgb[1]),
|
||||
to_u8(rgb[2])
|
||||
to_u8(rgb[2]),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ pub fn hex_from_rgba(rgba: [f64; 4]) -> String {
|
||||
to_u8(rgba[0]),
|
||||
to_u8(rgba[1]),
|
||||
to_u8(rgba[2]),
|
||||
to_u8(rgba[3])
|
||||
to_u8(rgba[3]),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use wayscriber::config::enums::ColorSpec;
|
||||
use wayscriber::draw::Color;
|
||||
use wayscriber::util::{ConfigHexColorError, name_to_color, parse_config_hex_color};
|
||||
@@ -52,7 +54,7 @@ impl ColorInput {
|
||||
if value.trim().is_empty() {
|
||||
Err(FormError::new(
|
||||
field.to_string(),
|
||||
"Please enter a color name.",
|
||||
tr("Please enter a color name."),
|
||||
))
|
||||
} else {
|
||||
Ok(ColorSpec::Name(value))
|
||||
@@ -63,10 +65,13 @@ impl ColorInput {
|
||||
for (index, component) in self.rgb.iter().enumerate() {
|
||||
let field = format!("{field}[{index}]");
|
||||
let parsed = component.trim().parse::<i64>().map_err(|_| {
|
||||
FormError::new(&field, "Expected integer between 0 and 255")
|
||||
FormError::new(&field, tr("Expected integer between 0 and 255"))
|
||||
})?;
|
||||
if !(0..=255).contains(&parsed) {
|
||||
return Err(FormError::new(&field, "Value must be between 0 and 255"));
|
||||
return Err(FormError::new(
|
||||
&field,
|
||||
tr("Value must be between 0 and 255"),
|
||||
));
|
||||
}
|
||||
rgb[index] = parsed as u8;
|
||||
}
|
||||
@@ -82,7 +87,7 @@ impl ColorInput {
|
||||
if value.is_empty() {
|
||||
return Err(FormError::new(
|
||||
field.to_string(),
|
||||
"Please enter a color name or #RRGGBB / #RRGGBBAA hex color.",
|
||||
tr("Please enter a color name or #RRGGBB / #RRGGBBAA hex color."),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -92,7 +97,7 @@ impl ColorInput {
|
||||
Err(_) => {
|
||||
return Err(FormError::new(
|
||||
field.to_string(),
|
||||
"Expected #RRGGBB or #RRGGBBAA hex color.",
|
||||
tr("Expected #RRGGBB or #RRGGBBAA hex color."),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -102,7 +107,7 @@ impl ColorInput {
|
||||
} else {
|
||||
Err(FormError::new(
|
||||
field.to_string(),
|
||||
"Expected a known color name or #RRGGBB / #RRGGBBAA hex color.",
|
||||
tr("Expected a known color name or #RRGGBB / #RRGGBBAA hex color."),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -170,7 +175,7 @@ impl ColorInput {
|
||||
if self.selected_named_is_custom() {
|
||||
let trimmed = self.name.trim();
|
||||
if trimmed.is_empty() {
|
||||
"Custom name".to_string()
|
||||
tr("Custom name").to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
@@ -178,11 +183,13 @@ impl ColorInput {
|
||||
self.selected_named.label().to_string()
|
||||
}
|
||||
}
|
||||
ColorMode::Rgb => format!(
|
||||
ColorMode::Rgb => trf(
|
||||
"{}, {}, {}",
|
||||
self.rgb[0].trim(),
|
||||
self.rgb[1].trim(),
|
||||
self.rgb[2].trim()
|
||||
&[
|
||||
&self.rgb[0].trim(),
|
||||
&self.rgb[1].trim(),
|
||||
&self.rgb[2].trim(),
|
||||
],
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorMode {
|
||||
Named,
|
||||
@@ -34,28 +36,28 @@ impl NamedColorOption {
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Red => "Red",
|
||||
Self::Green => "Green",
|
||||
Self::Blue => "Blue",
|
||||
Self::Yellow => "Yellow",
|
||||
Self::Orange => "Orange",
|
||||
Self::Pink => "Pink",
|
||||
Self::White => "White",
|
||||
Self::Black => "Black",
|
||||
Self::Custom => "Custom",
|
||||
Self::Red => tr("Red"),
|
||||
Self::Green => tr("Green"),
|
||||
Self::Blue => tr("Blue"),
|
||||
Self::Yellow => tr("Yellow"),
|
||||
Self::Orange => tr("Orange"),
|
||||
Self::Pink => tr("Pink"),
|
||||
Self::White => tr("White"),
|
||||
Self::Black => tr("Black"),
|
||||
Self::Custom => tr("Custom"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_value(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Red => "red",
|
||||
Self::Green => "green",
|
||||
Self::Blue => "blue",
|
||||
Self::Yellow => "yellow",
|
||||
Self::Orange => "orange",
|
||||
Self::Pink => "pink",
|
||||
Self::White => "white",
|
||||
Self::Black => "black",
|
||||
Self::Red => tr("red"),
|
||||
Self::Green => tr("green"),
|
||||
Self::Blue => tr("blue"),
|
||||
Self::Yellow => tr("yellow"),
|
||||
Self::Orange => tr("orange"),
|
||||
Self::Pink => tr("pink"),
|
||||
Self::White => tr("white"),
|
||||
Self::Black => tr("black"),
|
||||
Self::Custom => "",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::super::error::FormError;
|
||||
use super::super::util::{format_float, parse_f64};
|
||||
|
||||
@@ -27,13 +29,13 @@ impl ColorQuadInput {
|
||||
if !parsed.is_finite() {
|
||||
return Err(FormError::new(
|
||||
format!("{field}[{index}]"),
|
||||
"Expected a finite numeric value",
|
||||
tr("Expected a finite numeric value"),
|
||||
));
|
||||
}
|
||||
if !(0.0..=1.0).contains(&parsed) {
|
||||
return Err(FormError::new(
|
||||
format!("{field}[{index}]"),
|
||||
"Expected a value between 0 and 1",
|
||||
tr("Expected a value between 0 and 1"),
|
||||
));
|
||||
}
|
||||
out[index] = parsed;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::super::error::FormError;
|
||||
use super::super::util::{format_float, parse_f64};
|
||||
|
||||
@@ -26,7 +28,7 @@ impl ColorTripletInput {
|
||||
if trimmed.is_empty() {
|
||||
return Err(FormError::new(
|
||||
format!("{field}[{index}]"),
|
||||
"Value is required",
|
||||
tr("Value is required"),
|
||||
));
|
||||
}
|
||||
let parsed = parse_f64(trimmed)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::parse_hex;
|
||||
|
||||
/// What a color field says about text the save gate refuses. Kept short
|
||||
/// because it shows as a tooltip on a field the user is still typing in.
|
||||
const INVALID_HEX_MESSAGE: &str = "Not a hex color";
|
||||
fn invalid_hex_message() -> &'static str {
|
||||
tr("Not a hex color")
|
||||
}
|
||||
|
||||
/// What an emptied color field says. Longer than the rejection message
|
||||
/// because a blank field has nothing in it to point at.
|
||||
const EMPTY_HEX_MESSAGE: &str = "Enter a color as #RRGGBB or #RRGGBBAA";
|
||||
fn empty_hex_message() -> &'static str {
|
||||
tr("Enter a color as #RRGGBB or #RRGGBBAA")
|
||||
}
|
||||
|
||||
/// The message for hex text the model's save gate refuses, `None` while the
|
||||
/// field holds a color.
|
||||
@@ -17,12 +23,12 @@ const EMPTY_HEX_MESSAGE: &str = "Enter a color as #RRGGBB or #RRGGBBAA";
|
||||
pub(crate) fn hex_field_error(text: &str) -> Option<&'static str> {
|
||||
let trimmed = text.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Some(EMPTY_HEX_MESSAGE);
|
||||
return Some(empty_hex_message());
|
||||
}
|
||||
if parse_hex(trimmed).is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(INVALID_HEX_MESSAGE)
|
||||
Some(invalid_hex_message())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -38,16 +44,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn hex_field_error_reports_text_the_save_gate_refuses() {
|
||||
assert_eq!(hex_field_error("#00FF8"), Some(INVALID_HEX_MESSAGE));
|
||||
assert_eq!(hex_field_error("nope"), Some(INVALID_HEX_MESSAGE));
|
||||
assert_eq!(hex_field_error("#GGGGGG"), Some(INVALID_HEX_MESSAGE));
|
||||
assert_eq!(hex_field_error("#00FF8"), Some(invalid_hex_message()));
|
||||
assert_eq!(hex_field_error("nope"), Some(invalid_hex_message()));
|
||||
assert_eq!(hex_field_error("#GGGGGG"), Some(invalid_hex_message()));
|
||||
}
|
||||
|
||||
/// An emptied field is a color the save cannot write, so it is refused
|
||||
/// like any other text that is not a color, with its own actionable text.
|
||||
#[test]
|
||||
fn hex_field_error_refuses_an_emptied_field() {
|
||||
assert_eq!(hex_field_error(""), Some(EMPTY_HEX_MESSAGE));
|
||||
assert_eq!(hex_field_error(" "), Some(EMPTY_HEX_MESSAGE));
|
||||
assert_eq!(hex_field_error(""), Some(empty_hex_message()));
|
||||
assert_eq!(hex_field_error(" "), Some(empty_hex_message()));
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
mod mapping;
|
||||
mod validation;
|
||||
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use super::super::color::ColorTripletInput;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -16,8 +18,8 @@ impl BoardBackgroundOption {
|
||||
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Transparent => "Transparent",
|
||||
Self::Color => "Solid color",
|
||||
Self::Transparent => tr("Transparent"),
|
||||
Self::Color => tr("Solid color"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::tr;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use wayscriber::config::{
|
||||
@@ -110,7 +112,7 @@ impl BoardItemDraft {
|
||||
_ => {
|
||||
errors.push(FormError::new(
|
||||
format!("boards.items[{index}].grid.spacing"),
|
||||
"Enter a whole number from 8 to 200.",
|
||||
tr("Enter a whole number from 8 to 200."),
|
||||
));
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::{tr, trf};
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use wayscriber::config::BoardBackgroundConfig;
|
||||
@@ -62,7 +64,10 @@ pub(super) fn check_board_list(
|
||||
errors: &mut Vec<FormError>,
|
||||
) {
|
||||
if max_count == Some(0) {
|
||||
errors.push(FormError::new("boards.max_count", "Expected at least 1"));
|
||||
errors.push(FormError::new(
|
||||
"boards.max_count",
|
||||
tr("Expected at least 1"),
|
||||
));
|
||||
}
|
||||
|
||||
let mut first_index_by_id: HashMap<String, usize> = HashMap::new();
|
||||
@@ -72,7 +77,7 @@ pub(super) fn check_board_list(
|
||||
if lowercase != id {
|
||||
errors.push(FormError::new(
|
||||
field,
|
||||
format!("Board ids are lowercase; use \"{lowercase}\""),
|
||||
trf("Board ids are lowercase; use \"{}\"", &[&lowercase]),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -80,7 +85,7 @@ pub(super) fn check_board_list(
|
||||
if let Some(first) = first_index_by_id.get(&id) {
|
||||
errors.push(FormError::new(
|
||||
field,
|
||||
format!("Board {} already uses the id \"{id}\"", first + 1),
|
||||
trf("Board {} already uses the id \"{}\"", &[&(first + 1), &id]),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
@@ -94,7 +99,7 @@ pub(super) fn check_board_list(
|
||||
if !has_transparent {
|
||||
errors.push(FormError::new(
|
||||
"boards.items",
|
||||
"Keep one board with a Transparent background; the overlay draws on it",
|
||||
tr("Keep one board with a Transparent background; the overlay draws on it"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -103,9 +108,9 @@ pub(super) fn check_board_list(
|
||||
{
|
||||
errors.push(FormError::new(
|
||||
"boards.items",
|
||||
format!(
|
||||
"{} boards exceed Max boards ({max_count}); remove a board or raise Max boards",
|
||||
draft.items.len()
|
||||
trf(
|
||||
"{} boards exceed Max boards ({}); remove a board or raise Max boards",
|
||||
&[&draft.items.len(), &max_count],
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use wayscriber::i18n::trf;
|
||||
|
||||
use crate::models::error::FormError;
|
||||
use crate::models::fields::RegionPickerOption;
|
||||
use wayscriber::config::{
|
||||
@@ -46,14 +48,14 @@ impl CaptureDraft {
|
||||
Ok(()) => config.filename_template = self.filename_template.clone(),
|
||||
Err(reason) => errors.push(FormError::new(
|
||||
"capture.filename_template",
|
||||
format!("Filename template: {reason}."),
|
||||
trf("Filename template: {}.", &[&reason]),
|
||||
)),
|
||||
}
|
||||
match validate_capture_format(&self.format) {
|
||||
Ok(format) => config.format = format,
|
||||
Err(reason) => errors.push(FormError::new(
|
||||
"capture.format",
|
||||
format!("Image format: {reason}."),
|
||||
trf("Image format: {}.", &[&reason]),
|
||||
)),
|
||||
}
|
||||
config.copy_to_clipboard = self.copy_to_clipboard;
|
||||
@@ -67,7 +69,7 @@ impl CaptureDraft {
|
||||
Ok(languages) => config.ocr_languages = languages,
|
||||
Err(reason) => errors.push(FormError::new(
|
||||
"capture.ocr_languages",
|
||||
format!("OCR languages: {reason}."),
|
||||
trf("OCR languages: {}.", &[&reason]),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@ use super::super::super::color::{ColorInput, ColorQuadInput};
|
||||
use super::super::super::fields::ZoomChipDisplayOption;
|
||||
use super::super::super::fields::{
|
||||
ArrowStyleOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption,
|
||||
InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption,
|
||||
PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption,
|
||||
PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption,
|
||||
SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
|
||||
ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
|
||||
InputHudPositionOption, LanguageOption, PdfFitModeOption, PdfLabelContentModeOption,
|
||||
PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption,
|
||||
PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption,
|
||||
ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption,
|
||||
ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
|
||||
UiThemeOption,
|
||||
};
|
||||
#[cfg(feature = "tablet-input")]
|
||||
@@ -127,6 +127,7 @@ impl ConfigDraft {
|
||||
|
||||
ui_theme: UiThemeOption::from_theme(config.ui.theme),
|
||||
ui_reduced_motion: ReducedMotionOption::from_reduced_motion(config.ui.reduced_motion),
|
||||
ui_language: LanguageOption::from_language(config.ui.language),
|
||||
ui_show_status_bar: config.ui.show_status_bar,
|
||||
ui_status_bar_interactive: config.ui.status_bar_interactive,
|
||||
ui_active_output_badge: config.ui.active_output_badge,
|
||||
|
||||
@@ -4,11 +4,11 @@ use super::super::color::{ColorInput, ColorQuadInput};
|
||||
use super::super::fields::ZoomChipDisplayOption;
|
||||
use super::super::fields::{
|
||||
ArrowStyleOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption,
|
||||
InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption,
|
||||
PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption,
|
||||
PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption,
|
||||
SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption,
|
||||
ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
|
||||
InputHudPositionOption, LanguageOption, PdfFitModeOption, PdfLabelContentModeOption,
|
||||
PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption,
|
||||
PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption,
|
||||
ReducedMotionOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption,
|
||||
ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, ToolbarStrokeControlsOption,
|
||||
UiThemeOption,
|
||||
};
|
||||
#[cfg(feature = "tablet-input")]
|
||||
@@ -87,6 +87,7 @@ pub struct ConfigDraft {
|
||||
|
||||
pub ui_theme: UiThemeOption,
|
||||
pub ui_reduced_motion: ReducedMotionOption,
|
||||
pub ui_language: LanguageOption,
|
||||
pub ui_show_status_bar: bool,
|
||||
pub ui_status_bar_interactive: bool,
|
||||
pub ui_active_output_badge: bool,
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
//! contained a comma could not be typed back in — and made every entry a string
|
||||
//! the user had to spell exactly. Keeping the list a list removes both.
|
||||
|
||||
use wayscriber::i18n::trf;
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use wayscriber::draw::{families_match, system_font_families};
|
||||
@@ -25,7 +27,11 @@ impl fmt::Display for FontCycleEditError {
|
||||
Self::BlankFamily => formatter.write_str("A font cycle entry cannot be blank"),
|
||||
Self::DuplicateFamily(family) => write!(
|
||||
formatter,
|
||||
"\"{family}\" is already in the font cycle; choose a different font or remove its other row"
|
||||
"{}",
|
||||
trf(
|
||||
"\"{}\" is already in the font cycle; choose a different font or remove its other row",
|
||||
&[&family],
|
||||
)
|
||||
),
|
||||
Self::NoInstalledFonts => {
|
||||
formatter.write_str("No installed fonts are available to add")
|
||||
|
||||
Loaded 100 of 366 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user