///|
/// Logical terminal size measured in character cells.
///
/// `cols` is the number of columns and `rows` is the number of rows passed to
/// xterm.js. The managed API updates this value after explicit `resize`
/// actions and terminal resize events.
pub(all) struct Size {
cols : Int
rows : Int
} derive(Eq)
///|
let host_id_count : Ref[Int] = Ref(0)
///|
fn next_host_id() -> String {
host_id_count.val += 1
"rabbita_xterm-\{host_id_count.val}"
}
///|
/// Loading state for a managed terminal.
pub enum Status {
/// The lifecycle subscription has not finished loading xterm.js yet.
Loading
/// xterm.js has been loaded, the terminal has been opened in the DOM, and
/// commands such as `write` and `resize` can take effect.
Ready
/// Loading failed. The string is a user-displayable diagnostic.
Failed(String)
} derive(Eq)
///|
/// Events emitted by the managed terminal.
///
/// Feed these back into your application message type from the `on_event`
/// dispatch passed to `update`.
pub enum Event {
/// The terminal is mounted and ready to receive commands.
Ready
/// User input from xterm.js `onData`; this is input typed or pasted into the
/// terminal, not output written with `write`.
Data(String)
/// The terminal cell size changed.
Resized(Size)
/// The running terminal application requested a title change via OSC 0/2.
TitleChanged(String)
/// xterm.js failed to load.
LoadFailed(String)
/// The fit addon failed to load.
FitFailed(String)
}
///|
/// Opaque action handled by `update`.
///
/// Construct values with helpers such as `write`, `resize`, and `fit`, or pass
/// actions received from `subscriptions` back to `update`.
enum Action {
XtermLoaded(Result[@js.Xterm, Error])
FitLoaded(Result[@fit.FitAddon, Error])
Data(String)
Resized(Size)
TitleChanged(String)
Write(Bytes)
Writeln(Bytes)
Clear
Reset
Resize(Int, Int)
SetTheme(String, String)
Fit
}
///|
/// Managed Rabbita state for one xterm.js instance.
///
/// Store this in your application model, render it with `State::view`, handle
/// `Action` values with `update`, and install `subscriptions` from your app's
/// subscription function. The lifecycle subscription owns loading and disposal
/// of the underlying terminal.
struct State {
host_id : String
use_fit : Bool
background : String
foreground : String
cursor_blink : Bool
font_size : Int
cols : Int
rows : Int
mac_option_is_meta : Bool
scrollback : Int
status : Status
title : String
terminal : @js.Terminal?
fit_addon : @fit.FitAddon?
}
///|
/// Create a managed terminal state.
///
/// The terminal is not loaded immediately. It starts in `Loading`, and the
/// first call to `subscriptions` schedules loading xterm.js and, when
/// `use_fit` is true, the fit addon. `cols` and `rows` are the initial logical
/// cell size; if fit is enabled, the addon may resize the terminal after it is
/// attached to the DOM.
pub fn new(
use_fit? : Bool = true,
background? : String = "#101418",
foreground? : String = "#dce4ea",
cursor_blink? : Bool = true,
font_size? : Int = 14,
cols? : Int = 80,
rows? : Int = 24,
mac_option_is_meta? : Bool = true,
scrollback? : Int = 1000,
title? : String = "terminal",
) -> State {
let host_id = next_host_id()
{
host_id,
use_fit,
background,
foreground,
cursor_blink,
font_size,
cols,
rows,
mac_option_is_meta,
scrollback,
status: Loading,
title,
terminal: None,
fit_addon: None,
}
}
///|
/// Write output bytes to the terminal.
///
/// This corresponds to xterm.js `term.write`. It is intended for program
/// output such as an asciicast stream. User keyboard input arrives separately
/// as `Event::Data`.
///
/// If the action is handled before the terminal reaches `Ready`, it is a no-op.
pub fn write(data : Bytes) -> Action {
Write(data)
}
///|
/// Write output bytes followed by a newline.
///
/// This corresponds to xterm.js `term.writeln`. If the action is handled before
/// the terminal reaches `Ready`, it is a no-op.
pub fn writeln(data : Bytes) -> Action {
Writeln(data)
}
///|
/// Clear the visible terminal buffer.
///
/// This corresponds to xterm.js `term.clear`. If the action is handled before
/// the terminal reaches `Ready`, it is a no-op.
pub fn clear() -> Action {
Clear
}
///|
/// Reset terminal state, modes, cursor, and buffers.
///
/// This corresponds to xterm.js `term.reset`. It is stronger than `clear`.
/// If the action is handled before the terminal reaches `Ready`, it is a no-op.
pub fn reset() -> Action {
Reset
}
///|
/// Resize the terminal to an explicit logical cell size.
///
/// The managed state is updated immediately. xterm.js receives the resize when
/// the terminal is ready; before `Ready`, only the stored initial size changes.
pub fn resize(cols~ : Int, rows~ : Int) -> Action {
Resize(cols, rows)
}
///|
/// Update the terminal foreground and background colors.
///
/// The managed state is updated immediately. xterm.js receives the theme change
/// when the terminal is ready; before `Ready`, these colors become the initial
/// theme used at construction time.
pub fn set_theme(background~ : String, foreground~ : String) -> Action {
SetTheme(background, foreground)
}
///|
/// Ask the fit addon to resize the terminal to its DOM container.
///
/// This only has an effect when `new(use_fit=true)` was used and the fit addon
/// has loaded. The managed subscription also triggers this on window resize.
pub fn fit() -> Action {
Fit
}
///|
/// Current loading status.
pub fn State::status(self : State) -> Status {
self.status
}
///|
/// Human-readable status text suitable for a small loading/error label.
pub fn State::status_text(self : State) -> String {
match self.status {
Loading => "loading xterm.js"
Ready => "ready"
Failed(message) => message
}
}
///|
/// Last known terminal title.
///
/// This starts from the `title` argument passed to `new` and is updated when
/// the terminal emits `TitleChanged`.
pub fn State::title(self : State) -> String {
self.title
}
///|
/// Last known column count.
pub fn State::cols(self : State) -> Int {
self.cols
}
///|
/// Last known row count.
pub fn State::rows(self : State) -> Int {
self.rows
}
///|
/// Last known terminal size.
pub fn State::size(self : State) -> Size {
{ cols: self.cols, rows: self.rows }
}
///|
/// Whether the terminal has loaded and been opened in the DOM.
pub fn State::is_ready(self : State) -> Bool {
self.status == Ready
}
///|
/// Render the DOM host element for this terminal.
///
/// Include this exactly once in your view while the state is alive. The managed
/// lifecycle opens xterm.js into this element after render, so the `id` is
/// generated internally and does not need to be supplied by the application.
pub fn State::view(
self : State,
class? : String = "terminal-host",
) -> @html.Html {
@html.div(id=self.host_id, class~, "")
}
///|
fn effect(f : () -> Unit) -> @cmd.Cmd {
@cmd.custom_cmd(_ => f())
}
///|
fn terminal_cmd(state : State, f : (@js.Terminal) -> Unit) -> @cmd.Cmd {
match state.terminal {
Some(terminal) => effect(() => f(terminal))
None => @cmd.none
}
}
///|
fn fit_cmd(state : State) -> @cmd.Cmd {
match state.fit_addon {
Some(addon) => @fit.fit(addon)
None => @cmd.none
}
}
///|
fn attach_fit(terminal : @js.Terminal, addon : @fit.FitAddon) -> @cmd.Cmd {
@cmd.batch([@fit.attach(terminal, addon), @fit.fit(addon)])
}
///|
fn setup_terminal(
state : State,
terminal : @js.Terminal,
on_event : @cmd.Dispatch[Event],
) -> @cmd.Cmd {
@cmd.custom_cmd(
scheduler => {
let host = @dom.document().get_element_by_id(state.host_id).unwrap()
scheduler.add(effect(() => terminal.open(host)))
if state.fit_addon is Some(addon) {
scheduler.add(attach_fit(terminal, addon))
}
scheduler.add(on_event(Ready))
},
kind=@cmd.after_render,
)
}
///|
/// Update managed terminal state.
///
/// Call this from your application update branch for `Action` values. The
/// returned command performs the xterm.js side effect, and `on_event` maps
/// terminal events back into your application messages.
///
/// A typical Rabbita app stores `State`, wraps `Action` in an app message, and
/// passes terminal events through another app message:
///
/// ```mbt nocheck
/// let (cmd, xterm) = @xterm.update(action, model.xterm, event => dispatch(XtermEvent(event)))
/// ```
pub fn update(
action : Action,
state : State,
on_event : @cmd.Dispatch[Event],
) -> (@cmd.Cmd, State) {
match action {
XtermLoaded(Ok(xterm)) => {
let terminal = xterm.new_terminal(
background=state.background,
foreground=state.foreground,
cursor_blink=state.cursor_blink,
font_size=state.font_size,
cols=state.cols,
rows=state.rows,
mac_option_is_meta=state.mac_option_is_meta,
scrollback=state.scrollback,
)
let next = { ..state, status: Ready, terminal: Some(terminal) }
(setup_terminal(next, terminal, on_event), next)
}
XtermLoaded(Err(err)) => {
let message = "xterm.js failed: \{err.to_string()}"
(on_event(LoadFailed(message)), { ..state, status: Failed(message) })
}
FitLoaded(Ok(addon)) => {
let setup = match state.terminal {
Some(terminal) => attach_fit(terminal, addon)
None => @cmd.none
}
(setup, { ..state, fit_addon: Some(addon) })
}
FitLoaded(Err(err)) => {
let message = "fit addon failed: \{err.to_string()}"
(on_event(FitFailed(message)), state)
}
Data(data) => (on_event(Data(data)), state)
Resized(size) =>
(on_event(Resized(size)), { ..state, cols: size.cols, rows: size.rows })
TitleChanged(title) => (on_event(TitleChanged(title)), { ..state, title, })
Write(data) =>
(terminal_cmd(state, terminal => terminal.write(data)), state)
Writeln(data) =>
(terminal_cmd(state, terminal => terminal.writeln(data)), state)
Clear => (terminal_cmd(state, terminal => terminal.clear()), state)
Reset => (terminal_cmd(state, terminal => terminal.reset()), state)
Resize(cols, rows) =>
(
terminal_cmd(state, terminal => terminal.resize(cols~, rows~)),
{ ..state, cols, rows },
)
SetTheme(background, foreground) =>
(
terminal_cmd(state, terminal => {
terminal.set_theme(background~, foreground~)
}),
{ ..state, background, foreground },
)
Fit => (fit_cmd(state), state)
}
}
///|
priv suberror ManagedSub {
Lifecycle(State, @cmd.Dispatch[Action])
}
///|
fn lifecycle_loader(
payload : Error,
scheduler : &@cmd.Scheduler,
) -> @sub.RunningSub? {
match payload {
Lifecycle(state, dispatch) => {
let mut current = state
let mut tagger = dispatch
if current.status is Loading {
scheduler.add(
@cmd.attempt(result => tagger(XtermLoaded(result)), async fn() {
@js.load()
}),
)
if current.use_fit {
scheduler.add(@fit.load(result => tagger(FitLoaded(result))))
}
}
Some({
unload: scheduler => {
if current.terminal is Some(terminal) {
scheduler.add(effect(() => terminal.dispose()))
}
if current.fit_addon is Some(addon) {
scheduler.add(@fit.dispose(addon))
}
},
update_tagger: payload => {
guard payload is Lifecycle(next, next_dispatch) else { return }
current = next
tagger = next_dispatch
},
})
}
_ => None
}
}
///|
fn lifecycle(state : State, dispatch : @cmd.Dispatch[Action]) -> @sub.Sub {
@sub.custom_sub(
"xterm.lifecycle(\{state.host_id})",
@sub.Local,
Lifecycle(state, dispatch),
lifecycle_loader,
)
}
///|
/// Install subscriptions for the managed terminal.
///
/// This loads xterm.js while the state is `Loading`, forwards user input,
/// resize, and title-change events, and disposes the underlying JS resources
/// when the subscription is removed. Applications using the managed API should
/// not call low-level `dispose` directly.
pub fn subscriptions(
state : State,
dispatch : @cmd.Dispatch[Action],
) -> @sub.Sub {
let subs : Array[@sub.Sub] = [lifecycle(state, dispatch)]
if state.terminal is Some(terminal) {
subs.push(@js.on_data(terminal, data => dispatch(Data(data))))
subs.push(
@js.on_resize(terminal, size => {
dispatch(Resized({ cols: size.cols, rows: size.rows }))
}),
)
subs.push(
@js.on_title_change(terminal, title => dispatch(TitleChanged(title))),
)
}
if state.fit_addon is Some(_) {
subs.push(@sub.on_resize(_ => dispatch(Fit)))
}
@sub.batch(subs)
}