diff --git a/assets/icons/ui/copy.svg b/assets/icons/ui/copy.svg new file mode 100644 index 000000000..9166513a6 --- /dev/null +++ b/assets/icons/ui/copy.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index 299761fab..bb6af37b3 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -194,6 +194,10 @@ pub(super) struct OpenCADStudio { ribbon: Ribbon, app_menu: AppMenu, command_line: CommandLine, + /// Read-only editor buffer backing the command-line history dropdown, so + /// the log can be drag-selected across lines and copied (issue #232). + /// Rebuilt from the history each time the dropdown is opened. + history_content: iced::widget::text_editor::Content, status_bar: StatusBar, cursor_pos: Point, vp_size: (f32, f32), @@ -1176,6 +1180,16 @@ pub enum Message { CommandHistoryNext, /// Toggle the dropdown listing the full command-line history. CommandHistoryToggle, + /// Copy the full command-line history (every line) to the system + /// clipboard as plain text — issue #232, so output can be pasted for + /// debugging instead of screenshotted. + CommandHistoryCopy, + /// Clear every line from the command-line history. + CommandHistoryClear, + /// Text-editor action from the read-only history dropdown. Only + /// non-editing actions (cursor moves, selection, scroll) are applied so + /// the log stays read-only while remaining drag-selectable and copyable. + CommandHistoryEdit(iced::widget::text_editor::Action), /// User clicked an autocomplete suggestion — fill the input with /// the chosen command name and dispatch it. CommandSuggestionPick(String), @@ -1906,6 +1920,7 @@ impl OpenCADStudio { ribbon: Ribbon::new(), app_menu, command_line: CommandLine::new(), + history_content: iced::widget::text_editor::Content::new(), status_bar: StatusBar::new(), cursor_pos: Point::ORIGIN, vp_size: (1280.0, 720.0), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 898ef455f..9b2a58c19 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -774,6 +774,41 @@ impl OpenCADStudio { Message::CommandHistoryToggle => { self.command_line.toggle_history(); + // On open, snapshot the current log into the read-only editor + // buffer so it can be drag-selected across lines and copied. + if self.command_line.history_open { + use iced::widget::text_editor::{Action, Motion}; + self.history_content = iced::widget::text_editor::Content::with_text( + &self.command_line.history_plain_text(), + ); + // Scroll to the newest line (bottom), the most useful when + // opening the log to grab a recent error. + self.history_content.perform(Action::Move(Motion::DocumentEnd)); + } + Task::none() + } + + Message::CommandHistoryCopy => { + let text = self.command_line.history_plain_text(); + if text.is_empty() { + Task::none() + } else { + iced::clipboard::write(text) + } + } + + Message::CommandHistoryClear => { + self.command_line.clear_history(); + self.history_content = iced::widget::text_editor::Content::new(); + Task::none() + } + + Message::CommandHistoryEdit(action) => { + // Read-only: drop edits, keep selection / cursor / scroll so + // the user can still highlight and Ctrl+C the log. + if !action.is_edit() { + self.history_content.perform(action); + } Task::none() } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 02556e653..e230814c7 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1105,7 +1105,11 @@ impl OpenCADStudio { || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); let command_line_overlay = - iced::widget::container(self.command_line.view(allow_autocomplete, dyn_capturing)) + iced::widget::container(self.command_line.view( + allow_autocomplete, + dyn_capturing, + &self.history_content, + )) .width(Fill) .height(Fill) .align_x(iced::alignment::Horizontal::Center) @@ -1583,9 +1587,21 @@ impl OpenCADStudio { "z" if !shift => Some(Message::Undo), "z" if shift => Some(Message::Redo), "y" => Some(Message::Redo), - "c" => Some(Message::Command("COPYCLIP".to_string())), - "x" => Some(Message::Command("CUTCLIP".to_string())), - "v" => Some(Message::PasteShortcut), + // Clipboard accelerators defer to a focused + // text widget: when the command-line history + // editor (or any text field) captures Ctrl+C/ + // X/V it copies/cuts/pastes its own text and + // marks the event Captured, so we must NOT also + // fire the drawing's COPYCLIP/CUTCLIP/paste. + // Only when nothing captured (the drawing has + // focus, status Ignored) do these run. (#232) + "c" if status == Status::Ignored => { + Some(Message::Command("COPYCLIP".to_string())) + } + "x" if status == Status::Ignored => { + Some(Message::Command("CUTCLIP".to_string())) + } + "v" if status == Status::Ignored => Some(Message::PasteShortcut), _ => None, }, // Printable glyphs are already handled by the diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index 77df0bb9d..c4f8c1d40 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -3,7 +3,9 @@ use iced::time::Instant; use crate::app::Message; -use iced::widget::{button, column, container, opaque, row, scrollable, text, text_input}; +use iced::widget::{ + button, column, container, opaque, row, text, text_editor, text_input, Space, +}; use iced::{Background, Border, Color, Element, Length, Theme}; pub const CMD_INPUT_ID: &str = "cmd_input"; @@ -224,6 +226,25 @@ impl CommandLine { self.history_open = false; } + /// The whole history flattened to plain text, one entry per line, for the + /// clipboard-copy button (issue #232). Entries carry no per-line prefix + /// beyond what `push_*` already baked into `text` (e.g. "Command: "), so + /// the pasted block reads the same as the on-screen log. + pub fn history_plain_text(&self) -> String { + self.history + .iter() + .map(|e| e.text.as_str()) + .collect::>() + .join("\n") + } + + /// Drop every history line. The step-prompt mirror is reset too so a + /// later step still repins correctly. + pub fn clear_history(&mut self) { + self.history.clear(); + self.step_prompt = None; + } + /// Move the autocomplete highlight up one entry. Wraps to the last /// match. Returns `true` when there was a list to navigate. pub fn autocomplete_prev(&mut self) -> bool { @@ -268,7 +289,12 @@ impl CommandLine { ranked_matches(self.input.trim()) } - pub fn view(&self, show_autocomplete: bool, dyn_capturing: bool) -> Element<'_, Message> { + pub fn view<'a>( + &'a self, + show_autocomplete: bool, + dyn_capturing: bool, + history_content: &'a text_editor::Content, + ) -> Element<'a, Message> { // Only the most recent entries pushed within the last few // seconds show on the overlay. The dropdown button keeps the // full backlog reachable when the user actually wants it. @@ -420,30 +446,63 @@ impl CommandLine { .spacing(4) .align_y(iced::Center); - // Full backlog dropdown — appears ABOVE the input pill when - // open. The history `Vec` already contains every line pushed - // since startup; render them all (newest at the bottom) in a - // scrollable. `opaque` wraps the panel so mouse-wheel events - // inside the dropdown don't bubble through to the viewport - // shader behind it (otherwise scrolling the history zoomed - // the drawing). `anchor_bottom` keeps the newest line in view - // when the dropdown first opens. - let dropdown: Element<'_, Message> = if self.history_open { - let mut col = column![].spacing(0).width(Length::Fill); - for entry in &self.history { - let color = match entry.kind { - EntryKind::Command => CMD_COLOR, - EntryKind::Output => OUT_COLOR, - EntryKind::Error => ERR_COLOR, - EntryKind::Info => INFO_COLOR, - }; - col = col.push( - container(text(&entry.text).size(11).color(color)).padding([1, 8]), - ); - } - let panel = container( - scrollable(col).anchor_bottom().width(Length::Fill), + // Full backlog dropdown — appears ABOVE the input pill when open. The + // whole log is rendered in ONE read-only `text_editor` (backed by + // `history_content`) rather than per-line labels, so a single mouse + // drag selects across lines and Ctrl+C copies the lot — issue #232. + // Edits are dropped in the update handler, keeping it read-only. The + // editor scrolls internally past `max_height`; `opaque` stops its + // mouse-wheel events bubbling to the viewport shader behind it (else + // scrolling the history zoomed the drawing). + let dropdown: Element<'a, Message> = if self.history_open { + let log = text_editor(history_content) + .on_action(Message::CommandHistoryEdit) + .size(11) + .padding([2, 8]) + .max_height(180.0) + .style(|_: &Theme, _status| text_editor::Style { + background: Background::Color(PANEL_BG), + border: Border::default(), + placeholder: OUT_COLOR, + value: CMD_COLOR, + selection: Color { + r: 0.20, + g: 0.44, + b: 0.72, + a: 0.5, + }, + }); + // Header strip: a Copy-all and a Clear button pinned above the log. + let copy_btn = button( + row![ + crate::ui::icons::tinted(crate::ui::icons::COPY, 11.0, PROMPT_COLOR), + text("Copy").size(11).color(CMD_COLOR), + ] + .spacing(4) + .align_y(iced::Center), ) + .on_press(Message::CommandHistoryCopy) + .style(header_btn_style) + .padding([2, 6]); + let clear_btn = button( + row![ + crate::ui::icons::tinted(crate::ui::icons::TRASH, 11.0, ERR_COLOR), + text("Clear").size(11).color(CMD_COLOR), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::CommandHistoryClear) + .style(header_btn_style) + .padding([2, 6]); + let header = container( + row![Space::new().width(Length::Fill), copy_btn, clear_btn] + .spacing(6) + .align_y(iced::Center), + ) + .width(Length::Fill) + .padding([2, 6]); + let panel = container(column![header, log]) .style(|_: &Theme| container::Style { background: Some(Background::Color(PANEL_BG)), border: Border { @@ -454,7 +513,6 @@ impl CommandLine { ..Default::default() }) .width(Length::Fill) - .max_height(200.0) .padding([4, 0]); opaque(panel).into() } else { @@ -527,6 +585,31 @@ pub fn ranked_matches(needle: &str) -> Vec<&'static str> { matches } +/// Flat button style for the history dropdown's Copy / Clear strip: a subtle +/// filled pill that brightens on hover. +fn header_btn_style(_: &Theme, status: button::Status) -> button::Style { + let bg = if matches!(status, button::Status::Hovered) { + Color { + r: 0.24, + g: 0.24, + b: 0.24, + a: 1.0, + } + } else { + INPUT_ROW_BG + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color::WHITE, + border: Border { + color: BORDER_COLOR, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + } +} + const PANEL_BG: Color = Color { r: 0.15, g: 0.15, diff --git a/src/ui/icons.rs b/src/ui/icons.rs index 02d58503a..cea50277d 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -49,6 +49,7 @@ pub const CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg"); pub const CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg"); pub const PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg"); pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg"); +pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); pub const MOVE: &[u8] = include_bytes!("../../assets/icons/ui/move.svg"); pub const SPLIT_V: &[u8] = include_bytes!("../../assets/icons/ui/split_v.svg");