git.lucas.co / cce-terminal
terminal emulator
git clone https://git.lucas.co/cce-terminal.git

src/plate_menu.rs (11.6K)

  1 //! The root plate's corner control: the DE's circular menu trigger (the same
  2 //! affordance cce-designer's panes and cce-files' preview pane carry, on
  3 //! `cce_ui::widget::plate_dock`) riding the top-right of the terminal
  4 //! window, and the menu it opens. A right-click on the grid opens the same
  5 //! menu at the pointer, as a context menu.
  6 //!
  7 //! The terminal has one plate — the window itself — so the toolkit's dock
  8 //! vocabulary (collapse, detach) does not apply; the rows are the actions a
  9 //! terminal without a menubar has nowhere else to put: the clipboard pair,
 10 //! text zoom, scrollback/state resets, the tabs, and a new window. The tabs
 11 //! borrow the designer's dock-tab language: every tab listed as a radio row
 12 //! (the shown one marked, so the list reads as state), then New Tab and
 13 //! Close Tab. Geometry is the toolkit's (`corner_center` on the window
 14 //! rect), the menu is the shared `context_menu`, and the rows are dispatched
 15 //! here — the same `plate_menu_actions` + `handle_plate_menu_click` contract
 16 //! as the designer.
 17 
 18 use alacritty_terminal::grid::Scroll;
 19 use alacritty_terminal::vte::ansi::Handler;
 20 use cce_ui::widget::plate_dock::{self, CORNER_R};
 21 use cce_ui::widget::{context_menu, ElementState, MouseButton, WidgetId};
 22 
 23 use crate::{TabId, TerminalApp};
 24 
 25 /// What the corner menu can do to the terminal.
 26 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 27 pub enum PlateMenuAction {
 28     /// Selection → the regular clipboard (the Ctrl+Shift+C path).
 29     Copy,
 30     /// Regular clipboard → the pty (the Ctrl+Shift+V path).
 31     Paste,
 32     /// Text zoom, one point per step over the configured size.
 33     LargerText,
 34     SmallerText,
 35     /// Back to the configured size.
 36     ResetTextSize,
 37     /// Drop the scrollback history (the viewport stays).
 38     ClearScrollback,
 39     /// Full VT reset: modes, colors, tab stops, the alternate screen.
 40     ResetTerminal,
 41     /// Bring the named tab to the front. By id, not index: a tab whose
 42     /// shell exits while the menu is open shifts the indices under it.
 43     ShowTab(TabId),
 44     /// A fresh shell in the active tab's directory, shown.
 45     NewTab,
 46     /// Close the active tab (its shell is killed).
 47     CloseTab,
 48     /// Another cce-terminal, detached.
 49     NewWindow,
 50     /// A "-" row: engraved, inert — keeps the actions aligned with the option
 51     /// rows so a click on the line dispatches nothing.
 52     Separator,
 53 }
 54 
 55 /// The menu has no widget target — the terminal has no widget tree and
 56 /// dispatches its own rows — so the shared menu's built-in action routing
 57 /// (`mouse_input` with a `UiContext`) is never used; the id is a placeholder.
 58 const NO_TARGET: WidgetId = WidgetId(0);
 59 
 60 impl TerminalApp {
 61     /// Centre of the corner control, or `None` while the window is too small
 62     /// to carry one.
 63     pub(crate) fn plate_corner_center(&self) -> Option<(f32, f32)> {
 64         plate_dock::corner_center((0.0, 0.0, self.win_w, self.win_h), false)
 65     }
 66 
 67     pub(crate) fn plate_corner_hit(&self, px: f32, py: f32) -> bool {
 68         self.plate_corner_center().is_some_and(|c| plate_dock::corner_hit(c, px, py))
 69     }
 70 
 71     pub(crate) fn plate_menu_open(&self) -> bool {
 72         context_menu::is_visible() && !self.plate_menu_actions.is_empty()
 73     }
 74 
 75     /// Open the corner menu under its control. The menu hangs off the
 76     /// control's RIGHT edge, leftwards — anchored on the left as the
 77     /// designer's panes do, it would run off the window.
 78     pub(crate) fn open_plate_menu(&mut self) {
 79         let Some((cx, cy)) = self.plate_corner_center() else { return };
 80         let (options, actions) = self.menu_rows();
 81         // The menu sizes itself from its labels on `show`, so place it once
 82         // to learn the width, then again with its right edge on the control.
 83         let top = cy + CORNER_R;
 84         context_menu::show(0.0, top, options.clone(), 0, NO_TARGET);
 85         let left = (cx + CORNER_R - context_menu::w()).max(0.0);
 86         context_menu::show(left, top, options, 0, NO_TARGET);
 87         self.plate_menu_actions = actions;
 88     }
 89 
 90     /// Open the same menu as a context menu, its top-left at the pointer
 91     /// (a right-click on the grid). No clamping here: the menu is its own
 92     /// popup surface, and the positioner's constraint adjustment keeps it on
 93     /// the output when the click is near the window's right or bottom edge.
 94     pub(crate) fn open_context_menu(&mut self, px: f32, py: f32) {
 95         let (options, actions) = self.menu_rows();
 96         context_menu::show(px, py, options, 0, NO_TARGET);
 97         self.plate_menu_actions = actions;
 98     }
 99 
100     /// The rows, contextual: Copy only with a selection to copy, Reset Text
101     /// Size only while zoomed, Close Tab only with another tab to fall to.
102     fn menu_rows(&self) -> (Vec<String>, Vec<PlateMenuAction>) {
103         let mut options: Vec<String> = Vec::new();
104         let mut actions: Vec<PlateMenuAction> = Vec::new();
105         let mut row = |label: &str, action: PlateMenuAction| {
106             options.push(label.to_string());
107             actions.push(action);
108         };
109 
110         if self.tab().term.selection_to_string().is_some_and(|s| !s.is_empty()) {
111             row("Copy", PlateMenuAction::Copy);
112         }
113         row("Paste", PlateMenuAction::Paste);
114         row("-", PlateMenuAction::Separator);
115         row("Larger Text", PlateMenuAction::LargerText);
116         row("Smaller Text", PlateMenuAction::SmallerText);
117         if self.zoom_steps != 0 {
118             row("Reset Text Size", PlateMenuAction::ResetTextSize);
119         }
120         row("-", PlateMenuAction::Separator);
121         row("Clear Scrollback", PlateMenuAction::ClearScrollback);
122         row("Reset Terminal", PlateMenuAction::ResetTerminal);
123         row("-", PlateMenuAction::Separator);
124         // The tabs as a RADIO group: every one listed, the shown one marked.
125         // Clicking the marked row is a no-op (show_tab declines the active
126         // index), so the list reads as state, not just as actions. The marks
127         // are the shared menu's convention, drawn as the `circle` /
128         // `circle-outline` glyphs by `paint_with_labels`.
129         for (i, tab) in self.tabs.iter().enumerate() {
130             let mark = if i == self.active { context_menu::MARK_ON } else { context_menu::MARK_OFF };
131             row(&format!("{mark}{}", tab.label(i)), PlateMenuAction::ShowTab(tab.id));
132         }
133         row("New Tab", PlateMenuAction::NewTab);
134         if self.tabs.len() > 1 {
135             row("Close Tab", PlateMenuAction::CloseTab);
136         }
137         row("-", PlateMenuAction::Separator);
138         row("New Window", PlateMenuAction::NewWindow);
139         (options, actions)
140     }
141 
142     pub(crate) fn close_plate_menu(&mut self) {
143         context_menu::hide();
144         self.plate_menu_actions.clear();
145     }
146 
147     /// Route a button event while the corner menu is open: a left press on a
148     /// row dispatches it, any other press dismisses, and releases are eaten
149     /// so the press that opened the menu never completes as a click on the
150     /// grid underneath. `true` when the event was the menu's.
151     pub(crate) fn handle_plate_menu_input(
152         &mut self,
153         button: MouseButton,
154         state: ElementState,
155         px: f32,
156         py: f32,
157     ) -> bool {
158         if !self.plate_menu_open() {
159             return false;
160         }
161         if state != ElementState::Pressed {
162             return true;
163         }
164         let picked = if button == MouseButton::Left {
165             context_menu::row_at(px, py).and_then(|row| self.plate_menu_actions.get(row).copied())
166         } else {
167             None
168         };
169         self.close_plate_menu();
170         if let Some(action) = picked {
171             self.dispatch_plate_menu(action);
172         }
173         true
174     }
175 
176     fn dispatch_plate_menu(&mut self, action: PlateMenuAction) {
177         match action {
178             PlateMenuAction::Copy => {
179                 if let Some(text) = self.tab().term.selection_to_string() {
180                     if !text.is_empty() {
181                         cce_ui::widget::clipboard::copy_to_clipboard(&text);
182                     }
183                 }
184             }
185             PlateMenuAction::Paste => {
186                 if let Some(text) = cce_ui::widget::clipboard::read_from_clipboard() {
187                     if !text.is_empty() {
188                         self.paste(&text);
189                     }
190                 }
191             }
192             PlateMenuAction::LargerText => self.set_zoom(self.zoom_steps + 1),
193             PlateMenuAction::SmallerText => self.set_zoom(self.zoom_steps - 1),
194             PlateMenuAction::ResetTextSize => self.set_zoom(0),
195             PlateMenuAction::ClearScrollback => {
196                 // Drop the view to the live screen first: a display offset
197                 // into history that no longer exists is not a state the grid
198                 // guards against.
199                 let term = &mut self.tab_mut().term;
200                 term.scroll_display(Scroll::Bottom);
201                 term.grid_mut().clear_history();
202                 self.sync_scroll_motion();
203             }
204             PlateMenuAction::ResetTerminal => {
205                 let term = &mut self.tab_mut().term;
206                 term.selection = None;
207                 term.reset_state();
208                 self.sync_scroll_motion();
209             }
210             PlateMenuAction::ShowTab(id) => self.show_tab_by_id(id),
211             PlateMenuAction::NewTab => self.new_tab(),
212             PlateMenuAction::CloseTab => self.close_active_tab(),
213             PlateMenuAction::NewWindow => match std::env::current_exe() {
214                 Ok(exe) => {
215                     if let Err(e) = spawn_detached(std::process::Command::new(exe)) {
216                         log::warn!("cce-terminal: failed to spawn a new window: {e}");
217                     }
218                 }
219                 Err(e) => log::warn!("cce-terminal: cannot locate own executable: {e}"),
220             },
221             PlateMenuAction::Separator => {}
222         }
223     }
224 
225     /// Text zoom: re-derive the settings at the new step and reflow the grid,
226     /// the same path a config edit takes.
227     fn set_zoom(&mut self, steps: i32) {
228         self.zoom_steps = steps.clamp(-8, 24);
229         let font = self.font.clone();
230         let reloaded = crate::Settings::load(&font, self.zoom_steps);
231         self.apply_settings(reloaded);
232     }
233 
234     /// Draw the corner control (emphasized while hovered or open) and, over
235     /// everything, the open menu. Called last in `display_list`.
236     pub(crate) fn paint_plate_menu(&self, pc: &mut cce_ui::scene::paint::PaintCtx) {
237         if let Some(c) = self.plate_corner_center() {
238             let emphasized = self.plate_menu_open()
239                 || plate_dock::corner_hit(c, self.last_pointer.x as f32, self.last_pointer.y as f32);
240             plate_dock::draw_corner_dot(pc, c, emphasized);
241         }
242         if !self.plate_menu_open() {
243             return;
244         }
245         // The shared menu paints as the DE's lit plate; its labels carry
246         // bounds equal to the menu rect, which the engine's text-occlusion
247         // clamp exempts, so they render inside the menu while the grid's
248         // text beneath stays clamped.
249         // Labels come with the plate: a TextLabel carries no family, so the
250         // hand-rolled label loop that used to live here passed None and drew
251         // the menu in the default sans rather than the DE's menu font.
252         context_menu::paint_with_labels(pc);
253     }
254 }
255 
256 /// Spawn `cmd` and reap it on a background thread, so the child never lingers
257 /// as a zombie once it exits. This was `cce_ui::process::spawn_detached` until
258 /// the toolkit dropped that module (cce-ui 4e94236) as caller-less — the new-window
259 /// action here was a caller.
260 fn spawn_detached(mut cmd: std::process::Command) -> std::io::Result<()> {
261     let mut child = cmd.spawn()?;
262     std::thread::spawn(move || {
263         let _ = child.wait();
264     });
265     Ok(())
266 }