mod cli;
mod model;
mod pty;
mod render;
use aen_window::{Configure, Create, Event, Frame, Input, InputEvent, WorkerWindow};
use model::Grid;
use std::io;
use std::os::fd::AsRawFd;
use std::os::unix::process::ExitStatusExt;
use std::time::{Duration, Instant};
pub fn run() -> i32 {
match execute() {
Ok(code) => code,
Err(error) => {
eprintln!("lios: {error}");
1
}
}
}
fn execute() -> io::Result<i32> {
let action = cli::parse(std::env::args_os().skip(1)).map_err(io::Error::other)?;
let options = match action {
cli::Action::Help => {
print!("{}", cli::HELP);
return Ok(0);
}
cli::Action::Version => {
println!("lios {} (Aen)", env!("CARGO_PKG_VERSION"));
return Ok(0);
}
cli::Action::Check => {
let font = render::default_font()?;
let _ = render::Renderer::new(Some(&font), 16.0)?;
println!(
"frontend=Aen\ntransport=aen-window-v3\nfont={}\nendpoint={}",
font.display(),
aen_window::socket_from_env()?.display()
);
return Ok(0);
}
cli::Action::Run(options) => options,
};
let mut renderer = render::Renderer::new(options.font.as_deref(), options.font_size)?;
let socket = match &options.socket {
Some(path) => path.clone(),
None => aen_window::socket_from_env()?,
};
let mut window = WorkerWindow::connect(
&socket,
Create {
app_id: "org.knott.lios".into(),
title: "Lios Terminal".into(),
preferred_width: options.width,
preferred_height: options.height,
},
)?;
let deadline = Instant::now() + Duration::from_secs(4);
while window.configuration().is_none() {
for event in window.poll()? {
if !matches!(event, Event::Configured(_)) {
return Err(io::Error::other("unexpected Lios handshake event"));
}
}
if Instant::now() >= deadline {
return Err(io::Error::other("Lios native configuration timed out"));
}
wait(&window, None)?;
}
let configure = window.configuration().unwrap();
let grid = renderer.grid(configure.width, configure.height);
let mut terminal = model::Terminal::new(grid, options.scrollback);
let mut clipboard = aen_window::clipboard::socket_for_window(&socket)
.and_then(aen_window::clipboard::Worker::new)
.ok();
let mut clipboard_request = None;
let mut focus_epoch = 0_u64;
let mut pending_input = InputQueue::default();
let mut pty = pty::Pty::spawn(&options, grid.rows, grid.cols)?;
let mut presentation = Presentation::default();
let mut dirty = true;
let mut buffer = [0; 8192];
loop {
for event in window.poll()? {
match event {
Event::Configured(_) => {
terminal.cancel_pointer();
dirty = true;
}
Event::FrameAccepted {
frame_serial,
configure_serial,
} => {
let snapshot = presentation.accepted(frame_serial, configure_serial)?;
pty.resize(snapshot.grid.rows, snapshot.grid.cols)?;
}
Event::FrameDiscarded {
frame_serial,
configure_serial,
} => {
presentation.discarded(frame_serial, configure_serial)?;
dirty = true;
}
Event::Input(input) => {
if let Some(snapshot) = presentation.input(input.clone()) {
if terminal.focused
&& matches!(&input.event, InputEvent::Key { keysym: 0x51 | 0x71, pressed: true, modifiers, .. } if *modifiers == aen_window::MOD_CTRL | aen_window::MOD_SHIFT)
{
return Ok(0);
}
if matches!(&input.event, InputEvent::Focus { .. }) {
focus_epoch = focus_epoch
.checked_add(1)
.ok_or_else(|| io::Error::other("Lios focus epoch exhausted"))?;
}
let effect = terminal.input(input.event, snapshot.grid);
dirty |= effect.redraw;
pending_input.enqueue(&effect.bytes)?;
if let Some(action) = effect.clipboard {
let (text, paste) = match action {
model::ClipboardAction::Copy(text) => (Some(text), false),
model::ClipboardAction::Paste => (None, true),
};
if let Some(worker) = clipboard.as_mut() {
if paste && let Err(error) = pending_input.begin_paste() {
eprintln!("lios: native clipboard request: {error}");
continue;
}
match worker.request(snapshot.configure.window_id, text) {
Ok(id) => clipboard_request = Some((id, focus_epoch, paste)),
Err(error) => {
if paste {
pending_input.finish_paste(&[])?;
}
eprintln!("lios: native clipboard request: {error}")
}
}
} else {
eprintln!("lios: native clipboard endpoint is unavailable");
}
}
}
}
Event::CloseRequested => return Ok(0),
}
}
if let Some(outcome) = clipboard.as_mut().and_then(|worker| worker.poll())
&& let Some((id, epoch, paste)) = clipboard_request.take()
&& outcome.id == id
{
match outcome.value {
Ok(Some(text)) if paste && epoch == focus_epoch && terminal.focused => {
pending_input.finish_paste(&terminal.paste(&text)?)?;
dirty = true;
}
Err(error) => eprintln!("lios: native clipboard request: {error}"),
_ => {}
}
if paste && pending_input.paste_pending {
pending_input.finish_paste(&[])?;
}
}
let mut read = 0;
while read < pty::READ_BUDGET && !pty.eof {
match pty.read(&mut buffer) {
Ok(0) => break,
Ok(count) => {
read += count;
terminal.output(&buffer[..count]);
pty.enqueue(&terminal.replies()?)?;
dirty = true;
}
Err(error) if error.kind() == io::ErrorKind::WouldBlock => break,
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
Err(error) => return Err(error),
}
}
if pty.eof {
pending_input = InputQueue::default();
}
while !pending_input.ready.is_empty() && pty.available_input_bytes() > 0 {
let (bytes, _) = pending_input.ready.as_slices();
let count = bytes.len().min(pty.available_input_bytes());
pty.enqueue(&bytes[..count])?;
pending_input.ready.drain(..count);
}
pty.flush()?;
if dirty && window.can_submit() {
let configure = window
.configuration()
.ok_or_else(|| io::Error::other("missing Lios native configuration"))?;
let grid = renderer.grid(configure.width, configure.height);
if terminal.parser.screen().size() != (grid.rows, grid.cols) {
terminal.resize(grid);
}
let frame_serial = presentation
.last_serial
.checked_add(1)
.ok_or_else(|| io::Error::other("Lios frame serial exhausted"))?;
let pixels = renderer.render(&terminal, configure.width, configure.height)?;
window.submit(Frame {
frame_serial,
configure_serial: configure.configure_serial,
width: configure.width,
height: configure.height,
pixels: pixels.into(),
})?;
presentation.pending = Some(Snapshot {
frame_serial,
configure,
grid,
});
presentation.last_serial = frame_serial;
dirty = false;
}
let status = pty.status()?;
if let Some(status) = status
&& pty.eof
&& !options.hold
&& !dirty
&& presentation.pending.is_none()
&& presentation.adopted.is_some()
{
return Ok(status
.code()
.unwrap_or_else(|| 128 + status.signal().unwrap_or(1)));
}
wait(&window, Some(&pty))?;
}
}
#[derive(Default)]
struct InputQueue {
ready: std::collections::VecDeque<u8>,
deferred: std::collections::VecDeque<u8>,
paste_pending: bool,
}
impl InputQueue {
const LIMIT: usize = 128 * 1024;
const PASTE_RESERVE: usize = aen_window::clipboard::MAX_TEXT_BYTES + 12;
fn enqueue(&mut self, bytes: &[u8]) -> io::Result<()> {
let limit = Self::LIMIT
- if self.paste_pending {
Self::PASTE_RESERVE
} else {
0
};
if bytes.len() > limit.saturating_sub(self.ready.len() + self.deferred.len()) {
return Err(io::Error::other("Lios pending input exceeded its limit"));
}
let queue = if self.paste_pending {
&mut self.deferred
} else {
&mut self.ready
};
queue.extend(bytes);
Ok(())
}
fn begin_paste(&mut self) -> io::Result<()> {
if self.paste_pending || self.ready.len() > Self::LIMIT - Self::PASTE_RESERVE {
return Err(io::Error::new(
io::ErrorKind::WouldBlock,
"Lios paste input is busy",
));
}
self.paste_pending = true;
Ok(())
}
fn finish_paste(&mut self, bytes: &[u8]) -> io::Result<()> {
if !self.paste_pending || bytes.len() > Self::PASTE_RESERVE {
return Err(io::Error::other("invalid Lios paste completion"));
}
self.ready.extend(bytes);
self.ready.extend(self.deferred.drain(..));
self.paste_pending = false;
Ok(())
}
}
fn wait(window: &WorkerWindow, pty: Option<&pty::Pty>) -> io::Result<()> {
let mut descriptors = [
libc::pollfd {
fd: window.as_raw_fd(),
events: libc::POLLIN,
revents: 0,
},
libc::pollfd {
fd: -1,
events: 0,
revents: 0,
},
];
if let Some(pty) = pty.filter(|p| !p.eof) {
descriptors[1].fd = pty.as_raw_fd();
descriptors[1].events = libc::POLLIN | if pty.wants_write() { libc::POLLOUT } else { 0 };
}
if unsafe {
libc::poll(
descriptors.as_mut_ptr(),
descriptors.len() as libc::nfds_t,
20,
)
} < 0
{
let error = io::Error::last_os_error();
if error.kind() != io::ErrorKind::Interrupted {
return Err(error);
}
}
Ok(())
}
#[derive(Clone, Copy)]
struct Snapshot {
frame_serial: u64,
configure: Configure,
grid: Grid,
}
#[derive(Default)]
struct Presentation {
last_serial: u64,
pending: Option<Snapshot>,
adopted: Option<Snapshot>,
}
impl Presentation {
fn accepted(&mut self, frame: u64, configuration: u64) -> io::Result<Snapshot> {
let snapshot = self.match_pending(frame, configuration)?;
self.pending = None;
self.adopted = Some(snapshot);
Ok(snapshot)
}
fn discarded(&mut self, frame: u64, configuration: u64) -> io::Result<()> {
self.match_pending(frame, configuration)?;
self.pending = None;
Ok(())
}
fn match_pending(&self, frame: u64, configuration: u64) -> io::Result<Snapshot> {
self.pending
.filter(|s| s.frame_serial == frame && s.configure.configure_serial == configuration)
.ok_or_else(|| io::Error::other("mismatched Lios native frame acknowledgement"))
}
fn input(&self, input: Input) -> Option<Snapshot> {
self.adopted
.filter(|s| s.frame_serial == input.frame_serial)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn snapshot(frame_serial: u64, configure_serial: u64, cols: u16) -> Snapshot {
Snapshot {
frame_serial,
configure: Configure {
window_id: 1,
configure_serial,
width: 800,
height: 600,
scale: 1,
},
grid: Grid {
rows: 6,
cols,
cell_width: 10,
cell_height: 20,
padding: 8,
},
}
}
#[test]
fn asynchronous_paste_precedes_enter_and_cancel_releases_deferred_input() {
let mut queue = InputQueue::default();
queue.enqueue(b"before ").unwrap();
queue.begin_paste().unwrap();
queue.enqueue(b"\r").unwrap();
assert_eq!(queue.ready.iter().copied().collect::<Vec<_>>(), b"before ");
assert!(queue.begin_paste().is_err());
queue.finish_paste(b"selection").unwrap();
assert_eq!(
queue.ready.iter().copied().collect::<Vec<_>>(),
b"before selection\r"
);
queue.ready.clear();
queue.begin_paste().unwrap();
queue.enqueue(b"typed").unwrap();
assert!(queue.enqueue(&vec![0; InputQueue::LIMIT]).is_err());
queue.finish_paste(&[]).unwrap();
assert_eq!(queue.ready.iter().copied().collect::<Vec<_>>(), b"typed");
queue.ready.clear();
queue.begin_paste().unwrap();
queue.enqueue(b"\r").unwrap();
queue
.finish_paste(&vec![b'a'; InputQueue::PASTE_RESERVE])
.unwrap();
assert_eq!(queue.ready.back(), Some(&b'\r'));
assert!(queue.ready.len() <= InputQueue::LIMIT);
}
#[test]
fn input_and_resize_follow_exact_adopted_frames() {
let mut presentation = Presentation {
pending: Some(snapshot(1, 1, 80)),
..Presentation::default()
};
let input = |serial| Input {
frame_serial: serial,
event: InputEvent::Motion { x: 5, y: 5 },
};
assert!(presentation.input(input(1)).is_none());
assert!(presentation.accepted(1, 2).is_err());
presentation.accepted(1, 1).unwrap();
presentation.pending = Some(snapshot(2, 2, 40));
assert_eq!(presentation.input(input(1)).unwrap().grid.cols, 80);
presentation.discarded(2, 2).unwrap();
assert_eq!(presentation.input(input(1)).unwrap().grid.cols, 80);
presentation.pending = Some(snapshot(3, 2, 40));
presentation.accepted(3, 2).unwrap();
assert!(presentation.input(input(1)).is_none());
assert_eq!(presentation.input(input(3)).unwrap().grid.cols, 40);
}
}