From 3d1f355a5cf2458b62907006b6850778899bcca5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 6 Apr 2026 15:01:25 +0300 Subject: [PATCH] feat: TextStyle font browser (STYLE DIALOG command) - STYLE DIALOG / TEXTSTYLE DIALOG opens the Text Style Font Browser - Three-panel dialog: style list | built-in CXF font list | properties - Properties panel: font file text field, width factor, oblique angle - Apply button commits edits; Set Current sets the active text style - Built-in font picker (20 CXF fonts from assets/fonts/) auto-applies font to the selected style on click - Preview pane shows "AaBbCc 0123" sample text Co-Authored-By: Claude Sonnet 4.6 --- src/app/commands.rs | 3 + src/app/mod.rs | 29 ++++++ src/app/update.rs | 126 +++++++++++++++++++++++++ src/app/view.rs | 223 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 380 insertions(+), 1 deletion(-) diff --git a/src/app/commands.rs b/src/app/commands.rs index b9ba1782e..9688032f6 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -2339,6 +2339,9 @@ impl H7CAD { let parts: Vec<&str> = rest.splitn(3, ' ').collect(); let sub = parts.get(0).map(|s| s.to_uppercase()).unwrap_or_default(); match sub.as_str() { + "DIALOG" | "UI" => { + return Task::done(Message::TextStyleDialogOpen); + } "" | "LIST" | "?" => { let styles: Vec = self.tabs[i].scene.document .text_styles.iter() diff --git a/src/app/mod.rs b/src/app/mod.rs index 9f1398c80..5396f1d8d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -98,6 +98,16 @@ pub(super) struct H7CAD { tablestyle_open: bool, tablestyle_selected: String, + // ── TextStyle Font Browser ──────────────────────────────────────────── + textstyle_open: bool, + textstyle_selected: String, + /// Edit buffer for font file name. + textstyle_font: String, + /// Edit buffer for width factor. + textstyle_width: String, + /// Edit buffer for oblique angle (degrees). + textstyle_oblique: String, + // ── DimStyle Dialog ─────────────────────────────────────────────────── dimstyle_open: bool, /// Name of the style currently shown in the dialog. @@ -343,6 +353,19 @@ pub enum Message { PlotStyleLoaded(Option), /// Clear the active plot style table. PlotStyleClear, + // ── TextStyle Font Browser ──────────────────────────────────────────── + TextStyleDialogOpen, + TextStyleDialogClose, + TextStyleDialogSelect(String), + TextStyleDialogSetCurrent, + TextStyleDialogNew, + TextStyleDialogDelete, + /// Edit a string field (FontFile / Width / Oblique). + TextStyleEdit { field: &'static str, value: String }, + /// Commit edits to the selected text style. + TextStyleApply, + /// Select a font from the built-in font list. + TextStyleFontPick(String), // ── TableStyle Dialog ───────────────────────────────────────────────── TableStyleDialogOpen, TableStyleDialogClose, @@ -417,6 +440,12 @@ impl H7CAD { page_setup_scale: "Fit".to_string(), // Plot style active_plot_style: None, + // TextStyle font browser + textstyle_open: false, + textstyle_selected: "Standard".to_string(), + textstyle_font: String::new(), + textstyle_width: "1.0".to_string(), + textstyle_oblique: "0.0".to_string(), // TableStyle dialog tablestyle_open: false, tablestyle_selected: "Standard".to_string(), diff --git a/src/app/update.rs b/src/app/update.rs index 1761b0a2e..3013418c1 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2126,6 +2126,122 @@ impl H7CAD { Task::none() } + // ── TextStyle Font Browser ──────────────────────────────────────── + Message::TextStyleDialogOpen => { + let i = self.active_tab; + let cur = self.tabs[i].scene.document.header.current_text_style_name.clone(); + let exists = self.tabs[i].scene.document.text_styles.get(&cur).is_some(); + self.textstyle_selected = if exists { + cur + } else { + self.tabs[i].scene.document.text_styles + .iter().next().map(|s| s.name.clone()) + .unwrap_or_else(|| "Standard".to_string()) + }; + self.load_textstyle_bufs(i); + self.textstyle_open = true; + Task::none() + } + Message::TextStyleDialogClose => { + self.textstyle_open = false; + Task::none() + } + Message::TextStyleDialogSelect(name) => { + let i = self.active_tab; + self.textstyle_selected = name; + self.load_textstyle_bufs(i); + Task::none() + } + Message::TextStyleDialogSetCurrent => { + let i = self.active_tab; + let name = self.textstyle_selected.clone(); + if self.tabs[i].scene.document.text_styles.get(&name).is_some() { + self.push_undo_snapshot(i, "STYLE SET"); + self.tabs[i].scene.document.header.current_text_style_name = name.clone(); + self.tabs[i].dirty = true; + self.command_line.push_output(&format!("Current text style: {}", name)); + } + Task::none() + } + Message::TextStyleDialogNew => { + let i = self.active_tab; + let doc = &self.tabs[i].scene.document; + let mut n = 1u32; + let new_name = loop { + let candidate = format!("Style{}", n); + if !doc.text_styles.contains(&candidate) { break candidate; } + n += 1; + }; + self.push_undo_snapshot(i, "STYLE NEW"); + let style = acadrust::tables::TextStyle::new(&new_name); + let _ = self.tabs[i].scene.document.text_styles.add(style); + self.textstyle_selected = new_name.clone(); + self.textstyle_font = String::new(); + self.textstyle_width = "1.0".to_string(); + self.textstyle_oblique = "0.0".to_string(); + self.tabs[i].dirty = true; + Task::none() + } + Message::TextStyleDialogDelete => { + let i = self.active_tab; + let name = self.textstyle_selected.clone(); + if name.eq_ignore_ascii_case("Standard") { + self.command_line.push_error("Cannot delete the Standard text style."); + return Task::none(); + } + self.push_undo_snapshot(i, "STYLE DEL"); + self.tabs[i].scene.document.text_styles.remove(&name); + self.textstyle_selected = self.tabs[i].scene.document.text_styles + .iter().next().map(|s| s.name.clone()) + .unwrap_or_else(|| "Standard".to_string()); + self.load_textstyle_bufs(i); + self.tabs[i].dirty = true; + Task::none() + } + Message::TextStyleEdit { field, value } => { + match field { + "font" => self.textstyle_font = value, + "width" => self.textstyle_width = value, + "oblique" => self.textstyle_oblique = value, + _ => {} + } + Task::none() + } + Message::TextStyleApply => { + let i = self.active_tab; + let name = self.textstyle_selected.clone(); + if self.tabs[i].scene.document.text_styles.get(&name).is_some() { + self.push_undo_snapshot(i, "STYLE EDIT"); + let font = self.textstyle_font.clone(); + let width_str = self.textstyle_width.clone(); + let oblique_str = self.textstyle_oblique.clone(); + if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { + s.font_file = font; + if let Ok(w) = width_str.trim().parse::() { + s.width_factor = w; + } + if let Ok(a) = oblique_str.trim().parse::() { + s.oblique_angle = a.to_radians(); + } + } + self.tabs[i].dirty = true; + } + Task::none() + } + Message::TextStyleFontPick(font_file) => { + let i = self.active_tab; + self.textstyle_font = font_file.clone(); + let name = self.textstyle_selected.clone(); + if self.tabs[i].scene.document.text_styles.get(&name).is_some() { + self.push_undo_snapshot(i, "STYLE FONT"); + if let Some(s) = self.tabs[i].scene.document.text_styles.get_mut(&name) { + s.font_file = font_file; + } + self.tabs[i].dirty = true; + } + Task::none() + } + // ── TableStyle Dialog ───────────────────────────────────────────── Message::TableStyleDialogOpen => { use acadrust::objects::ObjectType; @@ -2465,6 +2581,16 @@ impl H7CAD { _ => {} } } + + /// Populate edit buffers from the currently selected text style. + fn load_textstyle_bufs(&mut self, tab: usize) { + let doc = &self.tabs[tab].scene.document; + if let Some(s) = doc.text_styles.get(&self.textstyle_selected) { + self.textstyle_font = s.font_file.clone(); + self.textstyle_width = format!("{:.4}", s.width_factor); + self.textstyle_oblique = format!("{:.2}", s.oblique_angle.to_degrees()); + } + } } /// Parse a scale string like "1:50" or "2:1" into (numerator, denominator). diff --git a/src/app/view.rs b/src/app/view.rs index cebe33c7e..8e760cd09 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -227,6 +227,15 @@ impl H7CAD { iced::widget::Space::new().width(0).height(0).into() }; + let textstyle_layer: Element<'_, Message> = if self.textstyle_open { + let tab = &self.tabs[self.active_tab]; + let styles: Vec = tab.scene.document.text_styles + .iter().map(|s| s.name.clone()).collect(); + textstyle_overlay(styles, &self.textstyle_selected, &self.textstyle_font, &self.textstyle_width, &self.textstyle_oblique) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let tablestyle_layer: Element<'_, Message> = if self.tablestyle_open { use acadrust::objects::ObjectType; let tab = &self.tabs[self.active_tab]; @@ -271,7 +280,7 @@ impl H7CAD { iced::widget::Space::new().width(0).height(0).into() }; - stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer].into() + stack![main_ui, self.app_menu.view(), snap_layer, dropdown_layer, layout_ctx_layer, page_setup_layer, textstyle_layer, tablestyle_layer, mlstyle_layer, dimstyle_layer].into() } pub fn subscription(&self) -> Subscription { @@ -1052,6 +1061,218 @@ fn dimstyle_overlay<'a>( stack![catcher, positioned].into() } +// ── TextStyle Font Browser overlay ───────────────────────────────────────── + +/// Built-in CXF font file names (relative to assets/fonts/). +const BUILTIN_FONTS: &[&str] = &[ + "CourierCad.cxf", "Cursive.cxf", "GothGBT.cxf", "GothGRT.cxf", "GothITT.cxf", + "GreekC.cxf", "GreekS.cxf", "ItalicC.cxf", "ItalicT.cxf", + "RomanC.cxf", "RomanD.cxf", "RomanS.cxf", "RomanT.cxf", + "SansND.cxf", "SansNS.cxf", "ScriptC.cxf", "ScriptS.cxf", + "Standard.cxf", "Unicode.cxf", "SymbolCad.cxf", +]; + +fn textstyle_overlay<'a>( + styles: Vec, + selected: &'a str, + font_buf: &'a str, + width_buf: &'a str, + oblique_buf: &'a str, +) -> Element<'a, Message> { + use iced::Length::Shrink; + + const PANEL_BG: Color = Color { r: 0.15, g: 0.15, b: 0.15, a: 1.0 }; + const BORDER: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0 }; + const TEXT_COL: Color = Color { r: 0.88, g: 0.88, b: 0.88, a: 1.0 }; + const DIM_COL: Color = Color { r: 0.55, g: 0.55, b: 0.55, a: 1.0 }; + const ACCENT: Color = Color { r: 0.25, g: 0.50, b: 0.85, a: 1.0 }; + const ACTIVE_BG: Color = Color { r: 0.20, g: 0.40, b: 0.70, a: 1.0 }; + + let field_style = |_: &Theme, _: iced::widget::text_input::Status| iced::widget::text_input::Style { + background: Background::Color(Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 }), + border: Border { color: BORDER, width: 1.0, radius: 3.0.into() }, + icon: TEXT_COL, placeholder: DIM_COL, value: TEXT_COL, selection: ACCENT, + }; + + let btn_style = |accent: bool| move |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match (accent, status) { + (true, button::Status::Hovered | button::Status::Pressed) => + Color { r: 0.20, g: 0.42, b: 0.72, a: 1.0 }, + (false, button::Status::Hovered | button::Status::Pressed) => + Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0 }, + (true, _) => ACCENT, + _ => Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0 }, + })), + text_color: TEXT_COL, + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }; + + // Left: style list. + let style_items: Vec> = styles.iter().map(|name| { + let is_sel = name.as_str() == selected; + button(text(name.clone()).size(11).color(TEXT_COL)) + .on_press(Message::TextStyleDialogSelect(name.clone())) + .style(move |_: &Theme, st| button::Style { + background: Some(Background::Color(match (is_sel, st) { + (true, _) => ACTIVE_BG, + (false, button::Status::Hovered | button::Status::Pressed) => + Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 }, + _ => Color::TRANSPARENT, + })), + text_color: TEXT_COL, + ..Default::default() + }) + .padding([3, 8]) + .width(Fill) + .into() + }).collect(); + + let style_panel = container( + column(style_items).spacing(2) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 })), + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding(4) + .width(150) + .height(280); + + // Middle: font file list (built-in CXF fonts). + let font_items: Vec> = BUILTIN_FONTS.iter().map(|&f| { + let is_sel = font_buf == f; + button(text(f).size(10).color(TEXT_COL)) + .on_press(Message::TextStyleFontPick(f.to_string())) + .style(move |_: &Theme, st| button::Style { + background: Some(Background::Color(match (is_sel, st) { + (true, _) => ACTIVE_BG, + (false, button::Status::Hovered | button::Status::Pressed) => + Color { r: 0.26, g: 0.26, b: 0.26, a: 1.0 }, + _ => Color::TRANSPARENT, + })), + text_color: TEXT_COL, + ..Default::default() + }) + .padding([3, 8]) + .width(Fill) + .into() + }).collect(); + + let font_panel = column![ + text("Font File:").size(11).color(DIM_COL), + container( + iced::widget::scrollable(column(font_items).spacing(1)) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { r: 0.12, g: 0.12, b: 0.12, a: 1.0 })), + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding(4) + .width(180) + .height(160), + text_input("font file…", font_buf) + .on_input(|v| Message::TextStyleEdit { field: "font", value: v }) + .style(field_style) + .size(11) + .width(180), + ] + .spacing(4); + + // Right: properties + preview. + let props = column![ + text("Properties").size(12).color(ACCENT), + row![ + text("Width Factor:").size(11).color(DIM_COL).width(110), + text_input("1.0", width_buf) + .on_input(|v| Message::TextStyleEdit { field: "width", value: v }) + .style(field_style) + .size(11) + .width(80), + ].spacing(6).align_y(iced::Center), + row![ + text("Oblique (°):").size(11).color(DIM_COL).width(110), + text_input("0.0", oblique_buf) + .on_input(|v| Message::TextStyleEdit { field: "oblique", value: v }) + .style(field_style) + .size(11) + .width(80), + ].spacing(6).align_y(iced::Center), + Space::new().height(8), + text("Preview:").size(11).color(DIM_COL), + container( + text("AaBbCc 0123").size(20).color(TEXT_COL) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { r: 0.10, g: 0.10, b: 0.10, a: 1.0 })), + border: Border { color: BORDER, width: 1.0, radius: 4.0.into() }, + ..Default::default() + }) + .padding(10) + .width(Fill), + Space::new().height(Fill), + row![ + button(text("Apply").size(11)) + .on_press(Message::TextStyleApply) + .style(btn_style(true)) + .padding([5, 10]), + button(text("Set Current").size(11)) + .on_press(Message::TextStyleDialogSetCurrent) + .style(btn_style(false)) + .padding([5, 10]), + ].spacing(6), + ] + .spacing(8) + .width(220); + + let panel = container( + column![ + row![ + text("Text Style Font Browser").size(13).color(TEXT_COL), + Space::new().width(Fill), + row![ + button(text("New").size(11)) + .on_press(Message::TextStyleDialogNew) + .style(btn_style(false)) + .padding([3, 8]), + button(text("Delete").size(11)) + .on_press(Message::TextStyleDialogDelete) + .style(btn_style(false)) + .padding([3, 8]), + button(text("✕").size(12).color(DIM_COL)) + .on_press(Message::TextStyleDialogClose) + .style(|_: &Theme, _| button::Style { + background: Some(Background::Color(Color::TRANSPARENT)), + text_color: DIM_COL, + ..Default::default() + }) + .padding([2, 6]), + ].spacing(4), + ].align_y(iced::Center), + row![style_panel, font_panel, props].spacing(12).align_y(iced::Top), + ].spacing(10).padding(16) + ) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { color: BORDER, width: 1.0, radius: 6.0.into() }, + ..Default::default() + }) + .width(Shrink); + + let catcher = mouse_area( + container(iced::widget::Space::new().width(Fill).height(Fill)) + ).on_press(Message::TextStyleDialogClose); + + let positioned = container(panel) + .width(Fill).height(Fill) + .align_x(iced::Alignment::Center) + .align_y(iced::Alignment::Center); + + stack![catcher, positioned].into() +} + // ── TableStyle Dialog overlay ─────────────────────────────────────────────── fn tablestyle_overlay<'a>(