1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
//! The event loop: every key the browser answers to.
use super::*;
impl App {
pub(crate) fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> io::Result<()> {
loop {
// The graph view, when open, takes over the whole screen and its
// own keys until Esc.
if self.graph.is_some() {
terminal.draw(|f| {
if let Some(g) = &self.graph {
g.render(f, f.area());
}
})?;
let Event::Key(key) = event::read()? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
return Ok(());
}
// Ctrl+[ is Esc; treat both as "step back".
let graph_back = matches!(key.code, KeyCode::Esc)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('[')));
// Ctrl+G flips back to the dossier - for whatever package you
// navigated to in the graph, not the one you came in on.
let to_dossier = key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('g'));
match key.code {
// Back out through the graph history; once there's nothing
// left to unwind, close the graph.
_ if graph_back => {
let unwound = self
.graph
.as_mut()
.map(|g| g.back(&self.world))
.unwrap_or(false);
if !unwound {
self.graph = None;
}
}
_ if to_dossier => {
let target = self.graph.as_ref().map(|g| g.selected_name().to_string());
self.graph = None;
// If we dug somewhere new, open that package's dossier;
// if we never moved, closing already lands us on it.
if let Some(t) = target
&& self.frame().focus.as_deref() != Some(t.as_str())
{
self.open(t);
}
}
// q always leaves the graph outright.
KeyCode::Char('q') => self.graph = None,
KeyCode::Enter => {
if let Some(g) = &mut self.graph {
g.recenter(&self.world);
}
}
// Column-aware nav: h/l (and ←/→) cross columns, j/k (and
// ↓/↑) move within a column.
KeyCode::Left | KeyCode::Char('h') => {
if let Some(g) = &mut self.graph {
g.move_horizontal(-1);
}
}
KeyCode::Right | KeyCode::Char('l') => {
if let Some(g) = &mut self.graph {
g.move_horizontal(1);
}
}
KeyCode::Up | KeyCode::Char('k') => {
if let Some(g) = &mut self.graph {
g.move_vertical(-1);
}
}
KeyCode::Down | KeyCode::Char('j') => {
if let Some(g) = &mut self.graph {
g.move_vertical(1);
}
}
_ => {}
}
continue;
}
// Recompute the visible (filtered) list for the current frame.
let visible = self.filtered();
self.clamp_selection(visible.len());
terminal.draw(|f| self.render(f, &visible))?;
let Event::Key(key) = event::read()? else {
continue;
};
// Accept Press and Repeat (so a held Ctrl+J scrolls); ignore Release,
// which the enhanced keyboard protocol also reports.
if key.kind == KeyEventKind::Release {
continue;
}
// Ctrl-C always quits.
if key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('c'))
{
return Ok(());
}
// Esc, or Ctrl+[ - the same control byte historically, but the
// enhanced keyboard protocol reports them separately, so treat both
// as escape.
let escape = matches!(key.code, KeyCode::Esc)
|| (key.modifiers.contains(KeyModifiers::CONTROL)
&& matches!(key.code, KeyCode::Char('[')));
match key.code {
_ if escape => {
// Pop a level; quit if we're already at the root.
if self.stack.len() == 1 {
return Ok(());
}
self.stack.pop();
}
KeyCode::Enter => {
if let Some(pkg) = visible.get(self.frame().selected).cloned() {
self.open(pkg);
}
}
KeyCode::Tab => {
// Cycle all → manual → auto → flatpak; skip the flatpak
// bucket entirely on machines with no flatpak apps.
self.filter = self.filter.next();
if self.filter == FilterMode::Flatpak && !self.has_flatpak() {
self.filter = self.filter.next();
}
self.frame_mut().selected = 0;
}
KeyCode::Up => self.move_selection(-1, visible.len()),
KeyCode::Down => self.move_selection(1, visible.len()),
KeyCode::Left | KeyCode::Right => {
// In a dossier, flip the list between "what needs it" and
// "what it needs". No-op on the root list.
if self.frame().focus.is_some() {
self.relation = self.relation.toggle();
self.frame_mut().selected = 0;
}
}
KeyCode::Backspace => {
self.frame_mut().query.pop();
self.frame_mut().selected = 0;
}
KeyCode::Char(c) => {
// vim-style: Ctrl-j/k move, Ctrl-h/l flip the relation
// (Ctrl-l works everywhere; Ctrl-h only where the terminal
// sends it distinct from Backspace, e.g. kitty protocol).
if key.modifiers.contains(KeyModifiers::CONTROL) {
match c {
'j' | 'n' => self.move_selection(1, visible.len()),
'k' | 'p' => self.move_selection(-1, visible.len()),
'h' | 'l' if self.frame().focus.is_some() => {
self.relation = self.relation.toggle();
self.frame_mut().selected = 0;
}
'g' => {
// Open the graph view centred on the focused
// package, or the highlighted row at the root.
let target = self
.frame()
.focus
.clone()
.or_else(|| visible.get(self.frame().selected).cloned());
if let Some(t) = target {
self.graph = Some(graph::GraphView::build(&self.world, &t));
}
}
_ => {}
}
} else {
self.frame_mut().query.push(c);
self.frame_mut().selected = 0;
}
}
_ => {}
}
}
}
}