update docs
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "fds-control"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
description = "Native grayscale X11 cartridge control panel"
|
||||
|
||||
[dependencies]
|
||||
clap.workspace = true
|
||||
fds-common = { path = "../fds-common" }
|
||||
libc = "0.2"
|
||||
serde_json = "1"
|
||||
x11rb = "=0.13.2"
|
||||
@@ -0,0 +1,859 @@
|
||||
//! Core X11 drawing keeps the control panel small, static and free of animation.
|
||||
use clap::Parser;
|
||||
use fds_common::{
|
||||
Bay,
|
||||
control::{self, BayState, Request, Response},
|
||||
manifest::Class,
|
||||
};
|
||||
use std::{
|
||||
error::Error,
|
||||
io::{Read, Write},
|
||||
os::{fd::AsRawFd, unix::net::UnixStream},
|
||||
process::{Child, Command},
|
||||
sync::mpsc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use x11rb::{
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
connection::Connection,
|
||||
protocol::{Event, xproto::*},
|
||||
rust_connection::RustConnection,
|
||||
wrapper::ConnectionExt as _,
|
||||
};
|
||||
type Result<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
version,
|
||||
about = "FDS cartridge control panel for X11",
|
||||
after_help = "Select a bay to inspect its cartridge. Run opens a terminal; Eject stops its programs and releases the cartridge. Remove media only after SAFE appears."
|
||||
)]
|
||||
struct Options {
|
||||
/// X display; defaults to DISPLAY.
|
||||
#[arg(long)]
|
||||
display: Option<String>,
|
||||
}
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Focus {
|
||||
Bays,
|
||||
Programs,
|
||||
Run,
|
||||
Eject,
|
||||
Rescan,
|
||||
}
|
||||
impl Focus {
|
||||
fn next(self) -> Self {
|
||||
match self {
|
||||
Self::Bays => Self::Programs,
|
||||
Self::Programs => Self::Run,
|
||||
Self::Run => Self::Eject,
|
||||
Self::Eject => Self::Rescan,
|
||||
Self::Rescan => Self::Bays,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Program {
|
||||
label: String,
|
||||
alias: String,
|
||||
}
|
||||
struct Model {
|
||||
bays: Vec<BayState>,
|
||||
bay: usize,
|
||||
program: usize,
|
||||
focus: Focus,
|
||||
status: String,
|
||||
busy: bool,
|
||||
available: bool,
|
||||
display: String,
|
||||
}
|
||||
impl Model {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
bays: Vec::new(),
|
||||
bay: 0,
|
||||
program: 0,
|
||||
focus: Focus::Bays,
|
||||
status: "Connecting to the cartridge service...".into(),
|
||||
busy: false,
|
||||
available: false,
|
||||
display: std::env::var("DISPLAY").unwrap_or_else(|_| ":0".into()),
|
||||
}
|
||||
}
|
||||
fn selected(&self) -> Option<&BayState> {
|
||||
self.bays
|
||||
.iter()
|
||||
.find(|b| u8::from(b.bay) as usize == self.bay + 1)
|
||||
}
|
||||
fn programs(&self) -> Vec<Program> {
|
||||
let Some(bay) = self.selected() else {
|
||||
return Vec::new();
|
||||
};
|
||||
if bay.state != "mounted_read_only" {
|
||||
return Vec::new();
|
||||
}
|
||||
bay.commands
|
||||
.iter()
|
||||
.map(|command| Program {
|
||||
label: command.selector.clone(),
|
||||
alias: command.alias.clone(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn can_eject(&self) -> bool {
|
||||
self.available
|
||||
&& !self.busy
|
||||
&& self
|
||||
.selected()
|
||||
.is_some_and(|b| b.mount.is_some() && b.state != "protected")
|
||||
}
|
||||
fn can_use_data(&self) -> bool {
|
||||
self.available
|
||||
&& !self.busy
|
||||
&& self.selected().is_some_and(|b| {
|
||||
b.state == "mounted_read_only"
|
||||
&& b.manifest
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.cartridge.class == Class::Data)
|
||||
})
|
||||
}
|
||||
fn can_run(&self) -> bool {
|
||||
self.available && !self.busy && self.program < self.programs().len()
|
||||
}
|
||||
fn select(&mut self, bay: usize) {
|
||||
if self.bay != bay {
|
||||
self.bay = bay;
|
||||
self.program = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All labels are bounded before core X11 text requests. Cartridge names never
|
||||
// become shell source; launches use an argument vector and the validated alias.
|
||||
fn text_bytes(text: &str, max: usize) -> Vec<u8> {
|
||||
let mut bytes: Vec<_> = text
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii() && !c.is_control() {
|
||||
c as u8
|
||||
} else {
|
||||
b'?'
|
||||
}
|
||||
})
|
||||
.take(max + 1)
|
||||
.collect();
|
||||
if bytes.len() > max {
|
||||
bytes.truncate(max);
|
||||
if max >= 3 {
|
||||
bytes[max - 3..].copy_from_slice(b"...");
|
||||
}
|
||||
}
|
||||
bytes
|
||||
}
|
||||
fn state_label(state: &str) -> String {
|
||||
state.replace('_', " ").to_uppercase()
|
||||
}
|
||||
|
||||
struct View {
|
||||
connection: RustConnection,
|
||||
window: Window,
|
||||
gc: Gcontext,
|
||||
black: u32,
|
||||
white: u32,
|
||||
gray: u32,
|
||||
delete: Atom,
|
||||
protocols: Atom,
|
||||
state_atom: Atom,
|
||||
}
|
||||
impl View {
|
||||
fn new(display: Option<&str>) -> Result<Self> {
|
||||
let (connection, index) = x11rb::connect(display)?;
|
||||
let screen = &connection.setup().roots[index];
|
||||
let (black, white) = (screen.black_pixel, screen.white_pixel);
|
||||
let gray = connection
|
||||
.alloc_color(screen.default_colormap, 0xcccc, 0xcccc, 0xcccc)?
|
||||
.reply()?
|
||||
.pixel;
|
||||
let window = connection.generate_id()?;
|
||||
connection
|
||||
.create_window(
|
||||
COPY_DEPTH_FROM_PARENT,
|
||||
window,
|
||||
screen.root,
|
||||
30,
|
||||
30,
|
||||
900,
|
||||
630,
|
||||
2,
|
||||
WindowClass::INPUT_OUTPUT,
|
||||
0,
|
||||
&CreateWindowAux::new()
|
||||
.background_pixel(white)
|
||||
.border_pixel(black)
|
||||
.event_mask(
|
||||
EventMask::EXPOSURE
|
||||
| EventMask::BUTTON_PRESS
|
||||
| EventMask::KEY_PRESS
|
||||
| EventMask::STRUCTURE_NOTIFY,
|
||||
),
|
||||
)?
|
||||
.check()?;
|
||||
connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_NAME,
|
||||
AtomEnum::STRING,
|
||||
b"FDS Control",
|
||||
)?;
|
||||
connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
AtomEnum::WM_CLASS,
|
||||
AtomEnum::STRING,
|
||||
b"fds-control\0FdsControl\0",
|
||||
)?;
|
||||
let protocols = connection
|
||||
.intern_atom(false, b"WM_PROTOCOLS")?
|
||||
.reply()?
|
||||
.atom;
|
||||
let delete = connection
|
||||
.intern_atom(false, b"WM_DELETE_WINDOW")?
|
||||
.reply()?
|
||||
.atom;
|
||||
connection.change_property32(
|
||||
PropMode::REPLACE,
|
||||
window,
|
||||
protocols,
|
||||
AtomEnum::ATOM,
|
||||
&[delete],
|
||||
)?;
|
||||
let state_atom = connection
|
||||
.intern_atom(false, b"_FDS_CONTROL_STATE")?
|
||||
.reply()?
|
||||
.atom;
|
||||
// Fixed dimensions keep the bitmap-font layout readable and predictable.
|
||||
let mut hints = x11rb::properties::WmSizeHints::new();
|
||||
hints.min_size = Some((900, 630));
|
||||
hints.max_size = Some((900, 630));
|
||||
hints.set_normal_hints(&connection, window)?;
|
||||
let font = connection.generate_id()?;
|
||||
if connection
|
||||
.open_font(
|
||||
font,
|
||||
b"-*-terminus-medium-r-normal--16-*-*-*-*-*-iso10646-1",
|
||||
)?
|
||||
.check()
|
||||
.is_err()
|
||||
{
|
||||
connection.open_font(font, b"fixed")?.check()?;
|
||||
}
|
||||
let gc = connection.generate_id()?;
|
||||
connection.create_gc(
|
||||
gc,
|
||||
window,
|
||||
&CreateGCAux::new()
|
||||
.foreground(black)
|
||||
.background(white)
|
||||
.font(font)
|
||||
.graphics_exposures(0),
|
||||
)?;
|
||||
connection.map_window(window)?;
|
||||
connection.flush()?;
|
||||
Ok(Self {
|
||||
connection,
|
||||
window,
|
||||
gc,
|
||||
black,
|
||||
white,
|
||||
gray,
|
||||
delete,
|
||||
protocols,
|
||||
state_atom,
|
||||
})
|
||||
}
|
||||
fn fill(&self, x: i16, y: i16, width: u16, height: u16, color: u32) -> Result<()> {
|
||||
self.connection
|
||||
.change_gc(self.gc, &ChangeGCAux::new().foreground(color))?;
|
||||
self.connection.poly_fill_rectangle(
|
||||
self.window,
|
||||
self.gc,
|
||||
&[Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn border(&self, x: i16, y: i16, width: u16, height: u16) -> Result<()> {
|
||||
self.connection
|
||||
.change_gc(self.gc, &ChangeGCAux::new().foreground(self.black))?;
|
||||
self.connection.poly_rectangle(
|
||||
self.window,
|
||||
self.gc,
|
||||
&[Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn text(&self, x: i16, y: i16, text: &str, max: usize, inverted: bool) -> Result<()> {
|
||||
self.connection.change_gc(
|
||||
self.gc,
|
||||
&ChangeGCAux::new().foreground(if inverted { self.white } else { self.black }),
|
||||
)?;
|
||||
let bytes = text_bytes(text, max.min(254));
|
||||
// poly_text draws only glyphs, preserving the selection/background fill.
|
||||
let mut data = vec![bytes.len() as u8, 0];
|
||||
data.extend(bytes);
|
||||
self.connection
|
||||
.poly_text8(self.window, self.gc, x, y, &data)?;
|
||||
Ok(())
|
||||
}
|
||||
fn button(&self, x: i16, width: u16, label: &str, enabled: bool, focused: bool) -> Result<()> {
|
||||
self.fill(
|
||||
x,
|
||||
498,
|
||||
width,
|
||||
38,
|
||||
if enabled && focused {
|
||||
self.black
|
||||
} else if enabled {
|
||||
self.white
|
||||
} else {
|
||||
self.gray
|
||||
},
|
||||
)?;
|
||||
self.border(x, 498, width, 38)?;
|
||||
self.text(
|
||||
x + 12,
|
||||
522,
|
||||
label,
|
||||
(width as usize - 24) / 8,
|
||||
enabled && focused,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
fn draw(&self, model: &Model) -> Result<()> {
|
||||
// Expose the same visible state to X11 inspection/accessibility tools.
|
||||
let state = serde_json::json!({"bay": model.bay + 1, "state": model.selected().map(|b| &b.state), "busy": model.busy, "available": model.available, "commands": model.programs().len(), "status": model.status});
|
||||
self.connection.change_property8(
|
||||
PropMode::REPLACE,
|
||||
self.window,
|
||||
self.state_atom,
|
||||
AtomEnum::STRING,
|
||||
serde_json::to_string(&state)?.as_bytes(),
|
||||
)?;
|
||||
self.fill(0, 0, 900, 630, self.white)?;
|
||||
self.fill(0, 0, 900, 72, self.black)?;
|
||||
self.text(22, 29, "FDS / CONTROL", 50, true)?;
|
||||
self.text(22, 53, "Cartridges and programs", 70, true)?;
|
||||
self.text(710, 42, "TWELVE BAYS", 22, true)?;
|
||||
self.text(22, 98, "BAY CARTRIDGE / STATE", 37, false)?;
|
||||
for index in 0..12 {
|
||||
let y = 110 + index as i16 * 31;
|
||||
let selected = model.bay == index;
|
||||
self.fill(
|
||||
20,
|
||||
y,
|
||||
300,
|
||||
31,
|
||||
if selected { self.black } else { self.white },
|
||||
)?;
|
||||
self.border(20, y, 300, 31)?;
|
||||
let bay = model
|
||||
.bays
|
||||
.iter()
|
||||
.find(|b| u8::from(b.bay) as usize == index + 1);
|
||||
let label = match bay {
|
||||
Some(b) => {
|
||||
if b.state == "empty" {
|
||||
"Empty".into()
|
||||
} else if b.state == "safe" {
|
||||
"SAFE - remove cartridge".into()
|
||||
} else {
|
||||
b.name.clone().unwrap_or_else(|| state_label(&b.state))
|
||||
}
|
||||
}
|
||||
None => "Unavailable".into(),
|
||||
};
|
||||
self.text(
|
||||
30,
|
||||
y + 21,
|
||||
&format!("{:02} {label}", index + 1),
|
||||
35,
|
||||
selected,
|
||||
)?;
|
||||
}
|
||||
if model.focus == Focus::Bays {
|
||||
self.border(17, 107, 306, 378)?;
|
||||
}
|
||||
self.text(346, 98, &format!("BAY {:02}", model.bay + 1), 60, false)?;
|
||||
if let Some(bay) = model.selected() {
|
||||
self.text(
|
||||
346,
|
||||
130,
|
||||
bay.name.as_deref().unwrap_or("No cartridge"),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
self.text(346, 156, &state_label(&bay.state), 66, false)?;
|
||||
if let Some(manifest) = &bay.manifest {
|
||||
self.text(
|
||||
346,
|
||||
184,
|
||||
&format!("Type: {:?}", manifest.cartridge.class),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
self.text(
|
||||
346,
|
||||
207,
|
||||
&format!("ID: {}", manifest.cartridge.id),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
self.text(
|
||||
346,
|
||||
235,
|
||||
&format!("Running processes: {}", bay.consumers),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
if let Some(detail) = &bay.detail {
|
||||
self.text(346, 260, detail, 66, false)?;
|
||||
}
|
||||
} else {
|
||||
self.text(346, 132, "Waiting for the cartridge service.", 66, false)?;
|
||||
}
|
||||
self.text(346, 292, "PROGRAMS", 66, false)?;
|
||||
let programs = model.programs();
|
||||
let first = model.program.saturating_sub(4);
|
||||
for row in 0..5 {
|
||||
let index = first + row;
|
||||
let y = 304 + row as i16 * 31;
|
||||
let selected = index == model.program && index < programs.len();
|
||||
self.fill(
|
||||
346,
|
||||
y,
|
||||
532,
|
||||
31,
|
||||
if selected { self.black } else { self.white },
|
||||
)?;
|
||||
self.border(346, y, 532, 31)?;
|
||||
if let Some(program) = programs.get(index) {
|
||||
self.text(356, y + 21, &program.label, 64, selected)?;
|
||||
} else if row == 0 {
|
||||
self.text(
|
||||
356,
|
||||
y + 21,
|
||||
"No software commands on this cartridge",
|
||||
64,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
}
|
||||
if model.focus == Focus::Programs {
|
||||
self.border(343, 301, 538, 161)?;
|
||||
}
|
||||
if !programs.is_empty() {
|
||||
self.text(
|
||||
346,
|
||||
482,
|
||||
&format!("Command {} of {}", model.program + 1, programs.len()),
|
||||
66,
|
||||
false,
|
||||
)?;
|
||||
}
|
||||
self.button(
|
||||
346,
|
||||
192,
|
||||
if model.can_use_data() {
|
||||
"Use DATA"
|
||||
} else {
|
||||
"Run in terminal"
|
||||
},
|
||||
model.can_run() || model.can_use_data(),
|
||||
model.focus == Focus::Run,
|
||||
)?;
|
||||
self.button(
|
||||
554,
|
||||
148,
|
||||
"Safe eject",
|
||||
model.can_eject(),
|
||||
model.focus == Focus::Eject,
|
||||
)?;
|
||||
self.button(
|
||||
718,
|
||||
160,
|
||||
"Rescan",
|
||||
!model.busy,
|
||||
model.focus == Focus::Rescan,
|
||||
)?;
|
||||
self.text(22, 517, "Remove media only after SAFE.", 37, false)?;
|
||||
self.fill(20, 552, 858, 44, self.gray)?;
|
||||
self.text(30, 572, &model.status, 104, false)?;
|
||||
self.text(
|
||||
30,
|
||||
589,
|
||||
&model.status.chars().skip(104).collect::<String>(),
|
||||
104,
|
||||
false,
|
||||
)?;
|
||||
self.text(
|
||||
22,
|
||||
619,
|
||||
"Arrows: select Tab: focus Enter: activate R: rescan Esc: close",
|
||||
106,
|
||||
false,
|
||||
)?;
|
||||
self.connection.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
fn keysym(&self, keycode: u8) -> Result<u32> {
|
||||
let mapping = self.connection.get_keyboard_mapping(keycode, 1)?.reply()?;
|
||||
Ok(mapping.keysyms.first().copied().unwrap_or(0))
|
||||
}
|
||||
}
|
||||
|
||||
struct Update {
|
||||
result: fds_common::Result<Response>,
|
||||
action: Option<String>,
|
||||
}
|
||||
struct Worker {
|
||||
request: mpsc::SyncSender<(Request, Option<String>)>,
|
||||
response: mpsc::Receiver<Update>,
|
||||
wake: UnixStream,
|
||||
in_flight: bool,
|
||||
queued: Option<(Request, Option<String>)>,
|
||||
}
|
||||
impl Worker {
|
||||
fn new() -> Result<Self> {
|
||||
let (tx, rx) = mpsc::sync_channel::<(Request, Option<String>)>(1);
|
||||
let (updates, response) = mpsc::channel();
|
||||
let (wake, mut writer) = UnixStream::pair()?;
|
||||
wake.set_nonblocking(true)?;
|
||||
std::thread::spawn(move || {
|
||||
while let Ok((request, action)) = rx.recv() {
|
||||
let result = control::request(&request).and_then(|reply| {
|
||||
let inventory = if matches!(request, Request::Bays) {
|
||||
reply
|
||||
} else {
|
||||
control::request(&Request::Bays)?
|
||||
};
|
||||
if let Request::Eject { bay } = &request {
|
||||
if !inventory.bays.iter().any(|b| b.bay == *bay && matches!(b.state.as_str(), "safe" | "empty")) {
|
||||
return Err(fds_common::Error("Bay changed after eject; inspect its current state before removing media".into()));
|
||||
}
|
||||
}
|
||||
Ok(inventory)
|
||||
});
|
||||
if updates.send(Update { result, action }).is_err()
|
||||
|| writer.write_all(b"R").is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(Self {
|
||||
request: tx,
|
||||
response,
|
||||
wake,
|
||||
in_flight: false,
|
||||
queued: None,
|
||||
})
|
||||
}
|
||||
fn send(&mut self, model: &mut Model, request: Request, action: Option<String>) -> Result<()> {
|
||||
if self.in_flight {
|
||||
// One user operation may queue behind the background status read.
|
||||
// Polling never disables controls or discards a click.
|
||||
if action.is_some() && self.queued.is_none() {
|
||||
self.queued = Some((request, action));
|
||||
model.busy = true;
|
||||
}
|
||||
} else {
|
||||
model.busy = action.is_some();
|
||||
self.request.send((request, action))?;
|
||||
self.in_flight = true;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn activate(
|
||||
focus: Focus,
|
||||
model: &mut Model,
|
||||
worker: &mut Worker,
|
||||
children: &mut Vec<Child>,
|
||||
) -> Result<()> {
|
||||
match focus {
|
||||
Focus::Run if model.can_use_data() => {
|
||||
model.status = "Activating DATA...".into();
|
||||
worker.send(
|
||||
model,
|
||||
Request::DataUse {
|
||||
bay: Bay::try_from((model.bay + 1) as u8)?,
|
||||
},
|
||||
Some("DATA is active at /data.".into()),
|
||||
)?;
|
||||
}
|
||||
Focus::Programs | Focus::Run if model.can_run() => {
|
||||
let program = &model.programs()[model.program];
|
||||
match Command::new("/usr/bin/xterm")
|
||||
.env("DISPLAY", &model.display)
|
||||
.args([
|
||||
"-hold",
|
||||
"-T",
|
||||
&program.label,
|
||||
"-fa",
|
||||
"Terminus",
|
||||
"-fs",
|
||||
"16",
|
||||
"-bg",
|
||||
"white",
|
||||
"-fg",
|
||||
"black",
|
||||
"-e",
|
||||
"/usr/bin/fds-program",
|
||||
&program.alias,
|
||||
])
|
||||
.spawn()
|
||||
{
|
||||
Ok(child) => {
|
||||
children.push(child);
|
||||
model.status = format!(
|
||||
"Opened {}. Close its terminal when finished.",
|
||||
program.label
|
||||
);
|
||||
}
|
||||
Err(error) => model.status = format!("Cannot open terminal: {error}"),
|
||||
}
|
||||
}
|
||||
Focus::Eject if model.can_eject() => {
|
||||
model.status = format!(
|
||||
"Releasing bay {:02}; waiting for programs and storage...",
|
||||
model.bay + 1
|
||||
);
|
||||
worker.send(
|
||||
model,
|
||||
Request::Eject {
|
||||
bay: Bay::try_from((model.bay + 1) as u8)?,
|
||||
},
|
||||
Some(format!(
|
||||
"Bay {:02} is SAFE. You may remove the cartridge.",
|
||||
model.bay + 1
|
||||
)),
|
||||
)?;
|
||||
}
|
||||
Focus::Rescan if !model.busy => {
|
||||
model.status = "Scanning cartridge bays...".into();
|
||||
worker.send(
|
||||
model,
|
||||
Request::Rescan,
|
||||
Some("Cartridge inventory refreshed.".into()),
|
||||
)?;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn run() -> Result<()> {
|
||||
let options = Options::parse();
|
||||
let view = View::new(options.display.as_deref())?;
|
||||
let mut model = Model::new();
|
||||
if let Some(display) = options.display {
|
||||
model.display = display;
|
||||
}
|
||||
let mut worker = Worker::new()?;
|
||||
let mut children: Vec<Child> = Vec::new();
|
||||
worker.send(
|
||||
&mut model,
|
||||
Request::Bays,
|
||||
Some("Select a bay to inspect its cartridge.".into()),
|
||||
)?;
|
||||
let mut refresh = Instant::now() + Duration::from_secs(2);
|
||||
let mut previous = String::new();
|
||||
let mut redraw = true;
|
||||
let mut painted_busy = false;
|
||||
loop {
|
||||
while let Some(event) = view.connection.poll_for_event()? {
|
||||
match event {
|
||||
Event::Expose(_) => redraw = true,
|
||||
Event::ClientMessage(e)
|
||||
if e.type_ == view.protocols && e.data.as_data32()[0] == view.delete =>
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
Event::DestroyNotify(_) => return Ok(()),
|
||||
Event::Error(e) => return Err(format!("X11 protocol error: {e:?}").into()),
|
||||
Event::ButtonPress(e) if e.detail == 1 => {
|
||||
let (x, y) = (e.event_x, e.event_y);
|
||||
if (20..320).contains(&x) && (110..482).contains(&y) {
|
||||
model.select(((y - 110) / 31) as usize);
|
||||
model.focus = Focus::Bays;
|
||||
} else if (346..878).contains(&x) && (304..459).contains(&y) {
|
||||
let index = model.program.saturating_sub(4) + ((y - 304) / 31) as usize;
|
||||
if index < model.programs().len() {
|
||||
model.program = index;
|
||||
model.focus = Focus::Programs;
|
||||
}
|
||||
} else if (498..536).contains(&y) {
|
||||
let focus = if (346..538).contains(&x) {
|
||||
Some(Focus::Run)
|
||||
} else if (554..702).contains(&x) {
|
||||
Some(Focus::Eject)
|
||||
} else if (718..878).contains(&x) {
|
||||
Some(Focus::Rescan)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(focus) = focus {
|
||||
model.focus = focus;
|
||||
activate(focus, &mut model, &mut worker, &mut children)?;
|
||||
}
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
Event::KeyPress(e) => {
|
||||
match view.keysym(e.detail)? {
|
||||
0xff1b => return Ok(()),
|
||||
0xff09 => model.focus = model.focus.next(),
|
||||
0xff52 | 0xff54 => {
|
||||
let down = view.keysym(e.detail)? == 0xff54;
|
||||
if model.focus == Focus::Programs {
|
||||
let length = model.programs().len();
|
||||
if length > 0 {
|
||||
model.program = if down {
|
||||
(model.program + 1).min(length - 1)
|
||||
} else {
|
||||
model.program.saturating_sub(1)
|
||||
};
|
||||
}
|
||||
} else {
|
||||
model.select(if down {
|
||||
(model.bay + 1).min(11)
|
||||
} else {
|
||||
model.bay.saturating_sub(1)
|
||||
});
|
||||
}
|
||||
}
|
||||
0xff0d => activate(model.focus, &mut model, &mut worker, &mut children)?,
|
||||
0x72 | 0x52 => {
|
||||
activate(Focus::Rescan, &mut model, &mut worker, &mut children)?
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
redraw = true;
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
while let Ok(update) = worker.response.try_recv() {
|
||||
worker.in_flight = false;
|
||||
if update.action.is_some() {
|
||||
model.busy = false;
|
||||
}
|
||||
redraw |= painted_busy;
|
||||
match update.result {
|
||||
Ok(reply) => {
|
||||
let serialized = serde_json::to_string(&reply.bays)?;
|
||||
let changed = serialized != previous;
|
||||
let reconnected = !model.available;
|
||||
model.available = true;
|
||||
model.bays = reply.bays;
|
||||
if model.program >= model.programs().len() {
|
||||
model.program = 0;
|
||||
}
|
||||
if let Some(action) = update.action {
|
||||
model.status = action;
|
||||
redraw = true;
|
||||
} else if changed && !reconnected && !model.busy {
|
||||
model.status =
|
||||
"Cartridge inventory updated. Check the selected bay's current state."
|
||||
.into();
|
||||
redraw = true;
|
||||
} else if reconnected {
|
||||
model.status = "Cartridge service connected.".into();
|
||||
redraw = true;
|
||||
}
|
||||
previous = serialized;
|
||||
redraw |= changed;
|
||||
}
|
||||
Err(error) => {
|
||||
let status = format!("Operation failed: {error}");
|
||||
redraw |= status != model.status;
|
||||
model.status = status;
|
||||
model.available = false;
|
||||
}
|
||||
}
|
||||
if let Some((request, action)) = worker.queued.take() {
|
||||
worker.send(&mut model, request, action)?;
|
||||
}
|
||||
refresh = Instant::now() + Duration::from_secs(2);
|
||||
}
|
||||
children.retain_mut(|child| !matches!(child.try_wait(), Ok(Some(_))));
|
||||
if redraw {
|
||||
view.draw(&model)?;
|
||||
painted_busy = model.busy;
|
||||
redraw = false;
|
||||
}
|
||||
if Instant::now() >= refresh {
|
||||
worker.send(&mut model, Request::Bays, None)?;
|
||||
refresh = Instant::now() + Duration::from_secs(2);
|
||||
}
|
||||
let mut fds = [
|
||||
libc::pollfd {
|
||||
fd: view.connection.stream().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
libc::pollfd {
|
||||
fd: worker.wake.as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
},
|
||||
];
|
||||
let remaining = refresh
|
||||
.saturating_duration_since(Instant::now())
|
||||
.as_millis()
|
||||
.min(2000) as i32;
|
||||
if unsafe { libc::poll(fds.as_mut_ptr(), 2, remaining) } < 0
|
||||
&& std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
return Err(std::io::Error::last_os_error().into());
|
||||
}
|
||||
if fds[1].revents != 0 {
|
||||
let mut buffer = [0; 64];
|
||||
let _ = worker.wake.read(&mut buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
fn main() {
|
||||
if let Err(error) = run() {
|
||||
eprintln!("fds-control: {error}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cartridge_labels_are_bounded_and_cannot_inject_x11_text_items() {
|
||||
assert_eq!(text_bytes("abc\n\u{ff}def", 20), b"abc??def");
|
||||
assert_eq!(text_bytes("abcdefgh", 6), b"abc...");
|
||||
assert_eq!(text_bytes("", 10), b"");
|
||||
}
|
||||
#[test]
|
||||
fn disconnected_controls_do_not_allow_operations() {
|
||||
let model = Model::new();
|
||||
assert!(!model.can_eject());
|
||||
assert!(!model.can_run());
|
||||
use clap::CommandFactory;
|
||||
Options::command().debug_assert();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user