Skip to content

Commit 0db3254

Browse files
authored
Merge pull request #14 from windoze/feat/reusable-popup-context-menu
Feat/reusable popup context menu
2 parents e2e708b + 23639f5 commit 0db3254

15 files changed

Lines changed: 1192 additions & 370 deletions

File tree

crates/atto-ui-editor/src/popup.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,7 @@ fn popup_decorations() -> WindowDecorations {
730730
maximize: false,
731731
close: false,
732732
},
733+
backdrop_dim: true,
733734
}
734735
}
735736

crates/atto-ui-terminal/examples/terminal_viewer.rs

Lines changed: 77 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,9 @@ use ratatui::widgets::{Paragraph, Wrap};
1616

1717
use atto_ui::app::{
1818
AppControl, CrosstermAppConfig, CursorMode, Desktop, MenuBar, MenuItem, MenuSpec,
19-
run_crossterm_desktop_with_actions,
20-
};
21-
use atto_ui::composable::{
22-
Component, ComponentContext, EventHandling, EventResult, FocusNav, Layout, Scrollable,
19+
popup_menu_window, run_crossterm_desktop_with_actions,
2320
};
21+
use atto_ui::composable::{Component, ComponentContext};
2422
use atto_ui::reactive::{Binding, DirtyObserver};
2523
use atto_ui::theme::Theme;
2624
use atto_ui::wm::{Window, WindowId, WindowKind, WindowState};
@@ -93,91 +91,6 @@ struct CommandContextState {
9391
block_index: usize,
9492
}
9593

96-
struct CommandContextMenuView {
97-
action_tx: mpsc::Sender<TerminalViewerAction>,
98-
last_area: Option<Rect>,
99-
}
100-
101-
impl CommandContextMenuView {
102-
fn new(action_tx: mpsc::Sender<TerminalViewerAction>) -> Self {
103-
Self {
104-
action_tx,
105-
last_area: None,
106-
}
107-
}
108-
109-
fn action_for_row(row: u16) -> Option<CommandContextMenuAction> {
110-
match row {
111-
0 => Some(CommandContextMenuAction::Rerun),
112-
1 => Some(CommandContextMenuAction::CopyCommand),
113-
2 => Some(CommandContextMenuAction::CopyOutput),
114-
_ => None,
115-
}
116-
}
117-
}
118-
119-
impl Component for CommandContextMenuView {
120-
fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, _ctx: ComponentContext<'_>) {
121-
self.last_area = Some(area);
122-
frame.render_widget(
123-
Paragraph::new(vec![
124-
Line::from("Rerun"),
125-
Line::from("Copy command"),
126-
Line::from("Copy output"),
127-
]),
128-
area,
129-
);
130-
}
131-
}
132-
133-
impl EventHandling for CommandContextMenuView {
134-
fn handle_event(&mut self, event: &Event, ctx: ComponentContext<'_>) -> EventResult {
135-
let Event::Mouse(MouseEvent {
136-
kind: MouseEventKind::Down(MouseButton::Left),
137-
column,
138-
row,
139-
..
140-
}) = event
141-
else {
142-
return EventResult::ignored();
143-
};
144-
let Some(area) = self.last_area else {
145-
return EventResult::ignored();
146-
};
147-
let (local_col, local_row) = match ctx.mouse_coordinate_space {
148-
atto_ui::composable::MouseCoordinateSpace::Absolute => {
149-
if *column < area.x
150-
|| *row < area.y
151-
|| *column >= area.x.saturating_add(area.width)
152-
|| *row >= area.y.saturating_add(area.height)
153-
{
154-
return EventResult::ignored();
155-
}
156-
(
157-
(*column).saturating_sub(area.x),
158-
(*row).saturating_sub(area.y),
159-
)
160-
}
161-
atto_ui::composable::MouseCoordinateSpace::Local => (*column, *row),
162-
};
163-
if local_col >= area.width {
164-
return EventResult::ignored();
165-
}
166-
let Some(action) = Self::action_for_row(local_row) else {
167-
return EventResult::ignored();
168-
};
169-
let _ = self
170-
.action_tx
171-
.send(TerminalViewerAction::CommandContext(action));
172-
EventResult::consumed()
173-
}
174-
}
175-
176-
impl Layout for CommandContextMenuView {}
177-
impl Scrollable for CommandContextMenuView {}
178-
impl FocusNav for CommandContextMenuView {}
179-
atto_ui::impl_component_default_traits!(CommandContextMenuView => DynamicTree);
180-
18194
struct FeatureGuideView {
18295
lines: Vec<String>,
18396
}
@@ -406,7 +319,6 @@ fn seed_terminal_banner(
406319
handle.process_output_str(
407320
"Right-click an OSC 133 command block for rerun/copy actions when shell integration is active.\r\n",
408321
);
409-
handle.process_output_str("\x1b[?1000h\x1b[?1006h");
410322
Ok(())
411323
}
412324

