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:
Hakan Seven committed 2026-08-02 18:53:38 +03:00
1 parent e102887000
commit b90b3e3ecf
26 files changed
+1581 -112

No files matched your search

+10 -1
View File
@@ -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.
+4
View File
@@ -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(),
}
}
}
+4
View File
@@ -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.
+16
View File
@@ -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
View File
@@ -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
View File
@@ -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,
)
},