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
This commit is contained in:
Hakan Seven 2026-08-04 18:53:08 +03:00
commit 9cf0c9f945
14 changed files with 245 additions and 40 deletions

View file

@ -89,6 +89,11 @@ modal-unable-save = Zeichnung kann nicht gespeichert werden
modal-drawing-changed = Zeichnung auf dem Datenträger geändert modal-drawing-changed = Zeichnung auf dem Datenträger geändert
modal-delete-layer = Layer löschen modal-delete-layer = Layer löschen
modal-unsaved-changes = Nicht gespeicherte Änderungen 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-point-style = Punktstil
modal-attribute-editor = Attribut-Editor modal-attribute-editor = Attribut-Editor
modal-save-drawing-as = Zeichnung speichern als modal-save-drawing-as = Zeichnung speichern als

View file

@ -108,6 +108,11 @@ modal-unable-save = Unable to Save Drawing
modal-drawing-changed = Drawing Changed on Disk modal-drawing-changed = Drawing Changed on Disk
modal-delete-layer = Delete Layer modal-delete-layer = Delete Layer
modal-unsaved-changes = Unsaved Changes 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-point-style = Point Style
modal-attribute-editor = Attribute Editor modal-attribute-editor = Attribute Editor
modal-save-drawing-as = Save Drawing As modal-save-drawing-as = Save Drawing As

View file

@ -89,6 +89,11 @@ modal-unable-save = Impossible d'enregistrer le dessin
modal-drawing-changed = Dessin modifié sur le disque modal-drawing-changed = Dessin modifié sur le disque
modal-delete-layer = Supprimer le calque modal-delete-layer = Supprimer le calque
modal-unsaved-changes = Modifications non enregistrées 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-point-style = Style de point
modal-attribute-editor = Éditeur d'attributs modal-attribute-editor = Éditeur d'attributs
modal-save-drawing-as = Enregistrer le dessin sous modal-save-drawing-as = Enregistrer le dessin sous

View file

@ -89,6 +89,11 @@ modal-unable-save = ड्रॉइंग सहेजी नहीं जा
modal-drawing-changed = डिस्क पर ड्रॉइंग बदल गई modal-drawing-changed = डिस्क पर ड्रॉइंग बदल गई
modal-delete-layer = लेयर हटाएँ modal-delete-layer = लेयर हटाएँ
modal-unsaved-changes = न सहेजे गए बदलाव modal-unsaved-changes = न सहेजे गए बदलाव
modal-unsaved-save-prompt = क्या आप बदलाव सहेजना चाहते हैं?
clipboard-no-supported-content = क्लिपबोर्ड में समर्थित टेक्स्ट या ड्रॉइंग ऑब्जेक्ट नहीं है।
clipboard-unavailable = सिस्टम क्लिपबोर्ड उपलब्ध नहीं है।
clipboard-occupied = सिस्टम क्लिपबोर्ड अभी उपयोग में है। फिर प्रयास करें।
clipboard-conversion-failed = क्लिपबोर्ड का टेक्स्ट पढ़ा नहीं जा सका।
modal-point-style = प्वाइंट स्टाइल modal-point-style = प्वाइंट स्टाइल
modal-attribute-editor = विशेषता संपादक modal-attribute-editor = विशेषता संपादक
modal-save-drawing-as = ड्रॉइंग इस रूप में सहेजें modal-save-drawing-as = ड्रॉइंग इस रूप में सहेजें

View file

@ -89,6 +89,11 @@ modal-unable-save = Tekening kan niet worden opgeslagen
modal-drawing-changed = Tekening gewijzigd op schijf modal-drawing-changed = Tekening gewijzigd op schijf
modal-delete-layer = Laag verwijderen modal-delete-layer = Laag verwijderen
modal-unsaved-changes = Niet-opgeslagen wijzigingen 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-point-style = Puntstijl
modal-attribute-editor = Kenmerkeditor modal-attribute-editor = Kenmerkeditor
modal-save-drawing-as = Tekening opslaan als modal-save-drawing-as = Tekening opslaan als

View file

@ -89,6 +89,11 @@ modal-unable-save = Не удалось сохранить чертёж
modal-drawing-changed = Чертёж изменён на диске modal-drawing-changed = Чертёж изменён на диске
modal-delete-layer = Удалить слой modal-delete-layer = Удалить слой
modal-unsaved-changes = Несохранённые изменения modal-unsaved-changes = Несохранённые изменения
modal-unsaved-save-prompt = Сохранить изменения?
clipboard-no-supported-content = Буфер обмена не содержит поддерживаемого текста или объектов чертежа.
clipboard-unavailable = Системный буфер обмена недоступен.
clipboard-occupied = Системный буфер обмена сейчас используется. Повторите попытку.
clipboard-conversion-failed = Не удалось прочитать текст из буфера обмена.
modal-point-style = Стиль точек modal-point-style = Стиль точек
modal-attribute-editor = Редактор атрибутов modal-attribute-editor = Редактор атрибутов
modal-save-drawing-as = Сохранить чертеж как modal-save-drawing-as = Сохранить чертеж как

