diff --git a/README.md b/README.md index 7368ce1..65c2256 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,19 @@ Default size of the virtual terminal window is 120x40 (cols by rows), which can be changed with `--size` argument. For example: `ht --size 80x24`. The window size can also be dynamically changed - see [resize command](#resize) below. +### Style Support + +ht supports capturing and returning terminal styling information (colors, bold, italic, etc.). Use the +`--style-mode` option to enable styled output: + +- `--style-mode plain` (default) - Returns only plain text in snapshots +- `--style-mode styled` - Includes styling information in snapshots + +Example: +```sh +ht --style-mode styled --subscribe snapshot +``` + Run `ht -h` or `ht --help` to see all available options. ## Live terminal preview @@ -205,6 +218,18 @@ specifying new width (`cols`) and height (`rows`). This command triggers `resize` event. +#### setStyleMode + +`setStyleMode` command allows changing the style mode during runtime to enable or +disable styled output in snapshots. + +```json +{ "type": "setStyleMode", "mode": "styled" } +{ "type": "setStyleMode", "mode": "plain" } +``` + +This command doesn't trigger any event but affects subsequent snapshots. + ### WebSocket API The WebSocket API currently provides 2 endpoints: @@ -280,6 +305,36 @@ Event data is an object with the following fields: - `text` - plain text snapshot as multi-line string, where each line represents a terminal row - `seq` - a raw sequence of characters, which when printed to a blank terminal puts it in the same state as [ht's virtual terminal](https://github.com/asciinema/avt) +When color mode is set to `styled`, additional fields are included: + +- `charMap` - 2D array of characters, where `charMap[row][col]` gives the character at that position +- `styleMap` - 2D array of style IDs, where `styleMap[row][col]` gives the style ID for the character at that position +- `styles` - object mapping style IDs to style definitions with `fg`, `bg`, and `attrs` fields + +Example styled snapshot for `$ ls foo/b*` where `baz.sh` is styled differently than `bar.txt`: +```json +{ + "type": "snapshot", + "data": { + "cols": 15, "rows": 2, + "text": "$ ls foo/b*\nbar.txt baz.sh", + "charMap": [ + ["$", " ", "l", "s", " ", "f", "o", "o", "/", "b", "*", " ", " ", " ", " "], + ["b", "a", "r", ".", "t", "x", "t", " ", " ", "b", "a", "z", ".", "s", "h"] + ], + "styleMap": [ + [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2] + ], + "styles": { + "0": {}, + "1": {"fg": {"indexed": 2}}, + "2": {"fg": {"rgb": [0, 255, 127]}, "attrs": ["bold"]} + } + } +} +``` + ## Testing on command line ht is aimed at programmatic use given its JSON-based API, however one can play @@ -303,7 +358,6 @@ TODO: either pull those into this repo or fork them into their own `htlib` repo. ## Possible future work -* update the interface to return the view with additional color and style information (text color, background, bold/italic/etc) also in a simple JSON format (so no dealing with color-related escape sequence either), and the frontend could render this using HTML (e.g. with styled pre/span tags, similar to how asciinema-player does it) or with SVG. * support subscribing to view updates, to avoid needing to poll (see [issue #9](https://github.com/andyk/ht/issues/9)) * native integration with asciinema for recording terminal sessions (see [issue #8](https://github.com/andyk/ht/issues/8)) diff --git a/src/api/http.rs b/src/api/http.rs index 425db02..aca9d75 100644 --- a/src/api/http.rs +++ b/src/api/http.rs @@ -86,7 +86,7 @@ async fn alis_message( use session::Event::*; match event { - Ok(Init(time, cols, rows, _pid, seq, _text)) => Some(Ok(json_message(json!({ + Ok(Init(time, cols, rows, _pid, seq, _text, _)) => Some(Ok(json_message(json!({ "time": time, "cols": cols, "rows": rows, @@ -101,7 +101,7 @@ async fn alis_message( format!("{cols}x{rows}") ])))), - Ok(Snapshot(_, _, _, _)) => None, + Ok(Snapshot(_, _, _, _, _)) => None, Err(e) => Some(Err(axum::Error::new(e))), } @@ -158,10 +158,10 @@ async fn event_stream_message( use session::Event::*; match event { - Ok(e @ Init(_, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))), + Ok(e @ Init(_, _, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))), Ok(e @ Output(_, _)) if sub.output => Some(Ok(json_message(e.to_json()))), Ok(e @ Resize(_, _, _)) if sub.resize => Some(Ok(json_message(e.to_json()))), - Ok(e @ Snapshot(_, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))), + Ok(e @ Snapshot(_, _, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))), Ok(_) => None, Err(e) => Some(Err(axum::Error::new(e))), } diff --git a/src/api/stdio.rs b/src/api/stdio.rs index 2aa2bf3..3e850b9 100644 --- a/src/api/stdio.rs +++ b/src/api/stdio.rs @@ -1,4 +1,5 @@ use super::Subscription; +use crate::cli::StyleMode; use crate::command::{self, Command, InputSeq}; use crate::session; use anyhow::Result; @@ -24,10 +25,16 @@ struct ResizeArgs { rows: usize, } +#[derive(Debug, Deserialize)] +struct SetStyleModeArgs { + mode: String, +} + pub async fn start( command_tx: mpsc::Sender, clients_tx: mpsc::Sender, sub: Subscription, + _color_mode: StyleMode, ) -> Result<()> { let (input_tx, mut input_rx) = mpsc::unbounded_channel(); thread::spawn(|| read_stdin(input_tx)); @@ -52,7 +59,7 @@ pub async fn start( use session::Event::*; match event { - Some(Ok(e @ Init(_, _, _, _, _, _))) if sub.init => { + Some(Ok(e @ Init(_, _, _, _, _, _, _))) if sub.init => { println!("{}", e.to_json()); } @@ -64,7 +71,7 @@ pub async fn start( println!("{}", e.to_json()); } - Some(Ok(e @ Snapshot(_, _, _, _))) if sub.snapshot => { + Some(Ok(e @ Snapshot(_, _, _, _, _))) if sub.snapshot => { println!("{}", e.to_json()); } @@ -113,6 +120,13 @@ fn build_command(value: serde_json::Value) -> Result { Some("takeSnapshot") => Ok(Command::Snapshot), + Some("setStyleMode") => { + let args: SetStyleModeArgs = args_from_json_value(value)?; + let style_mode = args.mode.parse::() + .map_err(|e| format!("invalid style mode: {}", e))?; + Ok(Command::SetStyleMode(style_mode)) + } + other => Err(format!("invalid command type: {other:?}")), } } @@ -282,6 +296,7 @@ fn parse_key(key: String) -> InputSeq { mod test { use super::{cursor_key, parse_line, standard_key, Command}; use crate::command::InputSeq; + use crate::cli::StyleMode; #[test] fn parse_input() { @@ -483,6 +498,21 @@ mod test { assert!(matches!(command, Command::Snapshot)); } + #[test] + fn parse_set_style_mode() { + let command = parse_line(r#"{ "type": "setStyleMode", "mode": "styled" }"#).unwrap(); + assert!(matches!(command, Command::SetStyleMode(StyleMode::Styled))); + + let command = parse_line(r#"{ "type": "setStyleMode", "mode": "plain" }"#).unwrap(); + assert!(matches!(command, Command::SetStyleMode(StyleMode::Plain))); + } + + #[test] + fn parse_set_style_mode_invalid() { + parse_line(r#"{ "type": "setStyleMode", "mode": "invalid" }"#).expect_err("should fail"); + parse_line(r#"{ "type": "setStyleMode" }"#).expect_err("should fail"); + } + #[test] fn parse_invalid_json() { parse_line("{").expect_err("should fail"); diff --git a/src/cli.rs b/src/cli.rs index ec92df1..ef453de 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,6 +4,25 @@ use clap::Parser; use nix::pty; use std::{fmt::Display, net::SocketAddr, ops::Deref, str::FromStr}; +#[derive(Debug, Clone, Copy, Default)] +pub enum StyleMode { + #[default] + Plain, + Styled, +} + +impl FromStr for StyleMode { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "plain" => Ok(StyleMode::Plain), + "styled" => Ok(StyleMode::Styled), + _ => Err(format!("invalid style mode: {s}. Valid options: plain, styled")), + } + } +} + #[derive(Debug, Parser)] #[clap(version, about)] #[command(name = "ht")] @@ -23,6 +42,10 @@ pub struct Cli { /// Subscribe to events #[arg(long, value_name = "EVENTS")] pub subscribe: Option, + + /// Style mode for snapshots + #[arg(short = 's', long = "style-mode", value_name = "MODE", default_value = "plain")] + pub style_mode: StyleMode, } impl Cli { diff --git a/src/command.rs b/src/command.rs index a9d29a7..07d0dfc 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1,8 +1,11 @@ +use crate::cli::StyleMode; + #[derive(Debug)] pub enum Command { Input(Vec), Snapshot, Resize(usize, usize), + SetStyleMode(StyleMode), } #[derive(Debug, PartialEq)] diff --git a/src/main.rs b/src/main.rs index e844e95..63a5086 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,23 +22,24 @@ async fn main() -> Result<()> { let (clients_tx, clients_rx) = mpsc::channel(1); start_http_api(cli.listen, clients_tx.clone()).await?; - let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default()); + let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default(), cli.style_mode); let (pid, pty) = start_pty(cli.command, &cli.size, input_rx, output_tx)?; - let session = build_session(&cli.size, pid); + let session = build_session(&cli.size, pid, cli.style_mode); run_event_loop(output_rx, input_tx, command_rx, clients_rx, session, api).await?; pty.await? } -fn build_session(size: &cli::Size, pid: i32) -> Session { - Session::new(size.cols(), size.rows(), pid) +fn build_session(size: &cli::Size, pid: i32, style_mode: cli::StyleMode) -> Session { + Session::new(size.cols(), size.rows(), pid, style_mode) } fn start_stdio_api( command_tx: mpsc::Sender, clients_tx: mpsc::Sender, sub: api::Subscription, + style_mode: cli::StyleMode, ) -> JoinHandle> { - tokio::spawn(api::stdio::start(command_tx, clients_tx, sub)) + tokio::spawn(api::stdio::start(command_tx, clients_tx, sub, style_mode)) } fn start_pty( @@ -106,6 +107,10 @@ async fn run_event_loop( session.resize(cols, rows); } + Some(Command::SetStyleMode(style_mode)) => { + session.set_style_mode(style_mode); + } + None => { eprintln!("stdin closed, shutting down..."); break; diff --git a/src/session.rs b/src/session.rs index 5dc59dd..b3b9a20 100644 --- a/src/session.rs +++ b/src/session.rs @@ -1,11 +1,57 @@ +use crate::cli::StyleMode; use anyhow::Result; +use avt::{Color, Pen}; use futures_util::{stream, Stream, StreamExt}; +use serde::Serialize; use serde_json::json; +use std::collections::HashMap; use std::future; use std::time::{Duration, Instant}; use tokio::sync::{broadcast, mpsc, oneshot}; use tokio_stream::wrappers::{errors::BroadcastStreamRecvError, BroadcastStream}; +#[derive(Debug, Clone, Serialize)] +pub struct PenJson { + #[serde(skip_serializing_if = "Option::is_none")] + fg: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bg: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + attrs: Vec, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum ColorJson { + Indexed { indexed: u8 }, + Rgb { rgb: [u8; 3] }, +} + +impl From<&Pen> for PenJson { + fn from(pen: &Pen) -> Self { + let mut attrs = Vec::new(); + if pen.is_bold() { attrs.push("bold".to_string()); } + if pen.is_faint() { attrs.push("faint".to_string()); } + if pen.is_italic() { attrs.push("italic".to_string()); } + if pen.is_underline() { attrs.push("underline".to_string()); } + if pen.is_strikethrough() { attrs.push("strikethrough".to_string()); } + if pen.is_blink() { attrs.push("blink".to_string()); } + if pen.is_inverse() { attrs.push("inverse".to_string()); } + + PenJson { + fg: pen.foreground().map(|c| match c { + Color::Indexed(i) => ColorJson::Indexed { indexed: i }, + Color::RGB(rgb) => ColorJson::Rgb { rgb: [rgb.r, rgb.g, rgb.b] }, + }), + bg: pen.background().map(|c| match c { + Color::Indexed(i) => ColorJson::Indexed { indexed: i }, + Color::RGB(rgb) => ColorJson::Rgb { rgb: [rgb.r, rgb.g, rgb.b] }, + }), + attrs, + } + } +} + pub struct Session { vt: avt::Vt, broadcast_tx: broadcast::Sender, @@ -13,14 +59,22 @@ pub struct Session { start_time: Instant, last_event_time: Instant, pid: i32, + style_mode: StyleMode, } #[derive(Clone)] pub enum Event { - Init(f64, usize, usize, i32, String, String), + Init(f64, usize, usize, i32, String, String, Option), Output(f64, String), Resize(f64, usize, usize), - Snapshot(usize, usize, String, String), + Snapshot(usize, usize, String, String, Option), +} + +#[derive(Clone)] +pub struct StyleData { + char_map: Vec>, + style_map: Vec>, + styles: HashMap, } pub struct Client(oneshot::Sender); @@ -31,7 +85,7 @@ pub struct Subscription { } impl Session { - pub fn new(cols: usize, rows: usize, pid: i32) -> Self { + pub fn new(cols: usize, rows: usize, pid: i32, style_mode: StyleMode) -> Self { let (broadcast_tx, _) = broadcast::channel(1024); let now = Instant::now(); @@ -42,6 +96,7 @@ impl Session { start_time: now, last_event_time: now, pid, + style_mode, } } @@ -63,12 +118,24 @@ impl Session { pub fn snapshot(&self) { let (cols, rows) = self.vt.size(); + let style_data = match self.style_mode { + StyleMode::Styled => { + let (pen_to_id, styles) = self.build_style_palette(); + Some(StyleData { + char_map: self.build_char_map(), + style_map: self.build_style_map(&pen_to_id), + styles, + }) + } + StyleMode::Plain => None, + }; let _ = self.broadcast_tx.send(Event::Snapshot( cols, rows, self.vt.dump(), self.text_view(), + style_data, )); } @@ -76,8 +143,23 @@ impl Session { self.vt.cursor_key_app_mode() } + pub fn set_style_mode(&mut self, style_mode: StyleMode) { + self.style_mode = style_mode; + } + pub fn subscribe(&self) -> Subscription { let (cols, rows) = self.vt.size(); + let style_data = match self.style_mode { + StyleMode::Styled => { + let (pen_to_id, styles) = self.build_style_palette(); + Some(StyleData { + char_map: self.build_char_map(), + style_map: self.build_style_map(&pen_to_id), + styles, + }) + } + StyleMode::Plain => None, + }; let init = Event::Init( self.elapsed_time(), @@ -86,6 +168,7 @@ impl Session { self.pid, self.vt.dump(), self.text_view(), + style_data, ); let broadcast_rx = self.broadcast_tx.subscribe(); @@ -105,21 +188,103 @@ impl Session { .collect::>() .join("\n") } + + fn build_style_palette(&self) -> (HashMap, HashMap) { + let mut pen_to_id = HashMap::new(); + let mut styles = HashMap::new(); + // Reserve ID 0 for default pen + let default_pen = Pen::default(); + let default_key = self.pen_to_key(&default_pen); + pen_to_id.insert(default_key, 0); + styles.insert("0".to_string(), PenJson::from(&default_pen)); + let mut next_id = 1; + + for line in self.vt.view() { + for cell in line.cells() { + if cell.width() > 0 { + let pen = *cell.pen(); + let pen_key = self.pen_to_key(&pen); + if !pen_to_id.contains_key(&pen_key) { + pen_to_id.insert(pen_key, next_id); + styles.insert(next_id.to_string(), PenJson::from(&pen)); + next_id += 1; + } + } + } + } + + (pen_to_id, styles) + } + + fn pen_to_key(&self, pen: &Pen) -> String { + // Create a unique string key for the pen + format!("{:?}", pen) + } + + fn build_char_map(&self) -> Vec> { + let (cols, _rows) = self.vt.size(); + self.vt + .view() + .into_iter() + .map(|line| { + let mut char_row = Vec::with_capacity(cols); + for cell in line.cells() { + char_row.push(cell.char()); + } + // Ensure we have exactly cols characters, pad with spaces if needed + char_row.resize(cols, ' '); + char_row + }) + .collect() + } + + fn build_style_map(&self, pen_to_id: &HashMap) -> Vec> { + let (cols, _rows) = self.vt.size(); + self.vt + .view() + .into_iter() + .map(|line| { + let mut style_row = Vec::with_capacity(cols); + for cell in line.cells() { + let pen_key = self.pen_to_key(cell.pen()); + style_row.push(*pen_to_id.get(&pen_key).unwrap_or(&0)); + } + // Ensure we have exactly cols style IDs, pad with default style if needed + style_row.resize(cols, 0); + style_row + }) + .collect() + } } impl Event { pub fn to_json(&self) -> serde_json::Value { + self.to_json_with_style_mode(&StyleMode::Plain) + } + + pub fn to_json_with_style_mode(&self, _style_mode: &StyleMode) -> serde_json::Value { match self { - Event::Init(_time, cols, rows, pid, seq, text) => json!({ - "type": "init", - "data": json!({ + Event::Init(_time, cols, rows, pid, seq, text, style_data) => { + let mut data = json!({ "cols": cols, "rows": rows, "pid": pid, "seq": seq, "text": text, + }); + + if let Some(style_data) = style_data { + let data_obj = data.as_object_mut().unwrap(); + data_obj.insert("charMap".to_string(), json!(style_data.char_map)); + data_obj.insert("styleMap".to_string(), json!(style_data.style_map)); + data_obj.insert("styles".to_string(), json!(style_data.styles)); + } + + json!({ + "type": "init", + "data": data }) - }), + }, Event::Output(_time, seq) => json!({ "type": "output", @@ -136,15 +301,26 @@ impl Event { }) }), - Event::Snapshot(cols, rows, seq, text) => json!({ - "type": "snapshot", - "data": json!({ + Event::Snapshot(cols, rows, seq, text, style_data) => { + let mut data = json!({ "cols": cols, "rows": rows, "seq": seq, "text": text, + }); + + if let Some(style_data) = style_data { + let data_obj = data.as_object_mut().unwrap(); + data_obj.insert("charMap".to_string(), json!(style_data.char_map)); + data_obj.insert("styleMap".to_string(), json!(style_data.style_map)); + data_obj.insert("styles".to_string(), json!(style_data.styles)); + } + + json!({ + "type": "snapshot", + "data": data }) - }), + }, } } }