@tryinget/pi-activity-strip 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/native/bin/linux-x64-gnu/artifact.json +3 -2
- package/native/bin/linux-x64-gnu/pi-activity-strip-panel +0 -0
- package/native/panel/src/app.rs +780 -29
- package/native/panel/src/directory_view.rs +409 -0
- package/native/panel/src/main.rs +1 -0
- package/native/panel/src/protocol.rs +71 -0
- package/native/panel/src/runtime.rs +43 -11
- package/native/panel/src/style.css +85 -0
- package/package.json +2 -3
- package/src/common/contracts.d.ts +1 -0
- package/src/common/contracts.ts +1 -0
- package/src/common/workspace-view.mjs +64 -0
- package/src/native/main.mjs +44 -1
- package/src/native/panel-projection.mjs +24 -2
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
use crate::app::AppMsg;
|
|
2
|
+
use crate::protocol::WindowEntry;
|
|
3
|
+
use relm4::Sender;
|
|
4
|
+
use relm4::gtk;
|
|
5
|
+
use relm4::gtk::prelude::*;
|
|
6
|
+
|
|
7
|
+
/// Enough digits for any Niri window id; a longer number is a typo, not a window.
|
|
8
|
+
const MAX_WINDOW_DIGITS: usize = 9;
|
|
9
|
+
/// A search is a few letters of a project name, never a sentence.
|
|
10
|
+
const MAX_QUERY_CHARS: usize = 32;
|
|
11
|
+
|
|
12
|
+
/// What Enter does with the typed query.
|
|
13
|
+
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
14
|
+
pub enum JumpChoice {
|
|
15
|
+
/// A number: exactly that Niri window, listed or not.
|
|
16
|
+
Window(i64),
|
|
17
|
+
/// A name: the first listed match, opened like clicking its row.
|
|
18
|
+
Card(String),
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/// The brand block's window list: every agent window on every workspace, as its card's first line
|
|
22
|
+
/// and its Niri window number. The typed query narrows it by name or number; Enter goes to the
|
|
23
|
+
/// typed window number, or to the first match of a name.
|
|
24
|
+
pub struct DirectoryView {
|
|
25
|
+
pub root: gtk::Box,
|
|
26
|
+
status: gtk::Label,
|
|
27
|
+
rows: gtk::Box,
|
|
28
|
+
sender: Sender<AppMsg>,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
impl DirectoryView {
|
|
32
|
+
pub fn new(sender: &Sender<AppMsg>) -> Self {
|
|
33
|
+
let root = gtk::Box::new(gtk::Orientation::Vertical, 6);
|
|
34
|
+
root.add_css_class("directory");
|
|
35
|
+
root.set_visible(false);
|
|
36
|
+
root.set_vexpand(true);
|
|
37
|
+
root.set_margin_top(8);
|
|
38
|
+
|
|
39
|
+
let status = gtk::Label::new(None);
|
|
40
|
+
status.add_css_class("jump-line");
|
|
41
|
+
status.set_halign(gtk::Align::Fill);
|
|
42
|
+
status.set_xalign(0.0);
|
|
43
|
+
|
|
44
|
+
let rows = gtk::Box::new(gtk::Orientation::Vertical, 1);
|
|
45
|
+
let scroller = gtk::ScrolledWindow::new();
|
|
46
|
+
scroller.add_css_class("directory-scroller");
|
|
47
|
+
scroller.set_policy(gtk::PolicyType::Never, gtk::PolicyType::Automatic);
|
|
48
|
+
// Fill the brand column's height; rows beyond it scroll (wheel or touchpad, no bar).
|
|
49
|
+
scroller.set_vexpand(true);
|
|
50
|
+
scroller.set_child(Some(&rows));
|
|
51
|
+
|
|
52
|
+
root.append(&status);
|
|
53
|
+
root.append(&scroller);
|
|
54
|
+
Self {
|
|
55
|
+
root,
|
|
56
|
+
status,
|
|
57
|
+
rows,
|
|
58
|
+
sender: sender.clone(),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
pub fn set_visible(&self, visible: bool) {
|
|
63
|
+
self.root.set_visible(visible);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Rebuild the rows for the current list and query. Clicking a row activates its card, so a
|
|
67
|
+
/// hidden tab is presented exactly as clicking its card would. The row Enter would open is
|
|
68
|
+
/// marked, so a typed name always shows where it leads.
|
|
69
|
+
pub fn render(
|
|
70
|
+
&self,
|
|
71
|
+
windows: &[WindowEntry],
|
|
72
|
+
query: &str,
|
|
73
|
+
can_type: bool,
|
|
74
|
+
allow_unlisted: bool,
|
|
75
|
+
) {
|
|
76
|
+
while let Some(child) = self.rows.first_child() {
|
|
77
|
+
self.rows.remove(&child);
|
|
78
|
+
}
|
|
79
|
+
let shown = filter_entries(windows, query);
|
|
80
|
+
let choice = jump_choice(windows, query, allow_unlisted);
|
|
81
|
+
// Alphabetical from the controller, so workspace headings would repeat; each row names
|
|
82
|
+
// its workspace instead.
|
|
83
|
+
for entry in &shown {
|
|
84
|
+
let chosen = match &choice {
|
|
85
|
+
Some(JumpChoice::Window(id)) => entry.window_id == *id,
|
|
86
|
+
Some(JumpChoice::Card(card_id)) => &entry.card_id == card_id,
|
|
87
|
+
None => false,
|
|
88
|
+
};
|
|
89
|
+
self.rows.append(&self.row(entry, chosen));
|
|
90
|
+
}
|
|
91
|
+
if shown.is_empty() {
|
|
92
|
+
let empty = gtk::Label::new(Some(if windows.is_empty() {
|
|
93
|
+
"No agent windows"
|
|
94
|
+
} else {
|
|
95
|
+
"No listed window matches"
|
|
96
|
+
}));
|
|
97
|
+
empty.add_css_class("directory-empty");
|
|
98
|
+
empty.set_xalign(0.0);
|
|
99
|
+
self.rows.append(&empty);
|
|
100
|
+
}
|
|
101
|
+
self.status
|
|
102
|
+
.set_text(&status_text(windows, query, can_type, allow_unlisted));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/// The typing hint alone, leaving the rows (and any click in progress on them) untouched.
|
|
106
|
+
pub fn set_prompt(
|
|
107
|
+
&self,
|
|
108
|
+
windows: &[WindowEntry],
|
|
109
|
+
query: &str,
|
|
110
|
+
can_type: bool,
|
|
111
|
+
allow_unlisted: bool,
|
|
112
|
+
) {
|
|
113
|
+
self.status
|
|
114
|
+
.set_text(&status_text(windows, query, can_type, allow_unlisted));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/// A transient message after a jump or activation, until the next keystroke re-renders.
|
|
118
|
+
pub fn set_status(&self, message: &str) {
|
|
119
|
+
self.status.set_text(message);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
fn row(&self, entry: &WindowEntry, chosen: bool) -> gtk::Button {
|
|
123
|
+
let row = gtk::Button::new();
|
|
124
|
+
row.add_css_class("directory-row");
|
|
125
|
+
if entry.current {
|
|
126
|
+
row.add_css_class("current");
|
|
127
|
+
}
|
|
128
|
+
if chosen {
|
|
129
|
+
row.add_css_class("chosen");
|
|
130
|
+
}
|
|
131
|
+
let line = gtk::Box::new(gtk::Orientation::Horizontal, 8);
|
|
132
|
+
let label = gtk::Label::new(Some(&format!(
|
|
133
|
+
"{}{}",
|
|
134
|
+
if entry.hidden_tab { "⧉ " } else { "" },
|
|
135
|
+
entry.label
|
|
136
|
+
)));
|
|
137
|
+
label.add_css_class("directory-label");
|
|
138
|
+
label.set_xalign(0.0);
|
|
139
|
+
label.set_hexpand(true);
|
|
140
|
+
label.set_ellipsize(relm4::gtk::pango::EllipsizeMode::End);
|
|
141
|
+
label.set_max_width_chars(1);
|
|
142
|
+
let workspace = gtk::Label::new(Some(&workspace_tag(entry)));
|
|
143
|
+
workspace.add_css_class("directory-workspace");
|
|
144
|
+
let id = gtk::Label::new(Some(&format!("#{}", entry.window_id)));
|
|
145
|
+
id.add_css_class("directory-id");
|
|
146
|
+
line.append(&label);
|
|
147
|
+
line.append(&workspace);
|
|
148
|
+
line.append(&id);
|
|
149
|
+
row.set_child(Some(&line));
|
|
150
|
+
row.set_tooltip_text(Some(&format!(
|
|
151
|
+
"{} — window #{}, {}{}",
|
|
152
|
+
entry.label,
|
|
153
|
+
entry.window_id,
|
|
154
|
+
workspace_title(entry),
|
|
155
|
+
if entry.hidden_tab { ", hidden tab" } else { "" }
|
|
156
|
+
)));
|
|
157
|
+
let tx = self.sender.clone();
|
|
158
|
+
let card_id = entry.card_id.clone();
|
|
159
|
+
row.connect_clicked(move |_| {
|
|
160
|
+
let _ = tx.send(AppMsg::DirectoryActivate(card_id.clone()));
|
|
161
|
+
});
|
|
162
|
+
row
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/// Entries whose project name contains the query (ignoring case) or whose window number starts
|
|
167
|
+
/// with it; all of them when nothing is typed.
|
|
168
|
+
pub fn filter_entries<'a>(windows: &'a [WindowEntry], query: &str) -> Vec<&'a WindowEntry> {
|
|
169
|
+
let needle = query.to_lowercase();
|
|
170
|
+
windows
|
|
171
|
+
.iter()
|
|
172
|
+
.filter(|entry| {
|
|
173
|
+
entry.window_id.to_string().starts_with(&needle)
|
|
174
|
+
|| entry.label.to_lowercase().contains(&needle)
|
|
175
|
+
})
|
|
176
|
+
.collect()
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
fn is_window_number(query: &str) -> bool {
|
|
180
|
+
!query.is_empty() && query.chars().all(|character| character.is_ascii_digit())
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/// What Enter does. Digits go to the listed window with exactly that number; failing that, to a
|
|
184
|
+
/// project whose name contains them (`0844` finds `pi-0844-…`); failing that, when
|
|
185
|
+
/// `allow_unlisted`, to exactly that number, listed or not, never a guessed completion. Anything
|
|
186
|
+
/// else goes to the first project whose name contains it. Nothing typed, or nothing matching,
|
|
187
|
+
/// does nothing. Typing held only by hover passes `allow_unlisted = false`, so a digit typed at a
|
|
188
|
+
/// terminal prompt cannot reach an arbitrary window.
|
|
189
|
+
pub fn jump_choice(
|
|
190
|
+
windows: &[WindowEntry],
|
|
191
|
+
query: &str,
|
|
192
|
+
allow_unlisted: bool,
|
|
193
|
+
) -> Option<JumpChoice> {
|
|
194
|
+
if query.is_empty() {
|
|
195
|
+
return None;
|
|
196
|
+
}
|
|
197
|
+
let needle = query.to_lowercase();
|
|
198
|
+
let named = || {
|
|
199
|
+
windows
|
|
200
|
+
.iter()
|
|
201
|
+
.find(|entry| entry.label.to_lowercase().contains(&needle))
|
|
202
|
+
.map(|entry| JumpChoice::Card(entry.card_id.clone()))
|
|
203
|
+
};
|
|
204
|
+
if !is_window_number(query) {
|
|
205
|
+
return named();
|
|
206
|
+
}
|
|
207
|
+
let number = (query.len() <= MAX_WINDOW_DIGITS)
|
|
208
|
+
.then(|| query.parse::<i64>().ok())
|
|
209
|
+
.flatten();
|
|
210
|
+
if let Some(id) = number
|
|
211
|
+
&& windows.iter().any(|entry| entry.window_id == id)
|
|
212
|
+
{
|
|
213
|
+
return Some(JumpChoice::Window(id));
|
|
214
|
+
}
|
|
215
|
+
named().or(number.filter(|_| allow_unlisted).map(JumpChoice::Window))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/// Append a typed character: letters and digits, and the `-`, `_` and `.` of project names.
|
|
219
|
+
pub fn push_query_char(query: &mut String, character: char) -> bool {
|
|
220
|
+
if !is_query_char(character) || query.chars().count() >= MAX_QUERY_CHARS {
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
query.push(character);
|
|
224
|
+
true
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
pub fn is_query_char(character: char) -> bool {
|
|
228
|
+
character.is_alphanumeric() || matches!(character, '-' | '_' | '.')
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/// The short workspace marker on each row: the number the operator sees in Niri.
|
|
232
|
+
fn workspace_tag(entry: &WindowEntry) -> String {
|
|
233
|
+
entry
|
|
234
|
+
.workspace_idx
|
|
235
|
+
.map(|idx| format!("ws{idx}"))
|
|
236
|
+
.unwrap_or_default()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/// The workspace in full, for the row's tooltip: its number and any name.
|
|
240
|
+
fn workspace_title(entry: &WindowEntry) -> String {
|
|
241
|
+
let number = entry
|
|
242
|
+
.workspace_idx
|
|
243
|
+
.map(|idx| format!("workspace {idx}"))
|
|
244
|
+
.unwrap_or_else(|| "workspace".to_owned());
|
|
245
|
+
match entry
|
|
246
|
+
.workspace_name
|
|
247
|
+
.as_deref()
|
|
248
|
+
.filter(|name| !name.is_empty())
|
|
249
|
+
{
|
|
250
|
+
Some(name) => format!("{number} · {name}"),
|
|
251
|
+
None => number,
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn status_text(
|
|
256
|
+
windows: &[WindowEntry],
|
|
257
|
+
query: &str,
|
|
258
|
+
can_type: bool,
|
|
259
|
+
allow_unlisted: bool,
|
|
260
|
+
) -> String {
|
|
261
|
+
if !can_type {
|
|
262
|
+
// The keyboard was handed back (hover timeout); a click takes it again.
|
|
263
|
+
return "Click, then type a name or number".to_owned();
|
|
264
|
+
}
|
|
265
|
+
match jump_choice(windows, query, allow_unlisted) {
|
|
266
|
+
_ if query.is_empty() => "Type a name or number · ⏎ go".to_owned(),
|
|
267
|
+
Some(JumpChoice::Window(_)) => format!("#{query}▏ ⏎ jump · ⌫ edit"),
|
|
268
|
+
Some(JumpChoice::Card(card_id)) => {
|
|
269
|
+
let label = windows
|
|
270
|
+
.iter()
|
|
271
|
+
.find(|entry| entry.card_id == card_id)
|
|
272
|
+
.map(|entry| entry.label.as_str())
|
|
273
|
+
.unwrap_or("match");
|
|
274
|
+
format!("{query}▏ ⏎ {label}")
|
|
275
|
+
}
|
|
276
|
+
None => format!("{query}▏ no match"),
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
#[cfg(test)]
|
|
281
|
+
mod tests {
|
|
282
|
+
use super::*;
|
|
283
|
+
|
|
284
|
+
fn jump_choice_open(windows: &[WindowEntry], query: &str) -> Option<JumpChoice> {
|
|
285
|
+
jump_choice(windows, query, true)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
fn entry(label: &str, window_id: i64, workspace_idx: i64) -> WindowEntry {
|
|
289
|
+
WindowEntry {
|
|
290
|
+
card_id: format!("card-{window_id}"),
|
|
291
|
+
label: label.into(),
|
|
292
|
+
window_id,
|
|
293
|
+
workspace_id: Some(workspace_idx + 100),
|
|
294
|
+
workspace_idx: Some(workspace_idx),
|
|
295
|
+
workspace_name: None,
|
|
296
|
+
hidden_tab: false,
|
|
297
|
+
current: false,
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
#[test]
|
|
302
|
+
fn the_status_line_says_what_enter_will_do() {
|
|
303
|
+
let windows = vec![entry("pi-extensions", 432, 2)];
|
|
304
|
+
assert_eq!(
|
|
305
|
+
status_text(&windows, "", false, true),
|
|
306
|
+
"Click, then type a name or number"
|
|
307
|
+
);
|
|
308
|
+
assert_eq!(
|
|
309
|
+
status_text(&windows, "", true, true),
|
|
310
|
+
"Type a name or number · ⏎ go"
|
|
311
|
+
);
|
|
312
|
+
assert!(status_text(&windows, "43", true, true).starts_with("#43"));
|
|
313
|
+
assert_eq!(
|
|
314
|
+
status_text(&windows, "ext", true, true),
|
|
315
|
+
"ext▏ ⏎ pi-extensions"
|
|
316
|
+
);
|
|
317
|
+
assert_eq!(status_text(&windows, "zzz", true, true), "zzz▏ no match");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
#[test]
|
|
321
|
+
fn the_query_matches_names_anywhere_and_numbers_from_the_start() {
|
|
322
|
+
let windows = vec![
|
|
323
|
+
entry("pi-extensions", 43, 2),
|
|
324
|
+
entry("dep-diet", 432, 2),
|
|
325
|
+
entry("Text-Tools", 344, 3),
|
|
326
|
+
];
|
|
327
|
+
let labels = |query: &str| -> Vec<String> {
|
|
328
|
+
filter_entries(&windows, query)
|
|
329
|
+
.iter()
|
|
330
|
+
.map(|entry| entry.label.clone())
|
|
331
|
+
.collect()
|
|
332
|
+
};
|
|
333
|
+
assert_eq!(labels("").len(), 3);
|
|
334
|
+
assert_eq!(
|
|
335
|
+
labels("ext"),
|
|
336
|
+
["pi-extensions", "Text-Tools"],
|
|
337
|
+
"any case, anywhere"
|
|
338
|
+
);
|
|
339
|
+
assert_eq!(labels("43"), ["pi-extensions", "dep-diet"]);
|
|
340
|
+
assert_eq!(labels("432"), ["dep-diet"]);
|
|
341
|
+
assert!(labels("9").is_empty());
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[test]
|
|
345
|
+
fn enter_goes_to_the_typed_number_or_the_first_named_match() {
|
|
346
|
+
let windows = vec![entry("dep-diet", 432, 2), entry("pi-extensions", 43, 2)];
|
|
347
|
+
assert_eq!(
|
|
348
|
+
jump_choice_open(&windows, "43"),
|
|
349
|
+
Some(JumpChoice::Window(43))
|
|
350
|
+
);
|
|
351
|
+
assert_eq!(
|
|
352
|
+
jump_choice_open(&windows, "007"),
|
|
353
|
+
Some(JumpChoice::Window(7)),
|
|
354
|
+
"a number need not be listed"
|
|
355
|
+
);
|
|
356
|
+
assert_eq!(jump_choice_open(&windows, "1234567890"), None);
|
|
357
|
+
assert_eq!(
|
|
358
|
+
jump_choice_open(&windows, "d"),
|
|
359
|
+
Some(JumpChoice::Card("card-432".into())),
|
|
360
|
+
"the first listed match"
|
|
361
|
+
);
|
|
362
|
+
assert_eq!(jump_choice_open(&windows, "nothing"), None);
|
|
363
|
+
let numbered = vec![entry("pi-0844-qualification", 12, 2), entry("x", 844, 2)];
|
|
364
|
+
assert_eq!(
|
|
365
|
+
jump_choice_open(&numbered, "844"),
|
|
366
|
+
Some(JumpChoice::Window(844)),
|
|
367
|
+
"a listed window number wins"
|
|
368
|
+
);
|
|
369
|
+
assert_eq!(
|
|
370
|
+
jump_choice_open(&numbered[..1], "0844"),
|
|
371
|
+
Some(JumpChoice::Card("card-12".into())),
|
|
372
|
+
"digits inside a name, with no such window listed, go to that project"
|
|
373
|
+
);
|
|
374
|
+
assert_eq!(jump_choice_open(&windows, ""), None);
|
|
375
|
+
assert_eq!(
|
|
376
|
+
jump_choice(&windows, "1", false),
|
|
377
|
+
None,
|
|
378
|
+
"held only by hover, an unlisted number goes nowhere"
|
|
379
|
+
);
|
|
380
|
+
assert_eq!(
|
|
381
|
+
jump_choice(&windows, "43", false),
|
|
382
|
+
Some(JumpChoice::Window(43)),
|
|
383
|
+
"a listed number still jumps"
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
#[test]
|
|
388
|
+
fn the_query_takes_name_characters_up_to_a_bound() {
|
|
389
|
+
let mut query = String::new();
|
|
390
|
+
assert!(push_query_char(&mut query, 'e'));
|
|
391
|
+
assert!(push_query_char(&mut query, '-'));
|
|
392
|
+
assert!(push_query_char(&mut query, '4'));
|
|
393
|
+
assert!(!push_query_char(&mut query, ' '));
|
|
394
|
+
assert!(!push_query_char(&mut query, '/'));
|
|
395
|
+
for _ in 0..50 {
|
|
396
|
+
push_query_char(&mut query, 'x');
|
|
397
|
+
}
|
|
398
|
+
assert_eq!(query.chars().count(), MAX_QUERY_CHARS);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[test]
|
|
402
|
+
fn each_row_names_its_workspace() {
|
|
403
|
+
let mut named = entry("a", 1, 3);
|
|
404
|
+
named.workspace_name = Some("claude-recovery".into());
|
|
405
|
+
assert_eq!(workspace_tag(&named), "ws3");
|
|
406
|
+
assert_eq!(workspace_title(&named), "workspace 3 · claude-recovery");
|
|
407
|
+
assert_eq!(workspace_title(&entry("a", 1, 2)), "workspace 2");
|
|
408
|
+
}
|
|
409
|
+
}
|
package/native/panel/src/main.rs
CHANGED
|
@@ -140,6 +140,31 @@ pub struct ViewMessage {
|
|
|
140
140
|
pub focused_card_id: Option<String>,
|
|
141
141
|
#[serde(default)]
|
|
142
142
|
pub sessions: Vec<Card>,
|
|
143
|
+
/// Every agent window on every workspace, for the brand block's window list.
|
|
144
|
+
#[serde(default)]
|
|
145
|
+
pub windows: Vec<WindowEntry>,
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// One entry of the window list: the card's first line and the Niri window number to jump to.
|
|
149
|
+
#[derive(Clone, Debug, Deserialize, PartialEq)]
|
|
150
|
+
#[serde(rename_all = "camelCase")]
|
|
151
|
+
pub struct WindowEntry {
|
|
152
|
+
#[serde(default)]
|
|
153
|
+
pub card_id: String,
|
|
154
|
+
#[serde(default)]
|
|
155
|
+
pub label: String,
|
|
156
|
+
pub window_id: i64,
|
|
157
|
+
/// Niri's workspace id: the grouping key, since workspace numbers repeat across monitors.
|
|
158
|
+
#[serde(default, deserialize_with = "lenient_integer")]
|
|
159
|
+
pub workspace_id: Option<i64>,
|
|
160
|
+
#[serde(default, deserialize_with = "lenient_integer")]
|
|
161
|
+
pub workspace_idx: Option<i64>,
|
|
162
|
+
#[serde(default)]
|
|
163
|
+
pub workspace_name: Option<String>,
|
|
164
|
+
#[serde(default)]
|
|
165
|
+
pub hidden_tab: bool,
|
|
166
|
+
#[serde(default)]
|
|
167
|
+
pub current: bool,
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
fn protocol_version() -> u8 {
|
|
@@ -252,6 +277,33 @@ pub fn demo_view() -> ViewMessage {
|
|
|
252
277
|
..empty_card()
|
|
253
278
|
},
|
|
254
279
|
],
|
|
280
|
+
windows: vec![
|
|
281
|
+
demo_window("demo-monitor", "activity-strip", 36, 2, false, false),
|
|
282
|
+
demo_window("demo-tool", "native-dogfood", 43, 2, false, true),
|
|
283
|
+
demo_window("demo-hidden", "pi-extensions", 43, 2, true, false),
|
|
284
|
+
demo_window("demo-other", "dep-surgeon", 344, 3, false, false),
|
|
285
|
+
demo_window("demo-far", "niri-desktop-continuity", 1048, 5, false, false),
|
|
286
|
+
],
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
fn demo_window(
|
|
291
|
+
card_id: &str,
|
|
292
|
+
label: &str,
|
|
293
|
+
window_id: i64,
|
|
294
|
+
workspace_idx: i64,
|
|
295
|
+
hidden_tab: bool,
|
|
296
|
+
current: bool,
|
|
297
|
+
) -> WindowEntry {
|
|
298
|
+
WindowEntry {
|
|
299
|
+
card_id: card_id.into(),
|
|
300
|
+
label: label.into(),
|
|
301
|
+
window_id,
|
|
302
|
+
workspace_id: Some(workspace_idx + 100),
|
|
303
|
+
workspace_idx: Some(workspace_idx),
|
|
304
|
+
workspace_name: None,
|
|
305
|
+
hidden_tab,
|
|
306
|
+
current,
|
|
255
307
|
}
|
|
256
308
|
}
|
|
257
309
|
|
|
@@ -300,6 +352,25 @@ mod tests {
|
|
|
300
352
|
assert_eq!(view.sessions[0].pid, 4242);
|
|
301
353
|
assert!(view.sessions[0].ak_tasks.is_empty());
|
|
302
354
|
assert_eq!(view.sessions[0].ak_task_overflow, 0);
|
|
355
|
+
assert!(
|
|
356
|
+
view.windows.is_empty(),
|
|
357
|
+
"controllers without a window list still parse"
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#[test]
|
|
362
|
+
fn view_messages_carry_the_window_list() {
|
|
363
|
+
let view: ViewMessage = serde_json::from_str(
|
|
364
|
+
r#"{"type":"view","revision":1,"windows":[{"cardId":"terminal:a","label":"pi-extensions","windowId":432,"workspaceIdx":"x","workspaceName":null,"hiddenTab":true,"current":false}]}"#,
|
|
365
|
+
)
|
|
366
|
+
.expect("view should parse");
|
|
367
|
+
assert_eq!(view.windows.len(), 1);
|
|
368
|
+
assert_eq!(view.windows[0].window_id, 432);
|
|
369
|
+
assert_eq!(
|
|
370
|
+
view.windows[0].workspace_idx, None,
|
|
371
|
+
"a malformed workspace number drops only that field"
|
|
372
|
+
);
|
|
373
|
+
assert!(view.windows[0].hidden_tab);
|
|
303
374
|
}
|
|
304
375
|
|
|
305
376
|
#[test]
|
|
@@ -45,20 +45,36 @@ pub fn apply_theme(definitions: &str) {
|
|
|
45
45
|
THEME_PROVIDER.with(|provider| provider.load_from_data(&css));
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/// Read controller messages from stdin on their own thread. The thread is detached on purpose: it
|
|
49
|
+
/// lives exactly as long as stdin, and on EOF it reports the parent gone, which closes the panel.
|
|
48
50
|
pub fn start_input_reader(sender: relm4::Sender<AppMsg>) {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
51
|
+
let reader_sender = sender.clone();
|
|
52
|
+
let spawned = std::thread::Builder::new()
|
|
53
|
+
.name("panel-input".into())
|
|
54
|
+
.spawn(move || read_input(reader_sender));
|
|
55
|
+
match spawned {
|
|
56
|
+
Ok(handle) => drop(handle),
|
|
57
|
+
Err(error) => {
|
|
58
|
+
let _ = sender.send(AppMsg::InputError(format!(
|
|
59
|
+
"could not start the panel input reader: {error}"
|
|
60
|
+
)));
|
|
61
|
+
let _ = sender.send(AppMsg::ParentGone);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
fn read_input(sender: relm4::Sender<AppMsg>) {
|
|
67
|
+
for line in BufReader::new(std::io::stdin()).lines() {
|
|
68
|
+
match line {
|
|
69
|
+
Ok(line) if !line.trim().is_empty() => consume_line(&sender, &line),
|
|
70
|
+
Ok(_) => {}
|
|
71
|
+
Err(error) => {
|
|
72
|
+
let _ = sender.send(AppMsg::InputError(format!("panel input failed: {error}")));
|
|
73
|
+
break;
|
|
58
74
|
}
|
|
59
75
|
}
|
|
60
|
-
|
|
61
|
-
|
|
76
|
+
}
|
|
77
|
+
let _ = sender.send(AppMsg::ParentGone);
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
|
|
@@ -121,6 +137,22 @@ fn consume_line(sender: &relm4::Sender<AppMsg>, line: &str) {
|
|
|
121
137
|
.to_owned();
|
|
122
138
|
let _ = sender.send(AppMsg::ActivationResult(card_id, ok, message));
|
|
123
139
|
}
|
|
140
|
+
Some("jump-result") => {
|
|
141
|
+
let ok = value
|
|
142
|
+
.get("ok")
|
|
143
|
+
.and_then(|item| item.as_bool())
|
|
144
|
+
.unwrap_or(false);
|
|
145
|
+
let message = value
|
|
146
|
+
.get("message")
|
|
147
|
+
.and_then(|item| item.as_str())
|
|
148
|
+
.unwrap_or(if ok { "Jumped." } else { "Nothing moved." })
|
|
149
|
+
.to_owned();
|
|
150
|
+
let window_id = value
|
|
151
|
+
.get("windowId")
|
|
152
|
+
.and_then(|item| item.as_i64())
|
|
153
|
+
.unwrap_or(-1);
|
|
154
|
+
let _ = sender.send(AppMsg::JumpResult(window_id, ok, message));
|
|
155
|
+
}
|
|
124
156
|
Some(kind) => {
|
|
125
157
|
let _ = sender.send(AppMsg::InputError(format!(
|
|
126
158
|
"unsupported panel message: {kind}"
|
|
@@ -265,3 +265,88 @@ window {
|
|
|
265
265
|
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
266
266
|
font-size: 8px;
|
|
267
267
|
}
|
|
268
|
+
|
|
269
|
+
/*
|
|
270
|
+
* The brand block's window list: every agent window on every workspace, as its card's first line
|
|
271
|
+
* and its Niri window number. The number is the thing you type, so it carries the accent.
|
|
272
|
+
*/
|
|
273
|
+
.directory {
|
|
274
|
+
border-top: 1px solid alpha(@pi_foreground, 0.12);
|
|
275
|
+
padding-top: 7px;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.jump-line {
|
|
279
|
+
color: @pi_accent;
|
|
280
|
+
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
281
|
+
font-size: 10px;
|
|
282
|
+
font-weight: 600;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
|
|
286
|
+
.directory-scroller,
|
|
287
|
+
.directory-scroller > viewport {
|
|
288
|
+
background: transparent;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/* Same as the card row: scrolls by wheel or touchpad without a visible bar. */
|
|
292
|
+
/* biome-ignore lint/correctness/noUnknownTypeSelector: GTK CSS uses widget node names. */
|
|
293
|
+
.directory-scroller scrollbar {
|
|
294
|
+
opacity: 0;
|
|
295
|
+
min-width: 0;
|
|
296
|
+
min-height: 0;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.directory-header {
|
|
300
|
+
color: alpha(@pi_foreground, 0.5);
|
|
301
|
+
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
302
|
+
font-size: 8px;
|
|
303
|
+
letter-spacing: 1px;
|
|
304
|
+
margin-top: 5px;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.directory-row {
|
|
308
|
+
background: transparent;
|
|
309
|
+
border: 1px solid transparent;
|
|
310
|
+
border-radius: 6px;
|
|
311
|
+
color: @pi_foreground;
|
|
312
|
+
min-height: 0;
|
|
313
|
+
padding: 2px 6px;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.directory-row:hover,
|
|
317
|
+
.directory-row:focus-visible {
|
|
318
|
+
background: mix(@pi_background, @pi_foreground, 0.1);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* The window you are in: the same accent ring as its card. */
|
|
322
|
+
.directory-row.current {
|
|
323
|
+
border-color: @pi_accent;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.directory-label {
|
|
327
|
+
font-size: 11px;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.directory-id {
|
|
331
|
+
color: @pi_accent;
|
|
332
|
+
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
333
|
+
font-size: 10px;
|
|
334
|
+
font-weight: 600;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.directory-empty {
|
|
338
|
+
color: alpha(@pi_foreground, 0.5);
|
|
339
|
+
font-size: 10px;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* Which workspace a row's window is on; quieter than the number you type. */
|
|
343
|
+
.directory-workspace {
|
|
344
|
+
color: alpha(@pi_foreground, 0.5);
|
|
345
|
+
font-family: "IBM Plex Mono", "JetBrains Mono", monospace;
|
|
346
|
+
font-size: 9px;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* The row Enter would open: a typed name always shows where it leads. */
|
|
350
|
+
.directory-row.chosen {
|
|
351
|
+
background: alpha(@pi_accent, 0.16);
|
|
352
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tryinget/pi-activity-strip",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "pi extension package for activity-strip workflows in monorepo runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -92,9 +92,8 @@
|
|
|
92
92
|
"@earendil-works/pi-ai": "0.84.4",
|
|
93
93
|
"@earendil-works/pi-coding-agent": "0.84.4",
|
|
94
94
|
"@types/node": "22.14.0",
|
|
95
|
-
"@typescript/native-preview": "7.0.0-dev.20260417.1",
|
|
96
95
|
"typebox": "1.3.7",
|
|
97
|
-
"typescript": "
|
|
96
|
+
"typescript": "7.0.2"
|
|
98
97
|
},
|
|
99
98
|
"overrides": {
|
|
100
99
|
"fast-xml-parser": "5.7.0"
|
|
@@ -99,6 +99,7 @@ export interface ActivityStripRuntimeStatus {
|
|
|
99
99
|
clickThrough?: boolean;
|
|
100
100
|
panelMoveCount?: number;
|
|
101
101
|
panelActivationCount?: number;
|
|
102
|
+
panelJumpCount?: number;
|
|
102
103
|
panelRestartCount?: number;
|
|
103
104
|
lastMovedCardId?: string | null;
|
|
104
105
|
warnings?: string[];
|