View file

@ -108,6 +108,11 @@ modal-unable-save = Çizim Kaydedilemiyor
modal-drawing-changed = Çizim Diskte Değişti modal-drawing-changed = Çizim Diskte Değişti
modal-delete-layer = Katmanı Sil modal-delete-layer = Katmanı Sil
modal-unsaved-changes = Kaydedilmemiş Değişiklikler 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-point-style = Nokta Stili
modal-attribute-editor = Öznitelik Düzenleyicisi modal-attribute-editor = Öznitelik Düzenleyicisi
modal-save-drawing-as = Çizimi Farklı Kaydet modal-save-drawing-as = Çizimi Farklı Kaydet

View file

@ -89,6 +89,11 @@ modal-unable-save = 无法保存图形
modal-drawing-changed = 磁盘上的图形已更改 modal-drawing-changed = 磁盘上的图形已更改
modal-delete-layer = 删除图层 modal-delete-layer = 删除图层
modal-unsaved-changes = 未保存的更改 modal-unsaved-changes = 未保存的更改
modal-unsaved-save-prompt = 是否要保存更改?
clipboard-no-supported-content = 剪贴板中没有受支持的文本或图形对象。
clipboard-unavailable = 系统剪贴板不可用。
clipboard-occupied = 系统剪贴板当前正被使用,请重试。
clipboard-conversion-failed = 无法读取剪贴板文本。
modal-point-style = 点样式 modal-point-style = 点样式
modal-attribute-editor = 属性编辑器 modal-attribute-editor = 属性编辑器
modal-save-drawing-as = 图纸另存为 modal-save-drawing-as = 图纸另存为

View file

