forked from 1b-agent/opencadstudio-upstream
feat(locale): add Fluent translations
Default to the platform language with persisted overrides across native and web builds. Refs #630
This commit is contained in:
1 parent
e102887000
commit
b90b3e3ecf
26 files changed
+1581
-112
No files matched your search
+10
-1
@@ -855,6 +855,8 @@ pub(super) struct OpenCADStudio {
|
||||
save_dialog_for_unsaved: bool,
|
||||
/// User preference for the first save of a new/unsaved drawing.
|
||||
default_save_format: String,
|
||||
/// Persisted interface language selection.
|
||||
language: crate::i18n::Language,
|
||||
|
||||
// ── DimStyle Dialog ───────────────────────────────────────────────────
|
||||
/// Name of the style currently shown in the dialog.
|
||||
@@ -1602,6 +1604,8 @@ pub enum Message {
|
||||
OptionsThemeChanged(String),
|
||||
/// Edit one of Custom theme's six base colours as #RRGGBB.
|
||||
OptionsThemeColorChanged(usize, String),
|
||||
/// Switch the interface language and redraw localized views.
|
||||
LanguageChanged(crate::i18n::Language),
|
||||
ClearScene,
|
||||
SetWireframe(bool),
|
||||
/// Set the active tab's render mode (one of acadrust's seven visual
|
||||
@@ -2648,6 +2652,10 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
fn new() -> Self {
|
||||
let config = config::AppConfig::load();
|
||||
if let Err(error) = crate::i18n::set_language(config.settings.language) {
|
||||
eprintln!("Unable to apply saved UI language: {error}");
|
||||
}
|
||||
// Boot with only the Welcome/Start tab. The user creates drawings
|
||||
// explicitly (File → New); we never auto-spawn Drawing1.
|
||||
let start_tab = DocumentTab::new_start();
|
||||
@@ -2827,6 +2835,7 @@ impl OpenCADStudio {
|
||||
save_dialog_filename: "drawing.dwg".to_string(),
|
||||
save_dialog_for_unsaved: false,
|
||||
default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(),
|
||||
language: crate::i18n::Language::default(),
|
||||
// Plot style
|
||||
active_plot_style: crate::io::plot_style::PlotStyleTable::load_named(
|
||||
crate::io::plot_style::DEFAULT_PLOT_STYLE,
|
||||
@@ -2997,7 +3006,7 @@ impl OpenCADStudio {
|
||||
// so preferences, recents, status-bar layout, ribbon density and print
|
||||
// options survive across sessions (issue #68). `last_saved_config` is
|
||||
// seeded below so the first change — not the boot — triggers a write.
|
||||
app.apply_config(config::AppConfig::load());
|
||||
app.apply_config(config);
|
||||
// Load command aliases from ocad.pgp (writes the shipped default file
|
||||
// on first launch). The hide-set keeps aliases out of autocomplete while
|
||||
// their target command still shows.
|
||||
|
||||
@@ -151,6 +151,9 @@ pub struct UserSettings {
|
||||
/// (#188).
|
||||
pub bg_color: Option<[u8; 3]>,
|
||||
pub paper_bg_color: Option<[u8; 3]>,
|
||||
/// Interface language preference. `System` negotiates against the
|
||||
/// platform locale on every launch.
|
||||
pub language: crate::i18n::Language,
|
||||
}
|
||||
|
||||
impl Default for UserSettings {
|
||||
@@ -177,6 +180,7 @@ impl Default for UserSettings {
|
||||
pick_drag_rect: false,
|
||||
bg_color: None,
|
||||
paper_bg_color: None,
|
||||
language: crate::i18n::Language::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,6 +288,7 @@ impl OpenCADStudio {
|
||||
pick_drag_rect: self.pick_drag_rect,
|
||||
bg_color: self.default_bg_color.map(f4_to_u3),
|
||||
paper_bg_color: self.default_paper_bg_color.map(f4_to_u3),
|
||||
language: self.language,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +318,9 @@ impl OpenCADStudio {
|
||||
self.pick_drag_rect = s.pick_drag_rect;
|
||||
self.default_bg_color = s.bg_color.map(u3_to_f4);
|
||||
self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4);
|
||||
if crate::i18n::set_language(s.language).is_ok() {
|
||||
self.language = s.language;
|
||||
}
|
||||
// Push the restored background onto every drawing tab that exists now
|
||||
// (the start tab and any initial drawing). Tabs created later pick it
|
||||
// up via `apply_bg_default` at their construction site.
|
||||
|
||||
@@ -4323,6 +4323,22 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::LanguageChanged(language) => {
|
||||
if self.language == language {
|
||||
return Task::none();
|
||||
}
|
||||
match crate::i18n::set_language(language) {
|
||||
Ok(()) => {
|
||||
self.language = language;
|
||||
self.persist_settings_if_changed();
|
||||
}
|
||||
Err(error) => self
|
||||
.command_line
|
||||
.push_error(&format!("Unable to change UI language: {error}")),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
Message::AboutOpen => {
|
||||
self.active_modal = Some(super::ModalKind::About);
|
||||
Task::none()
|
||||
|
||||
+29
-29
@@ -2318,7 +2318,7 @@ fn start_page_content<'a>(
|
||||
let headline = text("Open CAD Studio").size(40).style(start_primary_style);
|
||||
|
||||
// Plain outlined button (Open / New / Help / Contribute).
|
||||
let outline_btn = |label: &'static str, msg: Message| {
|
||||
let outline_btn = |label: String, msg: Message| {
|
||||
button(text(label).size(14))
|
||||
.on_press(msg)
|
||||
.padding([10, 22])
|
||||
@@ -2346,7 +2346,7 @@ fn start_page_content<'a>(
|
||||
button(
|
||||
row![
|
||||
crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 14.0),
|
||||
text("Donate").size(14),
|
||||
text(crate::tr!("start-donate")).size(14),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Center),
|
||||
@@ -2360,8 +2360,8 @@ fn start_page_content<'a>(
|
||||
};
|
||||
|
||||
let primary_row = WrapFlow::new(vec![
|
||||
outline_btn("New Drawing", Message::TabNew).into(),
|
||||
outline_btn("Open File…", Message::OpenFile).into(),
|
||||
outline_btn(crate::tr!("start-new-drawing"), Message::TabNew).into(),
|
||||
outline_btn(crate::tr!("start-open-file"), Message::OpenFile).into(),
|
||||
donate_btn.into(),
|
||||
])
|
||||
.spacing_x(12.0)
|
||||
@@ -2371,16 +2371,16 @@ fn start_page_content<'a>(
|
||||
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
|
||||
let mut secondary_items: Vec<Element<'a, Message>> = vec![
|
||||
outline_btn(
|
||||
"Send Feedback",
|
||||
crate::tr!("start-send-feedback"),
|
||||
Message::RibbonToolClick {
|
||||
tool_id: "REPORT".to_string(),
|
||||
event: crate::modules::ModuleEvent::Command("REPORT".to_string()),
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
outline_btn("Options", Message::OptionsOpen).into(),
|
||||
outline_btn(crate::tr!("action-options"), Message::OptionsOpen).into(),
|
||||
];
|
||||
secondary_items.push(outline_btn("Plugins", Message::PluginManagerOpen).into());
|
||||
secondary_items.push(outline_btn(crate::tr!("action-plugins"), Message::PluginManagerOpen).into());
|
||||
// The web build is already in the browser, so only the desktop offers a
|
||||
// link to the web version.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -2403,7 +2403,7 @@ fn start_page_content<'a>(
|
||||
.report_natural_width(action_width_out.clone());
|
||||
|
||||
let sponsors = column![
|
||||
text("Sponsors").size(15),
|
||||
text(crate::tr!("start-sponsors")).size(15),
|
||||
mouse_area(
|
||||
container(
|
||||
iced::widget::svg(iced::widget::svg::Handle::from_memory(include_bytes!(
|
||||
@@ -2505,7 +2505,7 @@ fn start_page_content<'a>(
|
||||
// whole thumbnail remains visible when that width changes.
|
||||
let thumb_h =
|
||||
(panel_w - VIDEO_PANEL_PADDING * 2.0 - VIDEO_SCROLL_GUTTER) * 9.0 / 16.0;
|
||||
let mut list = column![text("Tutorials").size(15)]
|
||||
let mut list = column![text(crate::tr!("start-tutorials")).size(15)]
|
||||
.spacing(10)
|
||||
.width(Fill)
|
||||
// Keep the scrollbar off the thumbnails.
|
||||
@@ -2545,14 +2545,14 @@ fn start_page_content<'a>(
|
||||
}
|
||||
if videos.is_empty() {
|
||||
let note = if videos_loading {
|
||||
"Loading videos…"
|
||||
crate::tr!("start-loading-videos")
|
||||
} else {
|
||||
"Videos load from the internet."
|
||||
crate::tr!("start-videos-online")
|
||||
};
|
||||
list = list.push(text(note).size(12).style(start_muted_style));
|
||||
}
|
||||
let playlist_btn = mouse_area(
|
||||
container(text("Open playlist on YouTube").size(12))
|
||||
container(text(crate::tr!("start-open-playlist")).size(12))
|
||||
.padding([6, 10])
|
||||
.width(Fill)
|
||||
.center_x(Fill)
|
||||
@@ -2605,7 +2605,7 @@ fn start_page_content<'a>(
|
||||
// web builds read the CI-generated snapshot. Both sources mark pinned
|
||||
// discussions and sort them before the rest of the list.
|
||||
let discussions_panel: Element<'a, Message> = {
|
||||
let mut list = column![text("Discussions").size(15)]
|
||||
let mut list = column![text(crate::tr!("start-discussions")).size(15)]
|
||||
.spacing(8)
|
||||
.width(Fill);
|
||||
for discussion in discussions {
|
||||
@@ -2618,7 +2618,7 @@ fn start_page_content<'a>(
|
||||
.align_y(iced::Center);
|
||||
if discussion.pinned {
|
||||
meta = meta.push(
|
||||
text("Pinned")
|
||||
text(crate::tr!("start-pinned"))
|
||||
.size(10)
|
||||
.style(start_primary_style),
|
||||
);
|
||||
@@ -2661,14 +2661,14 @@ fn start_page_content<'a>(
|
||||
}
|
||||
if discussions.is_empty() {
|
||||
let note = if discussions_loading {
|
||||
"Loading discussions…"
|
||||
crate::tr!("start-loading-discussions")
|
||||
} else {
|
||||
"Discussions load from GitHub."
|
||||
crate::tr!("start-discussions-online")
|
||||
};
|
||||
list = list.push(text(note).size(12).style(start_muted_style));
|
||||
}
|
||||
let open_btn = mouse_area(
|
||||
container(text("Open Discussions on GitHub").size(12))
|
||||
container(text(crate::tr!("start-open-discussions")).size(12))
|
||||
.padding([6, 10])
|
||||
.width(Fill)
|
||||
.center_x(Fill)
|
||||
@@ -2725,7 +2725,7 @@ fn start_page_content<'a>(
|
||||
// shows, so the rail always invites support.
|
||||
let supporters: Element<'a, Message> = {
|
||||
let mut list = column![
|
||||
text("Supporters").size(15),
|
||||
text(crate::tr!("start-supporters")).size(15),
|
||||
Space::new().height(iced::Length::Fixed(12.0)),
|
||||
]
|
||||
.spacing(6)
|
||||
@@ -2746,7 +2746,7 @@ fn start_page_content<'a>(
|
||||
container(
|
||||
iced::widget::row![
|
||||
crate::ui::icons::themed_danger_text(crate::ui::icons::HEART, 13.0),
|
||||
text("Support on Patreon").size(12),
|
||||
text(crate::tr!("start-support-on-patreon")).size(12),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
@@ -2831,7 +2831,7 @@ fn start_page_content<'a>(
|
||||
.height(Fill)
|
||||
.into(),
|
||||
StartLayout::Compact => {
|
||||
let tab_btn = |label: &'static str, section: super::StartSection| {
|
||||
let tab_btn = |label: String, section: super::StartSection| {
|
||||
let is_active = active == section;
|
||||
button(text(label).size(14))
|
||||
.on_press(Message::StartSectionSelect(section))
|
||||
@@ -2864,11 +2864,11 @@ fn start_page_content<'a>(
|
||||
})
|
||||
};
|
||||
let tab_bar = Row::with_children(vec![
|
||||
tab_btn("Recent Files", super::StartSection::Recent).into(),
|
||||
tab_btn("Videos", super::StartSection::Videos).into(),
|
||||
tab_btn("Welcome", super::StartSection::Welcome).into(),
|
||||
tab_btn("Discussions", super::StartSection::Discussions).into(),
|
||||
tab_btn("Supporters", super::StartSection::Supporters).into(),
|
||||
tab_btn(crate::tr!("start-recent-files"), super::StartSection::Recent).into(),
|
||||
tab_btn(crate::tr!("start-videos"), super::StartSection::Videos).into(),
|
||||
tab_btn(crate::tr!("start-welcome"), super::StartSection::Welcome).into(),
|
||||
tab_btn(crate::tr!("start-discussions"), super::StartSection::Discussions).into(),
|
||||
tab_btn(crate::tr!("start-supporters"), super::StartSection::Supporters).into(),
|
||||
])
|
||||
.spacing(6.0)
|
||||
.align_y(iced::Center)
|
||||
@@ -2944,11 +2944,11 @@ pub(super) fn recent_files_panel<'a>(
|
||||
) -> Element<'a, Message> {
|
||||
// Title mirrors the Supporters rail: size 15 in the bright text colour,
|
||||
// followed by a 12px gap before the content.
|
||||
let title = text("Recent Documents").size(15);
|
||||
let title = text(crate::tr!("start-recent-documents")).size(15);
|
||||
|
||||
let body: Element<'a, Message> = if recents.is_empty() {
|
||||
container(
|
||||
text("Files you open will show up here.")
|
||||
text(crate::tr!("start-no-recent-files"))
|
||||
.size(12)
|
||||
.style(start_muted_style)
|
||||
)
|
||||
@@ -2976,7 +2976,7 @@ pub(super) fn recent_files_panel<'a>(
|
||||
// directory line.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let dir = if dir.is_empty() {
|
||||
"Browser storage".to_string()
|
||||
crate::tr!("start-browser-storage")
|
||||
} else {
|
||||
dir
|
||||
};
|
||||
@@ -3087,7 +3087,7 @@ pub(super) fn recent_files_panel<'a>(
|
||||
.padding([2, 6])
|
||||
.width(iced::Length::Fixed(46.0));
|
||||
let limit_row = row![
|
||||
text("Keep recent files").size(11).style(start_muted_style).width(Fill),
|
||||
text(crate::tr!("start-keep-recent-files")).size(11).style(start_muted_style).width(Fill),
|
||||
button(crate::ui::icons::themed(crate::ui::icons::MINUS, 11.0))
|
||||
.on_press(Message::SetRecentLimit(shown.saturating_sub(STEP)))
|
||||
.padding([3, 6])
|
||||
|
||||
+32
-31
@@ -5,41 +5,41 @@ use iced::{Background, Element, Fill, Fit, Theme};
|
||||
impl OpenCADStudio {
|
||||
/// Title shown in the active modal's title bar. Keep in sync with the
|
||||
/// [`Self::modal_content`] dispatch.
|
||||
pub(super) fn modal_title(&self) -> &'static str {
|
||||
pub(super) fn modal_title(&self) -> String {
|
||||
use super::super::ModalKind as K;
|
||||
match self.active_modal {
|
||||
Some(K::About) => "About",
|
||||
Some(K::Shortcuts) => "Keyboard Shortcuts",
|
||||
Some(K::Aliases) => "Command Aliases",
|
||||
Some(K::Options) => "Options",
|
||||
Some(K::FindReplace) => "Find and Replace",
|
||||
Some(K::PluginManager) => "Plugin Manager",
|
||||
Some(K::UpdateNotice) => "Update Available",
|
||||
Some(K::Layers) => "Layer Manager",
|
||||
Some(K::LayerStateManager) => "Layer State Manager",
|
||||
Some(K::LayerStateEditor) => "Edit Layer State",
|
||||
Some(K::Plot) => "Plot",
|
||||
Some(K::LayoutManager) => "Layout Manager",
|
||||
Some(K::ScaleManager) => "Scale Manager",
|
||||
Some(K::AnnoObjectScale) => "Annotation Object Scale",
|
||||
Some(K::Plotstyle) => "Plot Style Editor",
|
||||
Some(K::TextStyle) => "Text Style Manager",
|
||||
Some(K::MlStyle) => "Multiline Style Manager",
|
||||
Some(K::TableStyle) => "Table Style Manager",
|
||||
Some(K::MLeaderStyle) => "Multileader Style Manager",
|
||||
Some(K::DimStyle) => "Dimension Style Manager",
|
||||
Some(K::AssocPrompt) => "Default Application",
|
||||
Some(K::AecDropWarning) => "Save Warning",
|
||||
Some(K::About) => crate::tr!("modal-about"),
|
||||
Some(K::Shortcuts) => crate::tr!("modal-keyboard-shortcuts"),
|
||||
Some(K::Aliases) => crate::tr!("modal-command-aliases"),
|
||||
Some(K::Options) => crate::tr!("action-options"),
|
||||
Some(K::FindReplace) => crate::tr!("modal-find-replace"),
|
||||
Some(K::PluginManager) => crate::tr!("modal-plugin-manager"),
|
||||
Some(K::UpdateNotice) => crate::tr!("modal-update-available"),
|
||||
Some(K::Layers) => crate::tr!("modal-layer-manager"),
|
||||
Some(K::LayerStateManager) => crate::tr!("modal-layer-state-manager"),
|
||||
Some(K::LayerStateEditor) => crate::tr!("modal-edit-layer-state"),
|
||||
Some(K::Plot) => crate::tr!("modal-plot"),
|
||||
Some(K::LayoutManager) => crate::tr!("modal-layout-manager"),
|
||||
Some(K::ScaleManager) => crate::tr!("modal-scale-manager"),
|
||||
Some(K::AnnoObjectScale) => crate::tr!("modal-annotation-object-scale"),
|
||||
Some(K::Plotstyle) => crate::tr!("modal-plot-style-editor"),
|
||||
Some(K::TextStyle) => crate::tr!("modal-text-style-manager"),
|
||||
Some(K::MlStyle) => crate::tr!("modal-multiline-style-manager"),
|
||||
Some(K::TableStyle) => crate::tr!("modal-table-style-manager"),
|
||||
Some(K::MLeaderStyle) => crate::tr!("modal-multileader-style-manager"),
|
||||
Some(K::DimStyle) => crate::tr!("modal-dimension-style-manager"),
|
||||
Some(K::AssocPrompt) => crate::tr!("modal-default-application"),
|
||||
Some(K::AecDropWarning) => crate::tr!("modal-save-warning"),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Some(K::FileInUse) => "Unable to Save Drawing",
|
||||
Some(K::FileInUse) => crate::tr!("modal-unable-save"),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
Some(K::ExternalChange) => "Drawing Changed on Disk",
|
||||
Some(K::LayerDeleteWarning) => "Delete Layer",
|
||||
Some(K::Unsaved) => "Unsaved Changes",
|
||||
Some(K::PointStyle) => "Point Style",
|
||||
Some(K::AttributeEditor) => "Attribute Editor",
|
||||
Some(K::SaveDialog) => "Save Drawing As",
|
||||
None => "",
|
||||
Some(K::ExternalChange) => crate::tr!("modal-drawing-changed"),
|
||||
Some(K::LayerDeleteWarning) => crate::tr!("modal-delete-layer"),
|
||||
Some(K::Unsaved) => crate::tr!("modal-unsaved-changes"),
|
||||
Some(K::PointStyle) => crate::tr!("modal-point-style"),
|
||||
Some(K::AttributeEditor) => crate::tr!("modal-attribute-editor"),
|
||||
Some(K::SaveDialog) => crate::tr!("modal-save-drawing-as"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ impl OpenCADStudio {
|
||||
&self.default_save_format,
|
||||
&self.ui_theme,
|
||||
&self.theme_color_inputs,
|
||||
self.language,
|
||||
flow,
|
||||
)
|
||||
},
|
||||
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
//! Application language selection and embedded Fluent resources.
|
||||
|
||||
use i18n_embed::fluent::{fluent_language_loader, FluentLanguageLoader};
|
||||
use i18n_embed::LanguageLoader;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use i18n_embed::DesktopLanguageRequester;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use i18n_embed::WebLanguageRequester;
|
||||
use rust_embed::RustEmbed;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
#[derive(RustEmbed)]
|
||||
#[folder = "locales/"]
|
||||
struct Localizations;
|
||||
|
||||
/// User-selectable UI language. `System` keeps following the platform's
|
||||
/// preferred locale while explicit choices remain stable across restarts.
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
|
||||
pub enum Language {
|
||||
#[default]
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "en-US")]
|
||||
EnUs,
|
||||
#[serde(rename = "tr-TR")]
|
||||
TrTr,
|
||||
#[serde(rename = "nl-NL")]
|
||||
NlNl,
|
||||
#[serde(rename = "fr-FR")]
|
||||
FrFr,
|
||||
#[serde(rename = "de-DE")]
|
||||
DeDe,
|
||||
#[serde(rename = "hi-IN")]
|
||||
HiIn,
|
||||
#[serde(rename = "ru-RU")]
|
||||
RuRu,
|
||||
#[serde(rename = "zh-CN")]
|
||||
ZhCn,
|
||||
}
|
||||
|
||||
impl Language {
|
||||
pub const ALL: [Language; 9] = [
|
||||
Language::System,
|
||||
Language::EnUs,
|
||||
Language::TrTr,
|
||||
Language::NlNl,
|
||||
Language::FrFr,
|
||||
Language::DeDe,
|
||||
Language::HiIn,
|
||||
Language::RuRu,
|
||||
Language::ZhCn,
|
||||
];
|
||||
|
||||
fn requested(self) -> Vec<i18n_embed::unic_langid::LanguageIdentifier> {
|
||||
match self {
|
||||
Language::System => system_languages(),
|
||||
Language::EnUs => vec!["en-US".parse().expect("valid locale")],
|
||||
Language::TrTr => vec!["tr-TR".parse().expect("valid locale")],
|
||||
Language::NlNl => vec!["nl-NL".parse().expect("valid locale")],
|
||||
Language::FrFr => vec!["fr-FR".parse().expect("valid locale")],
|
||||
Language::DeDe => vec!["de-DE".parse().expect("valid locale")],
|
||||
Language::HiIn => vec!["hi-IN".parse().expect("valid locale")],
|
||||
Language::RuRu => vec!["ru-RU".parse().expect("valid locale")],
|
||||
Language::ZhCn => vec!["zh-CN".parse().expect("valid locale")],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Language {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let label = match self {
|
||||
Language::System => crate::tr!("language-system"),
|
||||
Language::EnUs => crate::tr!("language-english"),
|
||||
Language::TrTr => crate::tr!("language-turkish"),
|
||||
Language::NlNl => crate::tr!("language-dutch"),
|
||||
Language::FrFr => crate::tr!("language-french"),
|
||||
Language::DeDe => crate::tr!("language-german"),
|
||||
Language::HiIn => crate::tr!("language-hindi"),
|
||||
Language::RuRu => crate::tr!("language-russian"),
|
||||
Language::ZhCn => crate::tr!("language-chinese-simplified"),
|
||||
};
|
||||
f.write_str(&label)
|
||||
}
|
||||
}
|
||||
|
||||
fn system_languages() -> Vec<i18n_embed::unic_langid::LanguageIdentifier> {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let requested = WebLanguageRequester::requested_languages();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let requested = DesktopLanguageRequester::requested_languages();
|
||||
requested
|
||||
}
|
||||
|
||||
fn load_language(
|
||||
loader: &FluentLanguageLoader,
|
||||
language: Language,
|
||||
) -> Result<(), i18n_embed::I18nEmbedError> {
|
||||
let mut requested = language.requested();
|
||||
if requested.is_empty() {
|
||||
requested.push(loader.fallback_language().clone());
|
||||
}
|
||||
i18n_embed::select(loader, &Localizations, &requested).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn loader() -> &'static FluentLanguageLoader {
|
||||
static LOADER: OnceLock<FluentLanguageLoader> = OnceLock::new();
|
||||
LOADER.get_or_init(|| {
|
||||
let loader = fluent_language_loader!();
|
||||
if let Err(error) = load_language(&loader, Language::System) {
|
||||
eprintln!("Unable to load system UI language: {error}");
|
||||
loader
|
||||
.load_languages(&Localizations, &[loader.fallback_language().clone()])
|
||||
.expect("fallback UI language must be embedded");
|
||||
}
|
||||
loader
|
||||
})
|
||||
}
|
||||
|
||||
/// Apply a user preference process-wide. The loader swaps resources atomically,
|
||||
/// so the next Iced view pass immediately receives the new language.
|
||||
pub fn set_language(language: Language) -> Result<(), i18n_embed::I18nEmbedError> {
|
||||
load_language(loader(), language)
|
||||
}
|
||||
|
||||
/// Built-in ribbon modules have stable ids; plug-ins keep their supplied title
|
||||
/// until they provide their own localization bundle.
|
||||
pub fn ribbon_module_title(id: &str, fallback: &str) -> String {
|
||||
match id {
|
||||
"draw" => crate::tr!("ribbon-tab-draw"),
|
||||
"annotate" => crate::tr!("ribbon-tab-annotate"),
|
||||
"insert" => crate::tr!("ribbon-tab-insert"),
|
||||
"model" => crate::tr!("ribbon-tab-model"),
|
||||
"layout" => crate::tr!("ribbon-tab-layout"),
|
||||
"manage" => crate::tr!("ribbon-tab-manage"),
|
||||
"view" => crate::tr!("ribbon-tab-view"),
|
||||
_ => fallback.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! tr {
|
||||
($message_id:literal $(,)?) => {
|
||||
i18n_embed_fl::fl!($crate::i18n::loader(), $message_id)
|
||||
};
|
||||
($message_id:literal, $($name:ident = $value:expr),+ $(,)?) => {
|
||||
i18n_embed_fl::fl!(
|
||||
$crate::i18n::loader(),
|
||||
$message_id,
|
||||
$($name = $value),+
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,7 @@ pub mod config;
|
||||
pub mod cli;
|
||||
pub mod command;
|
||||
pub mod entities;
|
||||
pub mod i18n;
|
||||
pub mod io;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) mod network;
|
||||
|
||||
@@ -10,6 +10,7 @@ mod config;
|
||||
mod cli;
|
||||
mod command;
|
||||
mod entities;
|
||||
mod i18n;
|
||||
mod io;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod network;
|
||||
|
||||
@@ -61,23 +61,27 @@ impl CadCommand for CopyCommand {
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
if self.awaiting_count {
|
||||
return "COPY Enter number of items to array:".to_string();
|
||||
return crate::tr!("command-copy-array-count");
|
||||
}
|
||||
match &self.step {
|
||||
Step::Base => format!(
|
||||
"COPY Specify base point [{} objects]:",
|
||||
self.handles.len()
|
||||
Step::Base => crate::tr!(
|
||||
"command-copy-base",
|
||||
count = (self.handles.len() as i64),
|
||||
),
|
||||
Step::Placing(base) => {
|
||||
if let Some(n) = self.array_count {
|
||||
format!(
|
||||
"COPY Specify second point for {n}-item array [base {:.3},{:.3}]:",
|
||||
base.x, base.y
|
||||
crate::tr!(
|
||||
"command-copy-array-target",
|
||||
count = (n as i64),
|
||||
x = format!("{:.3}", base.x),
|
||||
y = format!("{:.3}", base.y),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"COPY Specify destination [{} copies so far | Array | Enter=done | base {:.3},{:.3}]:",
|
||||
self.count, base.x, base.y
|
||||
crate::tr!(
|
||||
"command-copy-target",
|
||||
count = (self.count as i64),
|
||||
x = format!("{:.3}", base.x),
|
||||
y = format!("{:.3}", base.y),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,13 +53,14 @@ impl CadCommand for MoveCommand {
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
match &self.step {
|
||||
Step::Base => format!(
|
||||
"MOVE Specify base point [{} objects]:",
|
||||
self.handles.len()
|
||||
Step::Base => crate::tr!(
|
||||
"command-move-base",
|
||||
count = (self.handles.len() as i64),
|
||||
),
|
||||
Step::Target(base) => format!(
|
||||
"MOVE Specify destination [base {:.3},{:.3}]:",
|
||||
base.x, base.y
|
||||
Step::Target(base) => crate::tr!(
|
||||
"command-move-target",
|
||||
x = format!("{:.3}", base.x),
|
||||
y = format!("{:.3}", base.y),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ pub enum EntryKind {
|
||||
impl CommandLine {
|
||||
pub fn new() -> Self {
|
||||
let mut cl = Self::default();
|
||||
cl.push_info("Open CAD Studio ready.");
|
||||
cl.push_info("Type a command or use the ribbon. Open OBJ: INSERT tab.");
|
||||
cl.push_info(&crate::tr!("command-line-ready"));
|
||||
cl.push_info(&crate::tr!("command-line-hint"));
|
||||
cl
|
||||
}
|
||||
|
||||
@@ -430,7 +430,7 @@ impl CommandLine {
|
||||
}
|
||||
});
|
||||
let prompt = container(
|
||||
text("Command:").size(11).style(|theme: &Theme| iced::widget::text::Style {
|
||||
text(crate::tr!("command-line-label")).size(11).style(|theme: &Theme| iced::widget::text::Style {
|
||||
color: Some(theme.palette().success.base.color),
|
||||
}),
|
||||
)
|
||||
@@ -467,10 +467,7 @@ impl CommandLine {
|
||||
..Default::default()
|
||||
}
|
||||
});
|
||||
let literal_tip = container(
|
||||
text("Literal spaces: Space stays in the line instead of running the command (same as typing a leading '>'). Stays on until toggled off.")
|
||||
.size(11),
|
||||
)
|
||||
let literal_tip = container(text(crate::tr!("command-line-literal-spaces")).size(11))
|
||||
.padding([3, 6])
|
||||
.style(container::bordered_box);
|
||||
let literal_btn = tooltip(literal_btn, literal_tip, tooltip::Position::Top).gap(4);
|
||||
|
||||
+1
-1
@@ -253,7 +253,7 @@ pub fn backdrop<'a>(
|
||||
/// title bar; pass `Vector::ZERO` to keep it centred.
|
||||
pub fn modal<'a>(
|
||||
base: impl Into<Element<'a, Message>>,
|
||||
title: &'a str,
|
||||
title: impl iced::widget::text::IntoFragment<'a>,
|
||||
content: impl Into<Element<'a, Message>>,
|
||||
on_close: Message,
|
||||
offset: Vector,
|
||||
|
||||
@@ -422,7 +422,7 @@ impl Ribbon {
|
||||
let is_active = i == self.active;
|
||||
let is_contextual = module.id() == "layout";
|
||||
let btn = container(
|
||||
button(text(module.title()).size(12))
|
||||
button(text(crate::i18n::ribbon_module_title(module.id(), module.title())).size(12))
|
||||
.on_press(Message::RibbonSelectTab(i))
|
||||
.style(move |theme: &Theme, status| {
|
||||
let palette = theme.palette();
|
||||
|
||||
+29
-17
@@ -9,6 +9,7 @@ pub fn view_window<'a>(
|
||||
default_save_format: &'a str,
|
||||
ui_theme: &'a UiThemeConfig,
|
||||
theme_color_inputs: &'a [String; 6],
|
||||
language: crate::i18n::Language,
|
||||
sizing: crate::ui::modal::ModalSizing,
|
||||
) -> Element<'a, Message> {
|
||||
let selected_format = crate::io::SAVE_FORMAT_OPTIONS
|
||||
@@ -25,12 +26,12 @@ pub fn view_window<'a>(
|
||||
|
||||
let palette = ui_theme.palette.to_iced();
|
||||
let colors = [
|
||||
("Background", palette.background),
|
||||
("Text", palette.text),
|
||||
("Primary", palette.primary),
|
||||
("Success", palette.success),
|
||||
("Warning", palette.warning),
|
||||
("Danger", palette.danger),
|
||||
(crate::tr!("options-color-background"), palette.background),
|
||||
(crate::tr!("options-color-text"), palette.text),
|
||||
(crate::tr!("options-color-primary"), palette.primary),
|
||||
(crate::tr!("options-color-success"), palette.success),
|
||||
(crate::tr!("options-color-warning"), palette.warning),
|
||||
(crate::tr!("options-color-danger"), palette.danger),
|
||||
];
|
||||
|
||||
let mut color_controls = column![].spacing(8);
|
||||
@@ -60,16 +61,31 @@ pub fn view_window<'a>(
|
||||
);
|
||||
}
|
||||
|
||||
let close = button(text("Close").size(12))
|
||||
let close = button(text(crate::tr!("action-close")).size(12))
|
||||
.on_press(Message::CloseModal)
|
||||
.padding([6, 18])
|
||||
.style(button::secondary);
|
||||
|
||||
let content = column![
|
||||
text("Open and Save").size(15),
|
||||
text(crate::tr!("options-language-section")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text("Default save format:").size(12).width(150),
|
||||
text(crate::tr!("options-language-label")).size(12).width(150),
|
||||
iced::widget::pick_list(
|
||||
Some(language),
|
||||
crate::i18n::Language::ALL,
|
||||
|value| value.to_string(),
|
||||
)
|
||||
.on_select(Message::LanguageChanged)
|
||||
.width(sizing.width),
|
||||
]
|
||||
.spacing(12)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(22),
|
||||
text(crate::tr!("options-open-save-section")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text(crate::tr!("options-default-save-format-label")).size(12).width(150),
|
||||
iced::widget::pick_list(
|
||||
selected_format,
|
||||
crate::io::SAVE_FORMAT_OPTIONS,
|
||||
@@ -81,16 +97,14 @@ pub fn view_window<'a>(
|
||||
.spacing(12)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(8),
|
||||
text(
|
||||
"Used for the first save of a new drawing. Existing drawings keep their file type and version."
|
||||
)
|
||||
text(crate::tr!("options-default-save-format-help"))
|
||||
.size(11)
|
||||
.width(sizing.width),
|
||||
Space::new().height(22),
|
||||
text("Theme").size(15),
|
||||
text(crate::tr!("options-theme-section")).size(15),
|
||||
Space::new().height(10),
|
||||
row![
|
||||
text("Iced theme:").size(12).width(150),
|
||||
text(crate::tr!("options-theme-label")).size(12).width(150),
|
||||
iced::widget::pick_list(
|
||||
selected_theme,
|
||||
theme_options,
|
||||
@@ -102,9 +116,7 @@ pub fn view_window<'a>(
|
||||
.spacing(12)
|
||||
.align_y(iced::Center),
|
||||
Space::new().height(8),
|
||||
text(
|
||||
"Changing a base colour switches to Custom. Iced generates every component shade from these six colours."
|
||||
)
|
||||
text(crate::tr!("options-theme-help"))
|
||||
.size(11)
|
||||
.width(sizing.width),
|
||||
Space::new().height(12),
|
||||
|
||||
Reference in new issue
Block a user