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:
parent
d1f9055901
commit
9cf0c9f945
14 changed files with 245 additions and 40 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1259,7 +1259,44 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
|||
#[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<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> {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<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 {
|
||||
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::<HistoryHighlighter>(
|
||||
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::Font> {
|
||||
iced::advanced::text::highlighter::Format {
|
||||
color: Some(history_color(theme, kind)),
|
||||
font: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{ranked_matches, CommandLine};
|
||||
|
|
|
|||
Loading…
Reference in a new issue