@ -193,7 +193,7 @@ impl OpenCADStudio {
"PASTECLIP" => { "PASTECLIP" => {
if self.clipboard.is_empty() { 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 { } else {
let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard); let wires = self.tabs[i].scene.wires_for_entities(&self.clipboard);
let base = self.clipboard_base; let base = self.clipboard_base;

View file

@ -1586,10 +1586,11 @@ pub enum Message {
crate::scene::text::web_font::Script, crate::scene::text::web_font::Script,
Result<(), String>, Result<(), String>,
), ),
/// Ctrl+V. Routed by `update`: into the open text/MText editor (via an async /// Ctrl+V. Routed by `update` into an open text editor, the drawing-object
/// system-clipboard read, which is the only paste path that works on the /// clipboard, or the system text clipboard.
/// web) or, with no editor open, the entity paste command.
PasteShortcut, 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 /// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all
/// drawing objects otherwise (#236). /// drawing objects otherwise (#236).
SelectAllShortcut, SelectAllShortcut,
@ -2791,6 +2792,15 @@ pub enum Message {
), ),
} }
#[derive(Debug, Clone)]
pub enum SystemClipboardText {
Text(String),
EmptyOrUnsupported,
Unavailable,
Occupied,
ConversionFailed,
}
impl OpenCADStudio { impl OpenCADStudio {
/// Install the Start-page video list, decoding each thumbnail's JPEG into /// Install the Start-page video list, decoding each thumbnail's JPEG into
/// an image Handle exactly once (a fresh Handle per view frame would /// an image Handle exactly once (a fresh Handle per view frame would

View file

@ -1259,7 +1259,44 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
return Task::none(); 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<Message> {
#[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<Message> { pub(super) fn on_qselect_open(&mut self) -> Task<Message> {

View file

@ -3585,10 +3585,44 @@ impl OpenCADStudio {
// Ctrl+V. The MText editor and (on the web) the TEXT editor read the // Ctrl+V. The MText editor and (on the web) the TEXT editor read the
// system clipboard asynchronously — the only paste path that works // system clipboard asynchronously — the only paste path that works
// in the browser, where the synchronous clipboard the iced // in the browser, where the synchronous clipboard the iced
// text_input expects is empty. With no editor open it falls through // text_input expects is empty. With no editor open, drawing objects
// to the entity paste command. // take priority and system text is used when that clipboard is empty.
Message::PasteShortcut => self.on_paste_shortcut(), 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 => { Message::SelectAllShortcut => {
let i = self.active_tab; let i = self.active_tab;
if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) { if self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) {

View file

@ -1475,14 +1475,13 @@ fn unsaved_changes_dialog_window(
name: &str, name: &str,
sizing: crate::ui::modal::ModalSizing, sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let body_text = t!(
"Do you want to save changes to \"%{name}\"?",
name = name
);
container( container(
column![ 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), iced::widget::Space::new().height(20),
row![ row![
dialog_button(t!("Save"), Message::UnsavedDialogSave, button::primary), dialog_button(t!("Save"), Message::UnsavedDialogSave, button::primary),

View file

@ -10,6 +10,7 @@ use iced::widget::{
text_input, tooltip, Space, text_input, tooltip, Space,
}; };
use iced::{Background, Border, Color, Element, Length, Padding, Theme}; use iced::{Background, Border, Color, Element, Length, Padding, Theme};
use std::ops::Range;
pub const CMD_INPUT_ID: &str = "cmd_input"; pub const CMD_INPUT_ID: &str = "cmd_input";
pub const HISTORY_SCROLL_ID: &str = "command_history_scroll"; pub const HISTORY_SCROLL_ID: &str = "command_history_scroll";
@ -127,6 +128,58 @@ pub enum EntryKind {
Info, 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<EntryKind>,
}
#[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<usize>, 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 { impl CommandLine {
pub fn new() -> Self { pub fn new() -> Self {
let mut cl = Self::default(); let mut cl = Self::default();
@ -317,6 +370,17 @@ impl CommandLine {
.join("\n") .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 /// Drop every history line. The step-prompt mirror is reset too so a
/// later step still repins correctly. /// later step still repins correctly.
pub fn clear_history(&mut self) { pub fn clear_history(&mut self) {
@ -395,6 +459,7 @@ impl CommandLine {
visible.push(pinned); visible.push(pinned);
} }
let start = visible.len().saturating_sub(4); let start = visible.len().saturating_sub(4);
let has_recent_history = start < visible.len();
let history_rows = visible[start..] let history_rows = visible[start..]
.iter() .iter()
.fold(column![].spacing(0), |col, entry| { .fold(column![].spacing(0), |col, entry| {
@ -600,6 +665,10 @@ impl CommandLine {
.size(11) .size(11)
.padding([2, 8]) .padding([2, 8])
.height(Length::Shrink) .height(Length::Shrink)
.highlight_with::<HistoryHighlighter>(
self.history_highlight_settings(),
history_highlight_format,
)
.style(|theme: &Theme, _status| { .style(|theme: &Theme, _status| {
let palette = theme.palette(); let palette = theme.palette();
text_editor::Style { text_editor::Style {
@ -678,7 +747,7 @@ impl CommandLine {
// The transient recent lines and the full archive are two views of the // 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 // same history. Showing both while the archive is open creates a fake
// second history region and visually disconnects the input row (#555). // 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() container(column![]).height(0).into()
} else { } else {
container(history_rows) container(history_rows)
@ -698,34 +767,40 @@ impl CommandLine {
container(column![]).height(0).into() container(column![]).height(0).into()
}; };
container(column![ let history_open = self.history_open;
autocomplete, container(
dropdown, column![
recent_history, autocomplete,
history_divider, dropdown,
container(input_row) recent_history,
.style(|theme: &Theme| { history_divider,
let palette = theme.palette(); container(input_row)
container::Style { .style(|theme: &Theme| {
background: Some(Background::Color(palette.background.weakest.color)), let palette = theme.palette();
..Default::default() container::Style {
} background: Some(Background::Color(
}) palette.background.weakest.color,
.width(Length::Fill) )),
// Match the drawing tab bar / status bar height, and vertically ..Default::default()
// centre the prompt/input/dropdown within it (issue #216). }
.center_y(Length::Fixed(30.0)), })
]) .width(Length::Fill)
.style(|theme: &Theme| { // 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(); let palette = theme.palette();
container::Style { container::Style {
background: Some(Background::Color(palette.background.base.color)), background: Some(Background::Color(palette.background.base.color)),
border: Border { border: Border {
color: palette.background.neutral.color, color: palette.background.neutral.color,
width: 1.0, width: if history_open { 1.0 } else { 0.0 },
radius: 4.0.into(), radius: 4.0.into(),
}, },
..Default::default() ..Default::default()
} }
}) })
.width(Length::Fill.max(720.0)) .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::Font> {
iced::advanced::text::highlighter::Format {
color: Some(history_color(theme, kind)),
font: None,
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ranked_matches, CommandLine}; use super::{ranked_matches, CommandLine};