@@ -653,15 +565,26 @@ fn command_block_at_mouse(
653565
None
654566
}
655567

656-
fn command_context_menu_rect(screen: Rect, mouse: &MouseEvent) -> Rect {
657-
let width = 18.min(screen.width.max(1));
658-
let height = 5.min(screen.height.max(1));
659-
Rect {
660-
x: mouse.column.min(screen.width.saturating_sub(width)),
661-
y: mouse.row.min(screen.height.saturating_sub(height)),
662-
width,
663-
height,
664-
}
568+
fn command_context_menu_items(action_tx: &mpsc::Sender<TerminalViewerAction>) -> Vec<MenuItem> {
569+
let send = |action: CommandContextMenuAction, tx: mpsc::Sender<TerminalViewerAction>| {
570+
move || {
571+
let _ = tx.send(TerminalViewerAction::CommandContext(action));
572+
}
573+
};
574+
vec![
575+
MenuItem::action(
576+
"Rerun",
577+
send(CommandContextMenuAction::Rerun, action_tx.clone()),
578+
),
579+
MenuItem::action(
580+
"Copy command",
581+
send(CommandContextMenuAction::CopyCommand, action_tx.clone()),
582+
),
583+
MenuItem::action(
584+
"Copy output",
585+
send(CommandContextMenuAction::CopyOutput, action_tx.clone()),
586+
),
587+
]
665588
}
666589

667590
fn open_command_context_menu(
@@ -688,13 +611,12 @@ fn open_command_context_menu(
688611
let _ = pane.handle.select_command_block_output(block_index);
689612
}
690613
let menu_id = desktop.add_window(
691-
Window::new(
692-
WindowKind::Tooltip,
614+
popup_menu_window(
615+
command_context_menu_items(action_tx),
616+
(mouse.column, mouse.row),
617+
screen,
693618
"Command",
694-
command_context_menu_rect(screen, mouse),
695-
Box::new(CommandContextMenuView::new(action_tx.clone())),
696-
)
697-
.with_min_size(18, 5),
619+
),
698620
screen,
699621
);
700622
*context = Some(CommandContextState {
@@ -754,6 +676,50 @@ fn is_right_mouse_down(event: &Event) -> Option<&MouseEvent> {
754676
}
755677
}
756678

679+
fn non_right_mouse_down(event: &Event) -> Option<&MouseEvent> {
680+
match event {
681+
Event::Mouse(
682+
mouse @ MouseEvent {
683+
kind: MouseEventKind::Down(MouseButton::Left | MouseButton::Middle),
684+
..
685+
},
686+
) => Some(mouse),
687+
_ => None,
688+
}
689+
}
690+
691+
/// Dismisses the context menu when a non-right click lands outside the menu
692+
/// window. Clicks inside the menu are handled by the menu view itself (a row
693+
/// activation closes the window and its action is applied next frame, so the
694+
/// stored context must survive this call).
695+
fn dismiss_context_menu_on_outside_click(
696+
desktop: &mut Desktop,
697+
event: &Event,
698+
context: &mut Option<CommandContextState>,
699+
) {
700+
let Some(mouse) = non_right_mouse_down(event) else {
701+
return;
702+
};
703+
let Some(state) = *context else {
704+
return;
705+
};
706+
// If the menu window is gone, the click already activated an item; keep the
707+
// context so `on_action` can apply it next frame.
708+
let outside = match desktop.wm.window(state.menu_id) {
709+
Some(window) => {
710+
let rect = window.rect.get();
711+
mouse.column < rect.x
712+
|| mouse.row < rect.y
713+
|| mouse.column >= rect.x.saturating_add(rect.width)
714+
|| mouse.row >= rect.y.saturating_add(rect.height)
715+
}
716+
None => false,
717+
};
718+
if outside {
719+
close_command_context_menu(desktop, context);
720+
}
721+
}
722+
757723
fn spawn_terminal_window(
758724
desktop: &mut Desktop,
759725
screen: Rect,
@@ -1180,6 +1146,12 @@ fn main() -> Result<()> {
11801146
&action_tx_for_event,
11811147
&mut command_context_for_event.borrow_mut(),
11821148
);
1149+
} else {
1150+
dismiss_context_menu_on_outside_click(
1151+
desktop,
1152+
ev,
1153+
&mut command_context_for_event.borrow_mut(),
1154+
);
11831155
}
11841156
if is_plain_restart_key(ev) {
11851157
let mut sessions = terminal_sessions_for_event.borrow_mut();

0 commit comments

Comments
 (0)