use std::sync::Arc;
use crate::Command;
use crate::callback::{Callback, CommandLink};
use crate::core::component::{Component, Context, Update};
use crate::core::element::Element;
use crate::style::Length;
use crate::widgets::terminal::{
Terminal, TerminalInputEvent, TerminalPty, TerminalPtyConfig, TerminalPtyEvent,
TerminalRenderSnapshot, TerminalScreen, TerminalViewport,
};
use crate::widgets::{Text, VStack};
#[derive(Clone)]
pub struct ManagedTerminal {
props: ManagedTerminalProps,
}
#[derive(Clone, PartialEq)]
pub struct ManagedTerminalProps {
pub config: TerminalPtyConfig,
pub scrollback: usize,
pub initial_cols: u16,
pub initial_rows: u16,
pub on_status: Option<Callback<ManagedTerminalStatus>>,
pub auto_start: bool,
pub placeholder: Option<Arc<str>>,
pub forward_mouse: bool,
pub scroll_wheel: bool,
pub style: crate::style::Style,
pub focusable: bool,
pub width: Length,
pub height: Length,
}
impl Default for ManagedTerminalProps {
fn default() -> Self {
Self {
config: TerminalPtyConfig::default(),
scrollback: 2000,
initial_cols: 120,
initial_rows: 24,
on_status: None,
auto_start: true,
placeholder: Some(Arc::from("Starting terminal...")),
forward_mouse: true,
scroll_wheel: true,
style: crate::style::Style::default(),
focusable: true,
width: Length::Flex(1),
height: Length::Flex(1),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ManagedTerminalStatus {
Starting,
Ready,
Exited(i32),
Error(Arc<str>),
}
impl ManagedTerminal {
pub fn new() -> Self {
Self {
props: ManagedTerminalProps::default(),
}
}
pub fn config(mut self, config: TerminalPtyConfig) -> Self {
self.props.config = config;
self
}
pub fn scrollback(mut self, lines: usize) -> Self {
self.props.scrollback = lines;
self
}
pub fn initial_size(mut self, cols: u16, rows: u16) -> Self {
self.props.initial_cols = cols.max(1);
self.props.initial_rows = rows.max(1);
self
}
pub fn on_status(mut self, callback: Callback<ManagedTerminalStatus>) -> Self {
self.props.on_status = Some(callback);
self
}
pub fn auto_start(mut self, auto_start: bool) -> Self {
self.props.auto_start = auto_start;
self
}
pub fn placeholder(mut self, text: impl Into<Arc<str>>) -> Self {
self.props.placeholder = Some(text.into());
self
}
pub fn forward_mouse(mut self, forward: bool) -> Self {
self.props.forward_mouse = forward;
self
}
pub fn scroll_wheel(mut self, enabled: bool) -> Self {
self.props.scroll_wheel = enabled;
self
}
pub fn style(mut self, style: crate::style::Style) -> Self {
self.props.style = style;
self
}
pub fn focusable(mut self, focusable: bool) -> Self {
self.props.focusable = focusable;
self
}
pub fn width(mut self, width: Length) -> Self {
self.props.width = width;
self
}
pub fn height(mut self, height: Length) -> Self {
self.props.height = height;
self
}
}
impl Default for ManagedTerminal {
fn default() -> Self {
Self::new()
}
}
impl From<ManagedTerminal> for Element {
fn from(terminal: ManagedTerminal) -> Self {
let props = terminal.props.clone();
crate::child(move || terminal.clone(), props)
}
}
#[derive(Clone)]
pub enum ManagedTerminalMsg {
PtyReady(TerminalPty),
PtyEvent(TerminalPtyEvent),
TerminalInput(TerminalInputEvent),
TerminalMouse(Vec<u8>),
TerminalScrollTo(usize),
Resize { cols: u16, rows: u16 },
Start,
}
pub struct ManagedTerminalState {
screen: TerminalScreen,
snapshot: TerminalRenderSnapshot,
pty: Option<TerminalPty>,
cols: u16,
rows: u16,
status: ManagedTerminalStatus,
}
impl Component for ManagedTerminal {
type Message = ManagedTerminalMsg;
type Properties = ManagedTerminalProps;
type State = ManagedTerminalState;
fn create_state(&self, props: &Self::Properties) -> Self::State {
ManagedTerminalState {
screen: TerminalScreen::new(props.initial_rows, props.initial_cols, props.scrollback),
snapshot: TerminalRenderSnapshot::default(),
pty: None,
cols: props.initial_cols,
rows: props.initial_rows,
status: ManagedTerminalStatus::Starting,
}
}
fn init(&mut self, ctx: &mut Context<Self>) -> Option<Command> {
if let Some(on_status) = &ctx.props.on_status {
on_status.emit(ManagedTerminalStatus::Starting);
}
if ctx.props.auto_start {
let config = ctx.props.config.clone();
Some(ctx.link().command(move |link| {
Self::spawn_pty(link, &config);
}))
} else {
None
}
}
fn update(&mut self, msg: Self::Message, ctx: &mut Context<Self>) -> Update {
match msg {
ManagedTerminalMsg::PtyReady(pty) => {
let _ = pty.resize(ctx.state.cols, ctx.state.rows);
ctx.state.pty = Some(pty);
ctx.state.status = ManagedTerminalStatus::Ready;
if let Some(on_status) = &ctx.props.on_status {
on_status.emit(ManagedTerminalStatus::Ready);
}
Update::full()
}
ManagedTerminalMsg::PtyEvent(event) => {
match event {
TerminalPtyEvent::Output(bytes) => {
ctx.state.screen.process_bytes(&bytes);
if let Some(pty) = &ctx.state.pty {
for response in ctx.state.screen.drain_responses() {
if let Err(err) = pty.write(&response) {
let msg = format!("pty response write failed: {err}");
ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
break;
}
}
}
ctx.state.snapshot = ctx.state.screen.render_snapshot();
}
TerminalPtyEvent::Exited(code) => {
ctx.state.status = ManagedTerminalStatus::Exited(code);
ctx.state.pty = None;
if let Some(on_status) = &ctx.props.on_status {
on_status.emit(ManagedTerminalStatus::Exited(code));
}
}
TerminalPtyEvent::Error(message) => {
ctx.state.status = ManagedTerminalStatus::Error(message.clone());
if let Some(on_status) = &ctx.props.on_status {
on_status.emit(ManagedTerminalStatus::Error(message));
}
}
}
Update::full()
}
ManagedTerminalMsg::TerminalInput(input) => {
if let Some(pty) = &ctx.state.pty {
if let Err(err) = pty.write(&input.bytes) {
let msg = format!("stdin write failed: {err}");
ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
}
if ctx.state.screen.scrollback_offset() > 0 {
ctx.state.screen.set_scrollback(0);
ctx.state.snapshot = ctx.state.screen.render_snapshot();
return Update::full();
}
}
Update::none()
}
ManagedTerminalMsg::TerminalMouse(bytes) => {
if let Some(pty) = &ctx.state.pty
&& let Err(err) = pty.write(&bytes)
{
let msg = format!("mouse write failed: {err}");
ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
}
Update::none()
}
ManagedTerminalMsg::TerminalScrollTo(offset) => {
ctx.state.screen.set_scrollback(offset);
ctx.state.snapshot = ctx.state.screen.render_snapshot();
Update::full()
}
ManagedTerminalMsg::Resize { cols, rows } => {
if cols == ctx.state.cols && rows == ctx.state.rows {
return Update::none();
}
ctx.state.cols = cols;
ctx.state.rows = rows;
if let Some(pty) = &ctx.state.pty
&& let Err(err) = pty.resize(cols, rows)
{
let msg = format!("pty resize failed: {err}");
ctx.state.status = ManagedTerminalStatus::Error(Arc::from(msg));
return Update::full();
}
ctx.state.screen.resize(rows, cols);
ctx.state.snapshot = ctx.state.screen.render_snapshot();
Update::full()
}
ManagedTerminalMsg::Start => {
if ctx.state.pty.is_none() {
let config = ctx.props.config.clone();
return Update::with_command(ctx.link().command(move |link| {
Self::spawn_pty(link, &config);
}));
}
Update::none()
}
}
}
fn view(&self, ctx: &Context<Self>) -> Element {
if ctx.state.pty.is_none() && ctx.props.placeholder.is_some() {
let placeholder = ctx
.props
.placeholder
.clone()
.expect("placeholder.is_some() checked in enclosing if condition");
return VStack::new()
.width(ctx.props.width)
.height(ctx.props.height)
.child(Text::new(placeholder))
.into();
}
let mut terminal = Terminal::new()
.snapshot(ctx.state.snapshot.clone())
.style(ctx.props.style)
.focusable(ctx.props.focusable)
.width(ctx.props.width)
.height(ctx.props.height)
.scroll_wheel(ctx.props.scroll_wheel)
.on_input(ctx.link().callback(ManagedTerminalMsg::TerminalInput))
.on_resize(ctx.link().callback(|viewport: TerminalViewport| {
ManagedTerminalMsg::Resize {
cols: viewport.cols,
rows: viewport.rows,
}
}))
.on_scroll_to(ctx.link().callback(ManagedTerminalMsg::TerminalScrollTo));
if ctx.props.forward_mouse {
terminal =
terminal.on_mouse_forward(ctx.link().callback(ManagedTerminalMsg::TerminalMouse));
}
terminal.into()
}
}
impl ManagedTerminal {
fn spawn_pty(link: CommandLink<ManagedTerminalMsg>, config: &TerminalPtyConfig) {
let config = config.clone();
let event_link = link.clone();
match TerminalPty::spawn(config, move |event| {
event_link.send(ManagedTerminalMsg::PtyEvent(event));
}) {
Ok(pty) => link.send(ManagedTerminalMsg::PtyReady(pty)),
Err(err) => link.send(ManagedTerminalMsg::PtyEvent(TerminalPtyEvent::Error(
err.to_string().into(),
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn managed_terminal_props_default() {
let props = ManagedTerminalProps::default();
assert_eq!(props.scrollback, 2000);
assert_eq!(props.initial_cols, 120);
assert_eq!(props.initial_rows, 24);
assert!(props.auto_start);
assert!(props.forward_mouse);
assert!(props.scroll_wheel);
assert!(props.focusable);
}
#[test]
fn managed_terminal_builder() {
let terminal = ManagedTerminal::new()
.scrollback(5000)
.initial_size(80, 30)
.auto_start(false)
.forward_mouse(false);
assert_eq!(terminal.props.scrollback, 5000);
assert_eq!(terminal.props.initial_cols, 80);
assert_eq!(terminal.props.initial_rows, 30);
assert!(!terminal.props.auto_start);
assert!(!terminal.props.forward_mouse);
}
}