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");