From 9cf0c9f94540480316c793584f31dce8f93d4a05 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 4 Aug 2026 18:53:08 +0300 Subject: [PATCH] fix(ui): refine command line and clipboard - preserve command history colors after reopening - remove collapsed command bar residue and split the unsaved prompt - read system text when the drawing clipboard is empty Refs #467 Refs #468 Refs #470 --- locales/de-DE/opencadstudio.ftl | 5 ++ locales/en-US/opencadstudio.ftl | 5 ++ locales/fr-FR/opencadstudio.ftl | 5 ++ locales/hi-IN/opencadstudio.ftl | 5 ++ locales/nl-NL/opencadstudio.ftl | 5 ++ locales/ru-RU/opencadstudio.ftl | 5 ++ locales/tr-TR/opencadstudio.ftl | 5 ++ locales/zh-CN/opencadstudio.ftl | 5 ++ src/app/commands/blocks.rs | 2 +- src/app/mod.rs | 16 +++- src/app/update/command.rs | 39 ++++++++- src/app/update/mod.rs | 38 ++++++++- src/app/view/modal.rs | 11 ++- src/ui/command_line.rs | 139 +++++++++++++++++++++++++------- 14 files changed, 245 insertions(+), 40 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 4fa197ed3..b7114711e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = Zeichnung kann nicht gespeichert werden modal-drawing-changed = Zeichnung auf dem Datenträger geändert modal-delete-layer = Layer löschen modal-unsaved-changes = Nicht gespeicherte Änderungen +modal-unsaved-save-prompt = Möchten Sie die Änderungen speichern? +clipboard-no-supported-content = Die Zwischenablage enthält keinen unterstützten Text und keine Zeichnungsobjekte. +clipboard-unavailable = Die Systemzwischenablage ist nicht verfügbar. +clipboard-occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut. +clipboard-conversion-failed = Der Text in der Zwischenablage konnte nicht gelesen werden. modal-point-style = Punktstil modal-attribute-editor = Attribut-Editor modal-save-drawing-as = Zeichnung speichern als diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index c28bb7399..03521a4fe 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -108,6 +108,11 @@ modal-unable-save = Unable to Save Drawing modal-drawing-changed = Drawing Changed on Disk modal-delete-layer = Delete Layer modal-unsaved-changes = Unsaved Changes +modal-unsaved-save-prompt = Do you want to save the changes? +clipboard-no-supported-content = Clipboard does not contain supported text or drawing objects. +clipboard-unavailable = System clipboard is unavailable. +clipboard-occupied = System clipboard is currently in use. Try again. +clipboard-conversion-failed = Clipboard text could not be read. modal-point-style = Point Style modal-attribute-editor = Attribute Editor modal-save-drawing-as = Save Drawing As diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 0939c8cfb..06832dcad 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = Impossible d'enregistrer le dessin modal-drawing-changed = Dessin modifié sur le disque modal-delete-layer = Supprimer le calque modal-unsaved-changes = Modifications non enregistrées +modal-unsaved-save-prompt = Voulez-vous enregistrer les modifications ? +clipboard-no-supported-content = Le presse-papiers ne contient aucun texte ni objet de dessin pris en charge. +clipboard-unavailable = Le presse-papiers système n’est pas disponible. +clipboard-occupied = Le presse-papiers système est en cours d’utilisation. Réessayez. +clipboard-conversion-failed = Impossible de lire le texte du presse-papiers. modal-point-style = Style de point modal-attribute-editor = Éditeur d'attributs modal-save-drawing-as = Enregistrer le dessin sous diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 0ae93f8d2..db28b85d5 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = ड्रॉइंग सहेजी नहीं जा modal-drawing-changed = डिस्क पर ड्रॉइंग बदल गई modal-delete-layer = लेयर हटाएँ modal-unsaved-changes = न सहेजे गए बदलाव +modal-unsaved-save-prompt = क्या आप बदलाव सहेजना चाहते हैं? +clipboard-no-supported-content = क्लिपबोर्ड में समर्थित टेक्स्ट या ड्रॉइंग ऑब्जेक्ट नहीं है। +clipboard-unavailable = सिस्टम क्लिपबोर्ड उपलब्ध नहीं है। +clipboard-occupied = सिस्टम क्लिपबोर्ड अभी उपयोग में है। फिर प्रयास करें। +clipboard-conversion-failed = क्लिपबोर्ड का टेक्स्ट पढ़ा नहीं जा सका। modal-point-style = प्वाइंट स्टाइल modal-attribute-editor = विशेषता संपादक modal-save-drawing-as = ड्रॉइंग इस रूप में सहेजें diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 0494f4ed4..2833fb011 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = Tekening kan niet worden opgeslagen modal-drawing-changed = Tekening gewijzigd op schijf modal-delete-layer = Laag verwijderen modal-unsaved-changes = Niet-opgeslagen wijzigingen +modal-unsaved-save-prompt = Wilt u de wijzigingen opslaan? +clipboard-no-supported-content = Het klembord bevat geen ondersteunde tekst of tekenobjecten. +clipboard-unavailable = Het systeemklembord is niet beschikbaar. +clipboard-occupied = Het systeemklembord is momenteel in gebruik. Probeer het opnieuw. +clipboard-conversion-failed = De tekst op het klembord kon niet worden gelezen. modal-point-style = Puntstijl modal-attribute-editor = Kenmerkeditor modal-save-drawing-as = Tekening opslaan als diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 35db3ceba..7ad2fcf2d 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = Не удалось сохранить чертёж modal-drawing-changed = Чертёж изменён на диске modal-delete-layer = Удалить слой modal-unsaved-changes = Несохранённые изменения +modal-unsaved-save-prompt = Сохранить изменения? +clipboard-no-supported-content = Буфер обмена не содержит поддерживаемого текста или объектов чертежа. +clipboard-unavailable = Системный буфер обмена недоступен. +clipboard-occupied = Системный буфер обмена сейчас используется. Повторите попытку. +clipboard-conversion-failed = Не удалось прочитать текст из буфера обмена. modal-point-style = Стиль точек modal-attribute-editor = Редактор атрибутов modal-save-drawing-as = Сохранить чертеж как diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 92ff06d0a..dd8824b92 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -108,6 +108,11 @@ modal-unable-save = Çizim Kaydedilemiyor modal-drawing-changed = Çizim Diskte Değişti modal-delete-layer = Katmanı Sil modal-unsaved-changes = Kaydedilmemiş Değişiklikler +modal-unsaved-save-prompt = Değişiklikleri kaydetmek istiyor musunuz? +clipboard-no-supported-content = Panoda desteklenen metin veya çizim nesnesi yok. +clipboard-unavailable = Sistem panosuna erişilemiyor. +clipboard-occupied = Sistem panosu şu anda kullanılıyor. Yeniden deneyin. +clipboard-conversion-failed = Panodaki metin okunamadı. modal-point-style = Nokta Stili modal-attribute-editor = Öznitelik Düzenleyicisi modal-save-drawing-as = Çizimi Farklı Kaydet diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 89e98ebde..5ee38a8f1 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -89,6 +89,11 @@ modal-unable-save = 无法保存图形 modal-drawing-changed = 磁盘上的图形已更改 modal-delete-layer = 删除图层 modal-unsaved-changes = 未保存的更改 +modal-unsaved-save-prompt = 是否要保存更改? +clipboard-no-supported-content = 剪贴板中没有受支持的文本或图形对象。 +clipboard-unavailable = 系统剪贴板不可用。 +clipboard-occupied = 系统剪贴板当前正被使用,请重试。 +clipboard-conversion-failed = 无法读取剪贴板文本。 modal-point-style = 点样式 modal-attribute-editor = 属性编辑器 modal-save-drawing-as = 图纸另存为 diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 8351bba94..ccc6a1d64 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -193,7 +193,7 @@ impl OpenCADStudio { "PASTECLIP" => { if self.clipboard.is_empty() { - self.command_line.push_error(crate::t!("Clipboard is empty.").as_ref()); + return Some(self.read_system_clipboard_for_paste()); } else { let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard); let base = self.clipboard_base; diff --git a/src/app/mod.rs b/src/app/mod.rs index 5fc037f3a..2990ea39c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1586,10 +1586,11 @@ pub enum Message { crate::scene::text::web_font::Script, Result<(), String>, ), - /// Ctrl+V. Routed by `update`: into the open text/MText editor (via an async - /// system-clipboard read, which is the only paste path that works on the - /// web) or, with no editor open, the entity paste command. + /// Ctrl+V. Routed by `update` into an open text editor, the drawing-object + /// clipboard, or the system text clipboard. PasteShortcut, + /// Completion of a system text clipboard read requested by PASTECLIP. + SystemClipboardPaste(SystemClipboardText), /// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all /// drawing objects otherwise (#236). SelectAllShortcut, @@ -2791,6 +2792,15 @@ pub enum Message { ), } +#[derive(Debug, Clone)] +pub enum SystemClipboardText { + Text(String), + EmptyOrUnsupported, + Unavailable, + Occupied, + ConversionFailed, +} + impl OpenCADStudio { /// Install the Start-page video list, decoding each thumbnail's JPEG into /// an image Handle exactly once (a fresh Handle per view frame would diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5ce1823e4..0ae9d4478 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1259,7 +1259,44 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { #[cfg(not(target_arch = "wasm32"))] return Task::none(); } - Task::done(Message::Command("PASTECLIP".to_string())) + if self.clipboard.is_empty() { + self.read_system_clipboard_for_paste() + } else { + Task::done(Message::Command("PASTECLIP".to_string())) + } + } + + pub(in crate::app) fn read_system_clipboard_for_paste(&self) -> Task { + #[cfg(target_arch = "wasm32")] + { + Task::perform(crate::sys::read_clipboard_text(), |text| { + Message::SystemClipboardPaste(match text { + Some(text) if !text.is_empty() => { + crate::app::SystemClipboardText::Text(text) + } + _ => crate::app::SystemClipboardText::EmptyOrUnsupported, + }) + }) + } + + #[cfg(not(target_arch = "wasm32"))] + { + iced::clipboard::read_text().map(|result| { + use crate::app::SystemClipboardText as Text; + use iced::clipboard::Error; + + let result = match result { + Ok(text) if !text.is_empty() => Text::Text((*text).clone()), + Ok(_) | Err(Error::ContentNotAvailable) => Text::EmptyOrUnsupported, + Err(Error::ClipboardUnavailable) | Err(Error::Unknown { .. }) => { + Text::Unavailable + } + Err(Error::ClipboardOccupied) => Text::Occupied, + Err(Error::ConversionFailure) => Text::ConversionFailed, + }; + Message::SystemClipboardPaste(result) + }) + } } pub(super) fn on_qselect_open(&mut self) -> Task { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e444ca2d9..557cc79d0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3585,10 +3585,44 @@ impl OpenCADStudio { // Ctrl+V. The MText editor and (on the web) the TEXT editor read the // system clipboard asynchronously — the only paste path that works // in the browser, where the synchronous clipboard the iced - // text_input expects is empty. With no editor open it falls through - // to the entity paste command. + // text_input expects is empty. With no editor open, drawing objects + // take priority and system text is used when that clipboard is empty. Message::PasteShortcut => self.on_paste_shortcut(), + Message::SystemClipboardPaste(result) => { + use super::SystemClipboardText as Text; + + match result { + Text::Text(text) => { + let flat = text.replace(['\r', '\n'], " ").to_uppercase(); + self.command_line.input.push_str(&flat); + self.command_line.autocomplete_cursor = None; + self.focus_cmd_input() + } + Text::EmptyOrUnsupported => { + self.command_line.push_error( + crate::tr!("clipboard-no-supported-content").as_ref(), + ); + Task::none() + } + Text::Unavailable => { + self.command_line + .push_error(crate::tr!("clipboard-unavailable").as_ref()); + Task::none() + } + Text::Occupied => { + self.command_line + .push_error(crate::tr!("clipboard-occupied").as_ref()); + Task::none() + } + Text::ConversionFailed => { + self.command_line + .push_error(crate::tr!("clipboard-conversion-failed").as_ref()); + Task::none() + } + } + } + Message::SelectAllShortcut => { let i = self.active_tab; if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 6a2c5c7ae..7c0d1d616 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -1475,14 +1475,13 @@ fn unsaved_changes_dialog_window( name: &str, sizing: crate::ui::modal::ModalSizing, ) -> Element<'static, Message> { - let body_text = t!( - "Do you want to save changes to \"%{name}\"?", - name = name - ); - container( column![ - text(body_text).size(13), + text(name.to_owned()).size(14), + iced::widget::Space::new().height(4), + text(crate::tr!("modal-unsaved-save-prompt")) + .size(12) + .style(dialog_muted_text_style), iced::widget::Space::new().height(20), row![ dialog_button(t!("Save"), Message::UnsavedDialogSave, button::primary), diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 8e37dc32f..f933d374b 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -10,6 +10,7 @@ use iced::widget::{ text_input, tooltip, Space, }; use iced::{Background, Border, Color, Element, Length, Padding, Theme}; +use std::ops::Range; pub const CMD_INPUT_ID: &str = "cmd_input"; pub const HISTORY_SCROLL_ID: &str = "command_history_scroll"; @@ -127,6 +128,58 @@ pub enum EntryKind { Info, } +/// Per-logical-line entry kinds for the selectable full-history editor. +/// Keeping this metadata beside the editor's plain text preserves drag +/// selection and copy while allowing the renderer to restore each entry's +/// original color. +#[derive(Clone, Debug, PartialEq, Eq)] +struct HistoryHighlightSettings { + line_kinds: Vec, +} + +#[derive(Debug)] +struct HistoryHighlighter { + settings: HistoryHighlightSettings, + current_line: usize, +} + +impl iced::advanced::text::Highlighter for HistoryHighlighter { + type Settings = HistoryHighlightSettings; + type Highlight = EntryKind; + type Iterator<'a> = std::option::IntoIter<(Range, EntryKind)>; + + fn new(settings: &Self::Settings) -> Self { + Self { + settings: settings.clone(), + current_line: 0, + } + } + + fn update(&mut self, settings: &Self::Settings) { + self.settings = settings.clone(); + self.current_line = 0; + } + + fn change_line(&mut self, line: usize) { + self.current_line = line; + } + + fn highlight_line(&mut self, line: &str) -> Self::Iterator<'_> { + let kind = self + .settings + .line_kinds + .get(self.current_line) + .cloned() + .unwrap_or(EntryKind::Output); + self.current_line += 1; + Some((0..line.len(), kind)).into_iter() + } + + fn current_line(&self) -> usize { + self.current_line + } +} + impl CommandLine { pub fn new() -> Self { let mut cl = Self::default(); @@ -317,6 +370,17 @@ impl CommandLine { .join("\n") } + fn history_highlight_settings(&self) -> HistoryHighlightSettings { + let line_kinds = self + .history + .iter() + .flat_map(|entry| { + std::iter::repeat(entry.kind.clone()).take(entry.text.split('\n').count()) + }) + .collect(); + HistoryHighlightSettings { line_kinds } + } + /// Drop every history line. The step-prompt mirror is reset too so a /// later step still repins correctly. pub fn clear_history(&mut self) { @@ -395,6 +459,7 @@ impl CommandLine { visible.push(pinned); } let start = visible.len().saturating_sub(4); + let has_recent_history = start < visible.len(); let history_rows = visible[start..] .iter() .fold(column![].spacing(0), |col, entry| { @@ -600,6 +665,10 @@ impl CommandLine { .size(11) .padding([2, 8]) .height(Length::Shrink) + .highlight_with::( + self.history_highlight_settings(), + history_highlight_format, + ) .style(|theme: &Theme, _status| { let palette = theme.palette(); text_editor::Style { @@ -678,7 +747,7 @@ impl CommandLine { // The transient recent lines and the full archive are two views of the // same history. Showing both while the archive is open creates a fake // second history region and visually disconnects the input row (#555). - let recent_history: Element<'a, Message> = if self.history_open { + let recent_history: Element<'a, Message> = if self.history_open || !has_recent_history { container(column![]).height(0).into() } else { container(history_rows) @@ -698,34 +767,40 @@ impl CommandLine { container(column![]).height(0).into() }; - container(column![ - autocomplete, - dropdown, - recent_history, - history_divider, - container(input_row) - .style(|theme: &Theme| { - let palette = theme.palette(); - container::Style { - background: Some(Background::Color(palette.background.weakest.color)), - ..Default::default() - } - }) - .width(Length::Fill) - // Match the drawing tab bar / status bar height, and vertically - // centre the prompt/input/dropdown within it (issue #216). - .center_y(Length::Fixed(30.0)), - ]) - .style(|theme: &Theme| { + let history_open = self.history_open; + container( + column![ + autocomplete, + dropdown, + recent_history, + history_divider, + container(input_row) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color( + palette.background.weakest.color, + )), + ..Default::default() + } + }) + .width(Length::Fill) + // Match the drawing tab bar / status bar height, and vertically + // centre the prompt/input/dropdown within it (issue #216). + .center_y(Length::Fixed(30.0)), + ] + .width(Length::Fill), + ) + .style(move |theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 4.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: palette.background.neutral.color, + width: if history_open { 1.0 } else { 0.0 }, + radius: 4.0.into(), + }, + ..Default::default() } }) .width(Length::Fill.max(720.0)) @@ -819,6 +894,16 @@ fn history_color(theme: &Theme, kind: &EntryKind) -> Color { } } +fn history_highlight_format( + kind: &EntryKind, + theme: &Theme, +) -> iced::advanced::text::highlighter::Format { + iced::advanced::text::highlighter::Format { + color: Some(history_color(theme, kind)), + font: None, + } +} + #[cfg(test)] mod tests { use super::{ranked_matches, CommandLine};