use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::Result;
use crossterm::event::Event;
use ratatui::buffer::Buffer;
use ratatui::{Terminal, backend::Backend};
use tokio::sync::{mpsc, oneshot};
use unicode_width::UnicodeWidthStr;
use crate::core::entity::Origin;
use crate::core::message::Message;
use crate::core::usecase::{Request, agent, favorite, issue, notes, project};
use crate::infra::disk::snapshot::{Recorder, timestamp_now};
use crate::infra::dispatch;
use crate::infra::herdr::AgentWatch;
use crate::infra::linear::client::LinearClient;
use crate::interface::control::{Asked, Command, Reply};
use crate::interface::control::{notation, screen};
use crate::interface::tui::app::App;
use crate::interface::tui::{event, palette, ui};
const TICK: Duration = Duration::from_millis(80);
const SETTLE_TIMEOUT: Duration = Duration::from_secs(15);
pub struct Runtime {
pub app: App,
cache: ui::Cache,
client: Arc<LinearClient>,
tx: mpsc::UnboundedSender<Message>,
rx: mpsc::UnboundedReceiver<Message>,
origin: Origin,
recorder: Option<Recorder>,
agents: Option<AgentWatch>,
last_tick: Instant,
dirty: bool,
control: Option<mpsc::UnboundedReceiver<Asked>>,
waiting: Vec<(oneshot::Sender<Reply>, Instant)>,
frame: Vec<String>,
size: (u16, u16),
hold_external: bool,
held: Vec<String>,
}
impl Runtime {
pub fn new(app: App, client: LinearClient, origin: Origin, recorder: Option<Recorder>) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let agents = app.herdr.then(AgentWatch::new).flatten();
Self {
app,
cache: ui::Cache::default(),
client: Arc::new(client),
tx,
rx,
origin,
recorder,
agents,
last_tick: Instant::now(),
dirty: true,
control: None,
waiting: Vec::new(),
frame: Vec::new(),
size: (0, 0),
hold_external: false,
held: Vec::new(),
}
}
pub fn restart(&mut self, app: App, client: LinearClient) {
let (tx, rx) = mpsc::unbounded_channel();
self.app = app;
self.client = Arc::new(client);
self.tx = tx;
self.rx = rx;
self.cache = ui::Cache::default();
self.dirty = true;
}
pub fn accept_control(&mut self, commands: mpsc::UnboundedReceiver<Asked>) {
self.control = Some(commands);
}
pub fn hold_external(&mut self) {
self.hold_external = true;
}
pub fn note_held(&mut self, what: String) {
self.held.push(what);
}
pub fn send_queued(&mut self, now: Instant) {
if self.app.flush_palette_search(now) {
self.dirty = true;
}
while let Some(req) = self.app.outbox.requests.pop_front() {
self.app.outbox.inflight += 1;
self.dirty = true;
if self.hold_external
&& let Some((what, done)) = external(&req)
{
self.held.push(what);
let _ = self.tx.send(Message::Mutated(done));
continue;
}
let client = Arc::clone(&self.client);
let tx = self.tx.clone();
let per_page = self.app.items_per_page;
tokio::spawn(async move {
let msg = dispatch::execute_request(&client, req, per_page).await;
let _ = tx.send(msg);
});
}
}
pub fn draw<B>(&mut self, terminal: &mut Terminal<B>) -> Result<()>
where
B: Backend,
B::Error: Send + Sync + 'static,
{
if !self.dirty {
return Ok(());
}
let completed = terminal.draw(|f| ui::draw(f, &mut self.app, &mut self.cache))?;
if self.control.is_some() {
self.frame = text_of(completed.buffer);
self.size = (completed.area.width, completed.area.height);
}
self.dirty = false;
Ok(())
}
pub fn receive(&mut self) -> bool {
let mut moved = false;
while let Ok(msg) = self.rx.try_recv() {
self.app.outbox.inflight = self.app.outbox.inflight.saturating_sub(1);
self.app.handle_message(msg);
moved = true;
}
moved
}
pub fn serve_control(&mut self) -> bool {
let mut asked = Vec::new();
if let Some(control) = &mut self.control {
while let Ok(one) = control.try_recv() {
asked.push(one);
}
}
let moved = !asked.is_empty();
for (command, answer) in asked {
match self.carry_out(command) {
Ok(()) => self.waiting.push((answer, Instant::now())),
Err(reply) => {
let _ = answer.send(reply);
}
}
}
if moved {
self.dirty = true;
}
moved
}
fn carry_out(&mut self, command: Command) -> Result<(), Reply> {
match command {
Command::Screen => {}
Command::Press { keys } => {
let keys = notation::parse(&keys).map_err(|e| Reply::error(e.to_string()))?;
for key in keys {
event::handle(&mut self.app, Event::Key(key));
}
}
Command::Type { text } => {
for c in text.chars() {
event::handle(&mut self.app, Event::Key(notation::char_key(c)));
}
}
Command::Run { title } => {
self.app.cancel_restore();
palette::run_command(&mut self.app, &title).map_err(Reply::error)?;
}
Command::Open { issue } => {
let key = crate::interface::cli::issue_key(&issue)
.map_err(|e| Reply::error(e.to_string()))?;
self.app.cancel_restore();
self.app.open_issue_by_identifier(&key);
}
Command::Quit => {
self.app.quit();
return Err(Reply::done());
}
}
Ok(())
}
fn settled(&self) -> bool {
self.app.outbox.requests.is_empty()
&& self.app.outbox.inflight == 0
&& self.app.view.palette.search_due.is_none()
&& !self.dirty
}
pub fn answer_waiting(&mut self, now: Instant) {
if self.waiting.is_empty() {
return;
}
let settled = self.settled();
let (ready, still): (Vec<_>, Vec<_>) = std::mem::take(&mut self.waiting)
.into_iter()
.partition(|(_, since)| settled || now.duration_since(*since) >= SETTLE_TIMEOUT);
self.waiting = still;
if ready.is_empty() {
return;
}
if let Some(recorder) = &mut self.recorder
&& let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
{
recorder.record(snapshot);
}
let report = screen::report(&self.app, self.frame.clone(), self.size, self.held.clone());
for (answer, _) in ready {
let _ = answer.send(Reply::screen(&report));
}
}
pub fn settle(&mut self, moved: bool, now: Instant) {
if let Some(recorder) = &mut self.recorder {
if moved {
recorder.touch(now);
}
if recorder.is_due(now)
&& let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
{
recorder.record(snapshot);
}
}
self.dirty |= moved;
if let Some(watch) = &mut self.agents
&& let Some(list) = watch.poll(now)
{
self.app.set_agents(list);
self.dirty = true;
}
if self.app.loading() && self.last_tick.elapsed() >= TICK {
self.app.tick_spinner();
self.last_tick = Instant::now();
self.dirty = true;
}
}
pub fn touch(&mut self) {
self.dirty = true;
}
pub fn close(&mut self) {
if let Some(recorder) = &mut self.recorder
&& let Some(snapshot) = self.app.snapshot(&self.origin, timestamp_now())
{
recorder.close(snapshot);
}
}
}
fn external(request: &Request) -> Option<(String, &'static str)> {
let browser = |url: &str| {
(
format!("would open {url} in a browser"),
"Opened in browser",
)
};
match request {
Request::Issue(issue::Request::OpenInBrowser(url))
| Request::Project(project::Request::OpenInBrowser(url))
| Request::Favorite(favorite::Request::OpenInBrowser(url)) => Some(browser(url)),
Request::Notes(notes::Request::Deliver(handoff)) => {
Some(("would hand the notes to herdr".into(), handoff.done()))
}
Request::Agent(agent::Request::Focus { pane }) => Some((
format!("would bring herdr pane {pane} to the front"),
"Switched to the agent",
)),
_ => None,
}
}
fn text_of(buffer: &Buffer) -> Vec<String> {
let area = buffer.area;
(area.y..area.y + area.height)
.map(|y| {
let mut line = String::new();
let mut x = area.x;
while x < area.x + area.width {
let symbol = buffer[(x, y)].symbol();
line.push_str(symbol);
x += symbol.width().max(1) as u16;
}
line.trim_end().to_string()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::layout::Rect;
#[test]
fn a_frame_reads_as_text_with_wide_characters_whole() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 2));
buffer.set_string(0, 0, "天気 ok", ratatui::style::Style::default());
buffer.set_string(0, 1, "row two", ratatui::style::Style::default());
assert_eq!(text_of(&buffer), ["天気 ok", "row two"]);
}
#[test]
fn a_browser_or_herdr_hand_off_is_external() {
let open = Request::Issue(issue::Request::OpenInBrowser("https://x".into()));
assert_eq!(
external(&open),
Some((
"would open https://x in a browser".into(),
"Opened in browser"
))
);
let status = Request::Issue(issue::Request::Detail {
issue_id: "i".into(),
});
assert_eq!(external(&status), None);
}
fn terminal() -> Terminal<TestBackend> {
Terminal::new(TestBackend::new(80, 20)).unwrap()
}
#[tokio::test]
async fn an_agents_keys_are_answered_with_the_screen_they_lead_to() {
let mut app = App::new(&crate::config::Config::default());
app.outbox.requests.clear();
app.store.teams =
vec![serde_json::from_str(r#"{"id":"t","name":"Engineering","key":"ENG"}"#).unwrap()];
let client = LinearClient::with_header("unused".into());
let origin = Origin {
workspace: "/repo".into(),
cwd: "/repo".into(),
pid: 1,
herdr_pane: None,
};
let mut runtime = Runtime::new(app, client, origin, None);
let (tx, rx) = mpsc::unbounded_channel();
runtime.accept_control(rx);
let mut terminal = terminal();
let (answer, answered) = oneshot::channel();
tx.send((Command::Press { keys: "?".into() }, answer))
.unwrap();
assert!(runtime.serve_control());
runtime.draw(&mut terminal).unwrap();
runtime.answer_waiting(Instant::now());
let reply = answered.await.unwrap();
let screen = reply.screen.unwrap();
assert!(screen["overlay"].as_str().unwrap().starts_with("help"));
assert!(
screen["lines"]
.as_array()
.unwrap()
.iter()
.any(|l| l.as_str().unwrap().contains("Help"))
);
let (answer, answered) = oneshot::channel();
tx.send((
Command::Press {
keys: "<Nope>".into(),
},
answer,
))
.unwrap();
runtime.serve_control();
assert!(
answered
.await
.unwrap()
.error
.unwrap()
.contains("unknown key")
);
}
}