Compare commits
7 Commits
ce6acec299
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| eabce75e5f | |||
| 0bf070131d | |||
| ad8ebcaa71 | |||
| ec9a9a8892 | |||
| 75d18cf494 | |||
| 7d2491ea10 | |||
| 9226224507 |
@@ -1,6 +1,6 @@
|
|||||||
# socktop-swipe
|
# socktop-swipe
|
||||||
|
|
||||||
A rack-mounted touchscreen you swipe through to see what your machines are doing.
|
A rack-mounted touchscreen swipable display that monitors your servers via socktop and other status tuis.
|
||||||
|
|
||||||
Terminal dashboards are arranged in a grid. Swipe left and right to move along a
|
Terminal dashboards are arranged in a grid. Swipe left and right to move along a
|
||||||
row; swipe up and down to move between rows. A group of hosts is one place in
|
row; swipe up and down to move between rows. A group of hosts is one place in
|
||||||
@@ -45,12 +45,12 @@ you left.
|
|||||||
|
|
||||||
## Hardware
|
## Hardware
|
||||||
|
|
||||||
| Part | What was used | Notes |
|
| Part | What was used | Status |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB) | Debian 11, i3 on X11 |
|
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB), Debian 11, i3 on X11 | in daily use |
|
||||||
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB) | ~$35 used; 8/16 GB eMMC, DP out |
|
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB), 8/16 GB eMMC, DP out | not yet tried |
|
||||||
| Panel | GeeekPi 9" 1280x720 3U touchscreen | ILITEK USB touch |
|
| Panel | GeeekPi 9" 1280x720 3U touchscreen, ILITEK USB touch | in daily use |
|
||||||
| Mounting | [`cad/`](cad) — 19" rack adapter | 3D-print or laser-cut aluminium |
|
| Mounting | [`cad/`](cad) — 19" rack adapter | printed and in the rack |
|
||||||
|
|
||||||
Any Linux box with a USB or DSI touchscreen will do. It is deliberately modest
|
Any Linux box with a USB or DSI touchscreen will do. It is deliberately modest
|
||||||
hardware: the whole point is a display that idles at a few watts.
|
hardware: the whole point is a display that idles at a few watts.
|
||||||
@@ -83,8 +83,13 @@ if there is no terminal at all it takes the defaults and says so. `--yes` skips
|
|||||||
the questions.
|
the questions.
|
||||||
|
|
||||||
There are no prebuilt binaries yet — it builds from source, so a Rust toolchain
|
There are no prebuilt binaries yet — it builds from source, so a Rust toolchain
|
||||||
is installed if you do not have one. Budget about 1.8 GB and, on an Atom, twenty
|
is installed if you do not have one.
|
||||||
minutes or so.
|
|
||||||
|
Measured on the LattePanda (Atom x5-Z8350, 4 cores, 1.9 GB RAM, no swap), with a
|
||||||
|
toolchain already present: **108 seconds**, peaking at 1.1 GB of the 1.9 GB and
|
||||||
|
leaving a 103 MB build directory. The 946 KB binary is the only thing installed.
|
||||||
|
Add roughly 1.2 GB and a few minutes if rustup has to be fetched too. The
|
||||||
|
installer checks free space first and tells you if it will not fit.
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>From a checkout instead</summary>
|
<summary>From a checkout instead</summary>
|
||||||
|
|||||||
+4
-3
@@ -170,8 +170,9 @@ if ! have cargo; then
|
|||||||
NEED_RUSTUP=yes
|
NEED_RUSTUP=yes
|
||||||
COST_MB=$((COST_MB + 1200))
|
COST_MB=$((COST_MB + 1200))
|
||||||
fi
|
fi
|
||||||
# The build itself: a debug-free release build of this crate and its deps.
|
# The build itself. Measured on an Atom x5-Z8350: a 103 MB target directory,
|
||||||
COST_MB=$((COST_MB + 600))
|
# 108 seconds, peaking at 1.1 GB of RAM. 250 leaves headroom.
|
||||||
|
COST_MB=$((COST_MB + 250))
|
||||||
|
|
||||||
if [ -z "$TERMINAL" ]; then
|
if [ -z "$TERMINAL" ]; then
|
||||||
warn "no terminal emulator found. socktop-swipe can attach in an existing"
|
warn "no terminal emulator found. socktop-swipe can attach in an existing"
|
||||||
@@ -190,7 +191,7 @@ fi
|
|||||||
step "This will"
|
step "This will"
|
||||||
if [ -n "$NEED_PKGS" ]; then say " install packages:$NEED_PKGS"; fi
|
if [ -n "$NEED_PKGS" ]; then say " install packages:$NEED_PKGS"; fi
|
||||||
if [ "$NEED_RUSTUP" = yes ]; then say " install the Rust toolchain via rustup (~1.2 GB)"; fi
|
if [ "$NEED_RUSTUP" = yes ]; then say " install the Rust toolchain via rustup (~1.2 GB)"; fi
|
||||||
say " build socktop-swipe from source (~600 MB of build artifacts)"
|
say " build socktop-swipe from source (~100 MB of build artifacts)"
|
||||||
say " install the binary to $BIN"
|
say " install the binary to $BIN"
|
||||||
say " write a starter config to $CONF"
|
say " write a starter config to $CONF"
|
||||||
say " ...then ask about the touch device, autostart, autologin and blanking."
|
say " ...then ask about the touch device, autostart, autologin and blanking."
|
||||||
|
|||||||
@@ -103,3 +103,19 @@ adapter for the panel; roughly 3-4 W idle, 101x101x28 mm, VESA holes.
|
|||||||
|
|
||||||
The eMMC is why the installer checks free space before starting a build: a Rust
|
The eMMC is why the installer checks free space before starting a build: a Rust
|
||||||
toolchain plus a target directory is around 1.8 GB.
|
toolchain plus a target directory is around 1.8 GB.
|
||||||
|
|
||||||
|
## Never log out of a keyboard-less wall display
|
||||||
|
|
||||||
|
lightdm autologin fires when a **seat starts**, not after a logout. `i3-msg exit`
|
||||||
|
therefore drops the display to the greeter and leaves it there — and the rack
|
||||||
|
panel has no keyboard, so nobody can log back in at the machine. Recovering it
|
||||||
|
needs `sudo systemctl restart lightdm` or a reboot, and the LattePanda has no
|
||||||
|
passwordless sudo.
|
||||||
|
|
||||||
|
So: **`i3-msg exit` does not test the autostart, it only strands the box.** The
|
||||||
|
only honest test of the boot path is an actual reboot.
|
||||||
|
|
||||||
|
Related trap when handing over a root command: `ssh host 'sudo …'` allocates no
|
||||||
|
TTY, so sudo has nowhere to prompt and fails. Use `ssh -t`. Chaining with `&&`
|
||||||
|
then swallows the rest of the line, which is how a recovery command silently did
|
||||||
|
nothing at all. Use `;` for recovery steps that must run regardless.
|
||||||
|
|||||||
+6
-3
@@ -1,8 +1,11 @@
|
|||||||
# socktop-swipe v2 — release plan
|
# socktop-swipe v2 — release plan
|
||||||
|
|
||||||
Status: **implemented**, 2026-09-09. Milestones 1-7 are done on branch `v2-rust`;
|
Status: **implemented and in service**, 2026-09-09. Milestones 1-7 done on branch
|
||||||
milestone 8 (the Wyse 3040 validation) is the remaining acceptance gate. Where the
|
`v2-rust` (PR #1); the LattePanda rack display has been running v2 since, and
|
||||||
built thing differs from this plan, `notes/DESIGN.md` is authoritative.
|
passed a physical swipe test and a cold boot. Milestone 8, the Wyse 3040 install
|
||||||
|
following only the README, is the remaining acceptance gate.
|
||||||
|
|
||||||
|
Where the built thing differs from this plan, `notes/DESIGN.md` is authoritative.
|
||||||
|
|
||||||
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
|
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
|
||||||
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
|
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
|
||||||
|
|||||||
+13
-2
@@ -5,8 +5,19 @@
|
|||||||
- [ ] **Wyse 3040 validation.** Debian minimal to working rack display,
|
- [ ] **Wyse 3040 validation.** Debian minimal to working rack display,
|
||||||
following only the README. Every stumble is a README fix, then re-run.
|
following only the README. Every stumble is a README fix, then re-run.
|
||||||
This is the acceptance gate, not a nice-to-have.
|
This is the acceptance gate, not a nice-to-have.
|
||||||
- [ ] Switch the LattePanda over from v1. Its v2 config path and session name
|
- [x] **LattePanda switched over 2026-09-09.** v2 binary at `~/.local/bin`
|
||||||
differ, so both can be installed side by side while testing.
|
(no passwordless sudo on that box), config at
|
||||||
|
`~/.config/socktop-swipe/config.yaml`, i3 autostart replaced with a single
|
||||||
|
`socktop-swipe run`. All four screens verified live from the framebuffer.
|
||||||
|
Build there: 108 s, peak 1.1 GB of 1.9 GB, 103 MB target dir.
|
||||||
|
- [ ] Remove the v1 leftovers in `/usr/local/bin` on the LattePanda -- needs root:
|
||||||
|
`sudo rm -f /usr/local/bin/socktop-rack /usr/local/bin/socktop-swipe /usr/local/bin/socktop-gestures /usr/local/etc/socktop-swipe.env`
|
||||||
|
- [x] **Physically swipe-tested 2026-09-09** -- and it found a real bug the unit
|
||||||
|
tests could not: every swipe classified as up-to-down, because a contact's
|
||||||
|
start position was captured from the X event alone and recorded Y as zero.
|
||||||
|
Fixed, with protocol-B decoding tests. QA passed on the retest.
|
||||||
|
- [x] **Cold boot verified 2026-09-09.** Autologin, i3, and `socktop-swipe run`
|
||||||
|
all come up unattended.
|
||||||
- [ ] Add the tested-hardware table to the README once the Wyse is done.
|
- [ ] Add the tested-hardware table to the README once the Wyse is done.
|
||||||
- [ ] Decide on the position indicator (`indicator: true`). Implemented; keep or
|
- [ ] Decide on the position indicator (`indicator: true`). Implemented; keep or
|
||||||
cut based on whether it actually helps on the wall.
|
cut based on whether it actually helps on the wall.
|
||||||
|
|||||||
+265
-44
@@ -24,13 +24,120 @@ pub struct Swipe {
|
|||||||
pub fingers: usize,
|
pub fingers: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
/// One contact's journey.
|
||||||
|
///
|
||||||
|
/// The start position is per axis and `Option`, NOT a `(0, 0)` sentinel. X and Y
|
||||||
|
/// arrive as SEPARATE events, so a contact's opening frame is `POSITION_X` then
|
||||||
|
/// `POSITION_Y`: capturing "the start" on the first of those records a Y of
|
||||||
|
/// zero, and every later comparison then measures from the top edge of the panel
|
||||||
|
/// rather than from the finger. That made `dy` enormous and positive, so every
|
||||||
|
/// swipe -- horizontal ones included -- classified as up-to-down.
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
struct Slot {
|
struct Slot {
|
||||||
start: (i32, i32),
|
start_x: Option<i32>,
|
||||||
|
start_y: Option<i32>,
|
||||||
last: (i32, i32),
|
last: (i32, i32),
|
||||||
active: bool,
|
active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Slot {
|
||||||
|
/// An axis that never reported a position contributes no displacement.
|
||||||
|
fn track(&self) -> Track {
|
||||||
|
(
|
||||||
|
(
|
||||||
|
self.start_x.unwrap_or(self.last.0),
|
||||||
|
self.start_y.unwrap_or(self.last.1),
|
||||||
|
),
|
||||||
|
self.last,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The parts of a multitouch protocol-B stream we care about. Kept separate from
|
||||||
|
/// evdev's own types so the slot bookkeeping is testable without a device --
|
||||||
|
/// which is precisely the code the above bug lived in, untested.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Touched {
|
||||||
|
/// `ABS_MT_SLOT`: subsequent events apply to this slot.
|
||||||
|
Slot(i32),
|
||||||
|
/// `ABS_MT_TRACKING_ID`: a new contact when >= 0, a lift when -1.
|
||||||
|
TrackingId(i32),
|
||||||
|
X(i32),
|
||||||
|
Y(i32),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slot bookkeeping for multitouch protocol B.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct SlotTracker {
|
||||||
|
slots: HashMap<i32, Slot>,
|
||||||
|
current: i32,
|
||||||
|
peak: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SlotTracker {
|
||||||
|
pub fn feed(&mut self, ev: Touched) {
|
||||||
|
match ev {
|
||||||
|
Touched::Slot(n) => self.current = n,
|
||||||
|
Touched::TrackingId(id) => {
|
||||||
|
if id < 0 {
|
||||||
|
if let Some(s) = self.slots.get_mut(&self.current) {
|
||||||
|
s.active = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.slots.insert(
|
||||||
|
self.current,
|
||||||
|
Slot {
|
||||||
|
active: true,
|
||||||
|
..Slot::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
self.peak = self.peak.max(self.active());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Touched::X(x) => {
|
||||||
|
let s = self.slot();
|
||||||
|
s.last.0 = x;
|
||||||
|
s.start_x.get_or_insert(x);
|
||||||
|
}
|
||||||
|
Touched::Y(y) => {
|
||||||
|
let s = self.slot();
|
||||||
|
s.last.1 = y;
|
||||||
|
s.start_y.get_or_insert(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(&mut self) -> &mut Slot {
|
||||||
|
self.slots.entry(self.current).or_insert(Slot {
|
||||||
|
active: true,
|
||||||
|
..Slot::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active(&self) -> usize {
|
||||||
|
self.slots.values().filter(|s| s.active).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A gesture is over when every contact seen has lifted.
|
||||||
|
pub fn complete(&self) -> bool {
|
||||||
|
!self.slots.is_empty() && self.active() == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tracks(&self) -> Vec<Track> {
|
||||||
|
self.slots.values().map(Slot::track).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peak simultaneous contacts, which is what `touch.fingers` matches.
|
||||||
|
pub fn peak(&self) -> usize {
|
||||||
|
self.peak
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.slots.clear();
|
||||||
|
self.peak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Touchpanel {
|
pub struct Touchpanel {
|
||||||
device: Device,
|
device: Device,
|
||||||
cfg: Touch,
|
cfg: Touch,
|
||||||
@@ -129,53 +236,36 @@ impl Touchpanel {
|
|||||||
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
||||||
/// including rejected ones, and stops when it returns `false`.
|
/// including rejected ones, and stops when it returns `false`.
|
||||||
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
||||||
let mut slots: HashMap<i32, Slot> = HashMap::new();
|
let mut tracker = SlotTracker::default();
|
||||||
let mut current: i32 = 0;
|
|
||||||
let mut peak: usize = 0;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
for ev in self.device.fetch_events().context("reading touch events")? {
|
for ev in self.device.fetch_events().context("reading touch events")? {
|
||||||
match ev.kind() {
|
let touched = match ev.kind() {
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
||||||
current = ev.value();
|
Touched::Slot(ev.value())
|
||||||
}
|
}
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
||||||
if ev.value() < 0 {
|
Touched::TrackingId(ev.value())
|
||||||
if let Some(s) = slots.get_mut(¤t) {
|
|
||||||
s.active = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slots.insert(
|
|
||||||
current,
|
|
||||||
Slot {
|
|
||||||
start: (0, 0),
|
|
||||||
last: (0, 0),
|
|
||||||
active: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
peak = peak.max(slots.values().filter(|s| s.active).count());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
||||||
update(&mut slots, current, |p| p.0 = ev.value());
|
Touched::X(ev.value())
|
||||||
}
|
}
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
||||||
update(&mut slots, current, |p| p.1 = ev.value());
|
Touched::Y(ev.value())
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => continue,
|
||||||
}
|
};
|
||||||
|
tracker.feed(touched);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The gesture ends when the last contact lifts.
|
// The gesture ends when the last contact lifts.
|
||||||
if !slots.is_empty() && slots.values().all(|s| !s.active) {
|
if tracker.complete() {
|
||||||
let tracks: Vec<Track> = slots.values().map(|s| (s.start, s.last)).collect();
|
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
|
||||||
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
|
|
||||||
if !on_event(ev) {
|
if !on_event(ev) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slots.clear();
|
tracker.reset();
|
||||||
peak = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,19 +345,6 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(slots: &mut HashMap<i32, Slot>, current: i32, f: impl Fn(&mut (i32, i32))) {
|
|
||||||
let slot = slots.entry(current).or_insert(Slot {
|
|
||||||
start: (0, 0),
|
|
||||||
last: (0, 0),
|
|
||||||
active: true,
|
|
||||||
});
|
|
||||||
f(&mut slot.last);
|
|
||||||
// The first position report after a contact begins is also its origin.
|
|
||||||
if slot.start == (0, 0) {
|
|
||||||
slot.start = slot.last;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Candidate touchscreens, for `doctor --list` and the installer.
|
/// Candidate touchscreens, for `doctor --list` and the installer.
|
||||||
pub fn list_touchscreens() -> Vec<(String, String)> {
|
pub fn list_touchscreens() -> Vec<(String, String)> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
@@ -422,6 +499,150 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- slot tracking: real protocol-B event streams -----------------------
|
||||||
|
//
|
||||||
|
// These exist because the classifier tests above build Track tuples by hand
|
||||||
|
// and so never exercised the decoding. The bug that shipped to the rack
|
||||||
|
// display lived exactly here: every swipe came out as up-to-down, and the
|
||||||
|
// display got stuck on the bottom row because "down" from there is a no-op.
|
||||||
|
|
||||||
|
/// One contact moving from `from` to `to`, reported the way the kernel does:
|
||||||
|
/// tracking id, then X and Y as separate events, then a lift.
|
||||||
|
fn contact(t: &mut SlotTracker, slot: i32, from: (i32, i32), to: (i32, i32), steps: i32) {
|
||||||
|
t.feed(Touched::Slot(slot));
|
||||||
|
t.feed(Touched::TrackingId(slot + 1));
|
||||||
|
for i in 0..=steps {
|
||||||
|
let x = from.0 + (to.0 - from.0) * i / steps;
|
||||||
|
let y = from.1 + (to.1 - from.1) * i / steps;
|
||||||
|
t.feed(Touched::Slot(slot));
|
||||||
|
t.feed(Touched::X(x));
|
||||||
|
t.feed(Touched::Y(y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lift(t: &mut SlotTracker, slot: i32) {
|
||||||
|
t.feed(Touched::Slot(slot));
|
||||||
|
t.feed(Touched::TrackingId(-1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_horizontal_swipe_low_on_the_panel_is_not_read_as_downward() {
|
||||||
|
// THE REGRESSION. A right-to-left swipe at y=360 on a 720-tall panel.
|
||||||
|
// With a (0,0) start sentinel, Y was captured as 0 and dy became +360,
|
||||||
|
// dwarfing dx and classifying this as UD.
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
contact(&mut t, 0, (900, 360), (700, 362), 10);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
assert!(t.complete());
|
||||||
|
|
||||||
|
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
||||||
|
Some(Event::Swipe(s)) => {
|
||||||
|
assert_eq!(
|
||||||
|
s.direction,
|
||||||
|
Direction::RL,
|
||||||
|
"a left swipe must not read as down"
|
||||||
|
);
|
||||||
|
assert_eq!(s.fingers, 1);
|
||||||
|
}
|
||||||
|
other => panic!("expected an RL swipe, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn start_is_captured_per_axis() {
|
||||||
|
// X arrives before Y, so a start captured on the first event alone would
|
||||||
|
// record y=0 and report the finger travelling the height of the panel.
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
t.feed(Touched::Slot(0));
|
||||||
|
t.feed(Touched::TrackingId(1));
|
||||||
|
t.feed(Touched::X(900));
|
||||||
|
t.feed(Touched::Y(360));
|
||||||
|
t.feed(Touched::X(700));
|
||||||
|
t.feed(Touched::Y(360));
|
||||||
|
lift(&mut t, 0);
|
||||||
|
assert_eq!(t.tracks(), vec![((900, 360), (700, 360))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn all_four_directions_survive_decoding() {
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
// Centre of a 1280x720 panel, 200px each way.
|
||||||
|
for (to, want) in [
|
||||||
|
((440, 360), Direction::RL),
|
||||||
|
((840, 360), Direction::LR),
|
||||||
|
((640, 160), Direction::DU),
|
||||||
|
((640, 560), Direction::UD),
|
||||||
|
] {
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
contact(&mut t, 0, (640, 360), to, 10);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
match classify(&cfg, &t.tracks(), t.peak()) {
|
||||||
|
Some(Event::Swipe(s)) => assert_eq!(s.direction, want, "moving to {to:?}"),
|
||||||
|
other => panic!("moving to {to:?}: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ghost_contacts_decode_as_one_swipe_of_the_right_length() {
|
||||||
|
// The ILITEK panel reports one finger as two or three contacts.
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
contact(&mut t, 0, (900, 300), (700, 300), 8);
|
||||||
|
contact(&mut t, 1, (903, 305), (703, 305), 8);
|
||||||
|
contact(&mut t, 2, (897, 295), (697, 295), 8);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
lift(&mut t, 1);
|
||||||
|
lift(&mut t, 2);
|
||||||
|
|
||||||
|
assert_eq!(t.peak(), 3, "peak contacts drive the touch.fingers match");
|
||||||
|
match classify(&touch(150, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
||||||
|
Some(Event::Swipe(s)) => {
|
||||||
|
assert_eq!(s.direction, Direction::RL);
|
||||||
|
assert_eq!(s.fingers, 3);
|
||||||
|
}
|
||||||
|
other => panic!("expected one RL swipe: {other:?}"),
|
||||||
|
}
|
||||||
|
// 200px of travel, not 600.
|
||||||
|
match classify(&touch(250, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
||||||
|
Some(Event::Discarded(_, Rejected::TooShort { travel, .. })) => {
|
||||||
|
assert!((travel - 200.0).abs() < 2.0, "travel was {travel}");
|
||||||
|
}
|
||||||
|
other => panic!("expected 200px of travel: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gesture_is_only_complete_once_every_contact_lifts() {
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
assert!(!t.complete(), "nothing has been touched yet");
|
||||||
|
contact(&mut t, 0, (900, 300), (800, 300), 4);
|
||||||
|
assert!(!t.complete(), "still down");
|
||||||
|
contact(&mut t, 1, (500, 300), (400, 300), 4);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
assert!(!t.complete(), "one contact is still down");
|
||||||
|
lift(&mut t, 1);
|
||||||
|
assert!(t.complete());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reset_clears_the_previous_gesture() {
|
||||||
|
let mut t = SlotTracker::default();
|
||||||
|
contact(&mut t, 0, (900, 300), (700, 300), 4);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
t.reset();
|
||||||
|
assert!(!t.complete());
|
||||||
|
assert_eq!(t.peak(), 0);
|
||||||
|
assert!(t.tracks().is_empty());
|
||||||
|
|
||||||
|
// A second swipe must measure from its own origin, not the first one's.
|
||||||
|
contact(&mut t, 0, (300, 300), (500, 300), 4);
|
||||||
|
lift(&mut t, 0);
|
||||||
|
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
||||||
|
Some(Event::Swipe(s)) => assert_eq!(s.direction, Direction::LR),
|
||||||
|
other => panic!("{other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
||||||
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
||||||
|
|||||||
+34
-3
@@ -228,7 +228,10 @@ fn run(cfg: &Config, no_touch: bool) -> Result<()> {
|
|||||||
Some(term) => {
|
Some(term) => {
|
||||||
let mut argv = shell_words::split(term)
|
let mut argv = shell_words::split(term)
|
||||||
.with_context(|| format!("cannot parse terminal: {term}"))?;
|
.with_context(|| format!("cannot parse terminal: {term}"))?;
|
||||||
let prog = argv.remove(0);
|
// Same tilde expansion as `binaries:`. A window manager's PATH
|
||||||
|
// rarely includes ~/.cargo/bin, so a full path is the usual answer
|
||||||
|
// here and it should not have to be spelled out longhand.
|
||||||
|
let prog = config::expand_tilde(&argv.remove(0));
|
||||||
Command::new(&prog)
|
Command::new(&prog)
|
||||||
.args(argv)
|
.args(argv)
|
||||||
.arg("-e")
|
.arg("-e")
|
||||||
@@ -250,7 +253,16 @@ fn run(cfg: &Config, no_touch: bool) -> Result<()> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if !no_touch {
|
if !no_touch {
|
||||||
spawn_panel(cfg, tx)?;
|
// Deliberately not fatal. The dashboard is already on the wall by this
|
||||||
|
// point; exiting because the panel is missing would replace a display
|
||||||
|
// you cannot swipe with no display at all. Say so loudly and carry on
|
||||||
|
// serving the control socket, which is still a way to drive it.
|
||||||
|
if let Err(e) = spawn_panel(cfg, tx) {
|
||||||
|
eprintln!("socktop-swipe: touch gestures are NOT active: {e:#}");
|
||||||
|
eprintln!(
|
||||||
|
"socktop-swipe: the dashboard is up; drive it with `socktop-swipe forward` etc."
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
drive(&tmux, &mut grid, rx)
|
drive(&tmux, &mut grid, rx)
|
||||||
}
|
}
|
||||||
@@ -278,11 +290,30 @@ fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
|
|||||||
drive(&tmux, &mut grid, rx)
|
drive(&tmux, &mut grid, rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open the touch panel, retrying briefly.
|
||||||
|
///
|
||||||
|
/// At boot the autostart can win the race against USB enumeration, so the
|
||||||
|
/// device is simply not there yet. Ten seconds covers that without making a
|
||||||
|
/// genuinely wrong device path take ten seconds to report.
|
||||||
|
fn open_panel(cfg: &Config) -> Result<Touchpanel> {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||||
|
loop {
|
||||||
|
match Touchpanel::open(&cfg.touch) {
|
||||||
|
Ok(p) => return Ok(p),
|
||||||
|
Err(e) if std::time::Instant::now() < deadline => {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||||
|
let _ = e;
|
||||||
|
}
|
||||||
|
Err(e) => return Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Read the panel on its own thread. Opening it here rather than in the thread
|
/// Read the panel on its own thread. Opening it here rather than in the thread
|
||||||
/// keeps a permission or grab failure on the main path, where it can be
|
/// keeps a permission or grab failure on the main path, where it can be
|
||||||
/// reported properly instead of vanishing into a detached thread.
|
/// reported properly instead of vanishing into a detached thread.
|
||||||
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
|
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
|
||||||
let mut panel = Touchpanel::open(&cfg.touch)?;
|
let mut panel = open_panel(cfg)?;
|
||||||
let gestures = cfg.gestures.clone();
|
let gestures = cfg.gestures.clone();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let result = panel.run(|ev| {
|
let result = panel.run(|ev| {
|
||||||
|
|||||||
+39
-11
@@ -80,6 +80,19 @@ impl Tmux {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Name a pane for the border.
|
||||||
|
///
|
||||||
|
/// NOT `select-pane -T`, which sets the pane *title* -- a value the program
|
||||||
|
/// running in the pane can overwrite at any time with an OSC escape. unifly
|
||||||
|
/// probes for Kitty graphics support on startup and tmux consumed part of
|
||||||
|
/// that probe as a title change, so the border read
|
||||||
|
/// `Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA` instead of "unifly". A pane-scoped user
|
||||||
|
/// option is ours alone and no escape sequence can reach it.
|
||||||
|
fn label(&self, pane: &str, title: &str) -> Result<()> {
|
||||||
|
self.run(&["set-option", "-p", "-t", pane, "@socktop_label", title])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn zoomed(&self, window: &str) -> Result<bool> {
|
fn zoomed(&self, window: &str) -> Result<bool> {
|
||||||
Ok(self.run(&[
|
Ok(self.run(&[
|
||||||
"display-message",
|
"display-message",
|
||||||
@@ -122,8 +135,30 @@ impl Tmux {
|
|||||||
])?
|
])?
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// These are all WINDOW options, and new windows do not inherit them, so
|
||||||
|
// they must be set per window rather than once on the session. Setting
|
||||||
|
// a window option with `set-option -t <session>` silently applies it to
|
||||||
|
// whichever window happens to be current -- the same trap that makes
|
||||||
|
// `remain-on-exit` useless here, and the reason v1's pane borders only
|
||||||
|
// ever appeared on one of its windows.
|
||||||
|
//
|
||||||
|
// allow-rename: a window created with -n has automatic-rename off, but a
|
||||||
|
// program can still rename it with an escape sequence.
|
||||||
|
for (opt, val) in [
|
||||||
|
("allow-rename", "off"),
|
||||||
|
("pane-border-status", "top"),
|
||||||
|
(
|
||||||
|
"pane-border-format",
|
||||||
|
// Fall back to the pane title if the label is somehow unset, so
|
||||||
|
// a pane is never nameless.
|
||||||
|
" #{?#{@socktop_label},#{@socktop_label},#{pane_title}} ",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
self.run(&["set-option", "-w", "-t", &window, opt, val])?;
|
||||||
|
}
|
||||||
|
|
||||||
let first_pane = self.run(&["display-message", "-p", "-t", &window, "#{pane_id}"])?;
|
let first_pane = self.run(&["display-message", "-p", "-t", &window, "#{pane_id}"])?;
|
||||||
self.run(&["select-pane", "-t", &first_pane, "-T", &cell.panes[0].title])?;
|
self.label(&first_pane, &cell.panes[0].title)?;
|
||||||
let mut panes = vec![first_pane];
|
let mut panes = vec![first_pane];
|
||||||
|
|
||||||
for pane in &cell.panes[1..] {
|
for pane in &cell.panes[1..] {
|
||||||
@@ -138,7 +173,7 @@ impl Tmux {
|
|||||||
"#{pane_id}",
|
"#{pane_id}",
|
||||||
&Self::shell_command(&pane.command),
|
&Self::shell_command(&pane.command),
|
||||||
])?;
|
])?;
|
||||||
self.run(&["select-pane", "-t", &id, "-T", &pane.title])?;
|
self.label(&id, &pane.title)?;
|
||||||
panes.push(id);
|
panes.push(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,16 +299,9 @@ impl Multiplexer for Tmux {
|
|||||||
}
|
}
|
||||||
*self.placed.borrow_mut() = placed;
|
*self.placed.borrow_mut() = placed;
|
||||||
|
|
||||||
|
// Only SESSION options below; window options are set per window in
|
||||||
|
// place(), for the reason given there.
|
||||||
let s = &self.session;
|
let s = &self.session;
|
||||||
self.run(&["set-option", "-t", s, "pane-border-status", "top"])?;
|
|
||||||
self.run(&[
|
|
||||||
"set-option",
|
|
||||||
"-t",
|
|
||||||
s,
|
|
||||||
"pane-border-format",
|
|
||||||
" #{pane_title} ",
|
|
||||||
])?;
|
|
||||||
|
|
||||||
// Mouse mode MUST stay off. With it on, a touch swipe is also delivered
|
// Mouse mode MUST stay off. With it on, a touch swipe is also delivered
|
||||||
// to tmux as a click-drag: dragging across a pane border resizes it and
|
// to tmux as a click-drag: dragging across a pane border resizes it and
|
||||||
// taps reselect panes, both fighting the gesture layer. Exclusive evdev
|
// taps reselect panes, both fighting the gesture layer. Exclusive evdev
|
||||||
|
|||||||
+71
-19
@@ -12,8 +12,6 @@ use socktop_swipe::monitor;
|
|||||||
use socktop_swipe::session::tmux::Tmux;
|
use socktop_swipe::session::tmux::Tmux;
|
||||||
use socktop_swipe::session::Multiplexer;
|
use socktop_swipe::session::Multiplexer;
|
||||||
|
|
||||||
const SESSION: &str = "socktop-swipe-selftest";
|
|
||||||
|
|
||||||
fn have_tmux() -> bool {
|
fn have_tmux() -> bool {
|
||||||
Command::new("tmux")
|
Command::new("tmux")
|
||||||
.arg("-V")
|
.arg("-V")
|
||||||
@@ -29,12 +27,12 @@ fn tmux(args: &[&str]) -> String {
|
|||||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn config() -> Config {
|
fn config(session: &str) -> Config {
|
||||||
// `true` exits at once, which also exercises remain-on-exit keeping the
|
// `true` exits at once, which also exercises remain-on-exit keeping the
|
||||||
// pane addressable afterwards.
|
// pane addressable afterwards.
|
||||||
let yaml = format!(
|
let yaml = format!(
|
||||||
r#"
|
r#"
|
||||||
session: {SESSION}
|
session: {session}
|
||||||
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
|
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
|
||||||
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
|
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
|
||||||
screens:
|
screens:
|
||||||
@@ -56,24 +54,26 @@ screens:
|
|||||||
serde_yaml::from_str(&yaml).expect("test config should parse")
|
serde_yaml::from_str(&yaml).expect("test config should parse")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// (active window name, active pane title, is the window zoomed)
|
/// (active window name, active pane label, is the window zoomed)
|
||||||
fn visible() -> (String, String, bool) {
|
fn visible(session: &str) -> (String, String, bool) {
|
||||||
let s = tmux(&[
|
let s = tmux(&[
|
||||||
"display-message",
|
"display-message",
|
||||||
"-p",
|
"-p",
|
||||||
"-t",
|
"-t",
|
||||||
SESSION,
|
session,
|
||||||
"#{window_name}\t#{pane_title}\t#{window_zoomed_flag}",
|
"#{window_name}\t#{@socktop_label}\t#{window_zoomed_flag}",
|
||||||
]);
|
]);
|
||||||
let f: Vec<&str> = s.split('\t').collect();
|
let f: Vec<&str> = s.split('\t').collect();
|
||||||
(f[0].into(), f[1].into(), f[2] == "1")
|
(f[0].into(), f[1].into(), f[2] == "1")
|
||||||
}
|
}
|
||||||
|
|
||||||
struct Cleanup;
|
/// Each test uses its own session name: cargo runs tests in parallel and they
|
||||||
|
/// would otherwise tear down each other's tmux server state.
|
||||||
|
struct Cleanup(&'static str);
|
||||||
impl Drop for Cleanup {
|
impl Drop for Cleanup {
|
||||||
fn drop(&mut self) {
|
fn drop(&mut self) {
|
||||||
let _ = Command::new("tmux")
|
let _ = Command::new("tmux")
|
||||||
.args(["kill-session", "-t", SESSION])
|
.args(["kill-session", "-t", self.0])
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null())
|
.stderr(Stdio::null())
|
||||||
.status();
|
.status();
|
||||||
@@ -86,9 +86,10 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
eprintln!("skipping: tmux is not installed");
|
eprintln!("skipping: tmux is not installed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _cleanup = Cleanup;
|
const SESSION: &str = "socktop-swipe-selftest-nav";
|
||||||
|
let _cleanup = Cleanup(SESSION);
|
||||||
|
|
||||||
let cfg = config();
|
let cfg = config(SESSION);
|
||||||
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
||||||
let mux = Tmux::new(&cfg.session, false);
|
let mux = Tmux::new(&cfg.session, false);
|
||||||
mux.build(&grid).expect("session should build");
|
mux.build(&grid).expect("session should build");
|
||||||
@@ -108,7 +109,7 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
|
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
|
||||||
let (win, _, zoomed) = visible();
|
let (win, _, zoomed) = visible(SESSION);
|
||||||
assert_eq!(win, "r0c0");
|
assert_eq!(win, "r0c0");
|
||||||
assert!(!zoomed, "the overview must not be zoomed");
|
assert!(!zoomed, "the overview must not be zoomed");
|
||||||
|
|
||||||
@@ -116,7 +117,7 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
for expected in ["alpha", "bravo", "charlie", "delta"] {
|
for expected in ["alpha", "bravo", "charlie", "delta"] {
|
||||||
let pos = grid.apply(Move::Forward);
|
let pos = grid.apply(Move::Forward);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
let (win, title, zoomed) = visible();
|
let (win, title, zoomed) = visible(SESSION);
|
||||||
assert_eq!(win, "r0c0");
|
assert_eq!(win, "r0c0");
|
||||||
assert_eq!(title, expected, "wrong host zoomed");
|
assert_eq!(title, expected, "wrong host zoomed");
|
||||||
assert!(zoomed, "{expected} should be zoomed full-screen");
|
assert!(zoomed, "{expected} should be zoomed full-screen");
|
||||||
@@ -125,7 +126,7 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
// Past the last host, on to the next cell's overview.
|
// Past the last host, on to the next cell's overview.
|
||||||
let pos = grid.apply(Move::Forward);
|
let pos = grid.apply(Move::Forward);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
let (win, _, zoomed) = visible();
|
let (win, _, zoomed) = visible(SESSION);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
win, "r0c5",
|
win, "r0c5",
|
||||||
"0x5 follows 0x0 despite the gap in column numbers"
|
"0x5 follows 0x0 despite the gap in column numbers"
|
||||||
@@ -135,7 +136,7 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
// Back must land on 0x0's LAST host, not its overview.
|
// Back must land on 0x0's LAST host, not its overview.
|
||||||
let pos = grid.apply(Move::Back);
|
let pos = grid.apply(Move::Back);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
let (win, title, zoomed) = visible();
|
let (win, title, zoomed) = visible(SESSION);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(win.as_str(), title.as_str(), zoomed),
|
(win.as_str(), title.as_str(), zoomed),
|
||||||
("r0c0", "delta", true)
|
("r0c0", "delta", true)
|
||||||
@@ -144,14 +145,14 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
// Up to unifly: a single-pane cell, so nothing to zoom.
|
// Up to unifly: a single-pane cell, so nothing to zoom.
|
||||||
let pos = grid.apply(Move::Up);
|
let pos = grid.apply(Move::Up);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
let (win, _, zoomed) = visible();
|
let (win, _, zoomed) = visible(SESSION);
|
||||||
assert_eq!(win, "rm1c0");
|
assert_eq!(win, "rm1c0");
|
||||||
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
|
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
|
||||||
|
|
||||||
// And back down to exactly the host we left.
|
// And back down to exactly the host we left.
|
||||||
let pos = grid.apply(Move::Down);
|
let pos = grid.apply(Move::Down);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
let (win, title, zoomed) = visible();
|
let (win, title, zoomed) = visible(SESSION);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
(win.as_str(), title.as_str(), zoomed),
|
(win.as_str(), title.as_str(), zoomed),
|
||||||
("r0c0", "delta", true),
|
("r0c0", "delta", true),
|
||||||
@@ -161,7 +162,58 @@ fn session_layout_and_navigation_match_the_grid() {
|
|||||||
// Down twice: through row 0 to kuma.
|
// Down twice: through row 0 to kuma.
|
||||||
let pos = grid.apply(Move::Down);
|
let pos = grid.apply(Move::Down);
|
||||||
mux.show(&grid, &pos).unwrap();
|
mux.show(&grid, &pos).unwrap();
|
||||||
assert_eq!(visible().0, "r1c0");
|
assert_eq!(visible(SESSION).0, "r1c0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Window options do not propagate from the session, and new windows do not
|
||||||
|
/// inherit them. Setting one with `set-option -t <session>` quietly applies it
|
||||||
|
/// to whichever window is current, which is how v1 ended up with pane borders
|
||||||
|
/// on only one of its three windows. Assert EVERY window got them.
|
||||||
|
#[test]
|
||||||
|
fn window_options_are_set_on_every_window() {
|
||||||
|
if !have_tmux() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const SESSION: &str = "socktop-swipe-selftest-opts";
|
||||||
|
let _cleanup = Cleanup(SESSION);
|
||||||
|
|
||||||
|
let cfg = config(SESSION);
|
||||||
|
let grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
||||||
|
Tmux::new(&cfg.session, false).build(&grid).unwrap();
|
||||||
|
|
||||||
|
for (window, want_labels) in [
|
||||||
|
("rm1c0", vec!["unifly"]),
|
||||||
|
("r0c0", vec!["alpha", "bravo", "charlie", "delta"]),
|
||||||
|
("r0c5", vec!["echo1", "foxtrot"]),
|
||||||
|
("r1c0", vec!["uptime kuma"]),
|
||||||
|
] {
|
||||||
|
let target = format!("{SESSION}:{window}");
|
||||||
|
for (opt, want) in [("pane-border-status", "top"), ("allow-rename", "off")] {
|
||||||
|
let got = tmux(&["show-options", "-w", "-t", &target, "-v", opt]);
|
||||||
|
assert_eq!(got, want, "{window} is missing the {opt} window option");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tmux(&[
|
||||||
|
"show-options",
|
||||||
|
"-w",
|
||||||
|
"-t",
|
||||||
|
&target,
|
||||||
|
"-v",
|
||||||
|
"pane-border-format"
|
||||||
|
])
|
||||||
|
.contains("@socktop_label"),
|
||||||
|
"{window} is missing the pane-border-format"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Labels live in a pane-scoped user option precisely so the program in
|
||||||
|
// the pane cannot overwrite them with a title escape sequence.
|
||||||
|
let labels = tmux(&["list-panes", "-t", &target, "-F", "#{@socktop_label}"]);
|
||||||
|
assert_eq!(
|
||||||
|
labels.lines().collect::<Vec<_>>(),
|
||||||
|
want_labels,
|
||||||
|
"{window} has the wrong pane labels"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
Reference in New Issue
Block a user