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 }