Skip to content

Commit 67e1864

Browse files
added --style-mode, now captures colors and such
1 parent ed569e9 commit 67e1864

File tree

7 files changed

+314
-23
lines changed

7 files changed

+314
-23
lines changed

README.md

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,19 @@ Default size of the virtual terminal window is 120x40 (cols by rows), which can
6565
be changed with `--size` argument. For example: `ht --size 80x24`. The window
6666
size can also be dynamically changed - see [resize command](#resize) below.
6767

68+
### Style Support
69+
70+
ht supports capturing and returning terminal styling information (colors, bold, italic, etc.). Use the
71+
`--style-mode` option to enable styled output:
72+
73+
- `--style-mode plain` (default) - Returns only plain text in snapshots
74+
- `--style-mode styled` - Includes styling information in snapshots
75+
76+
Example:
77+
```sh
78+
ht --style-mode styled --subscribe snapshot
79+
```
80+
6881
Run `ht -h` or `ht --help` to see all available options.
6982

7083
## Live terminal preview
@@ -205,6 +218,18 @@ specifying new width (`cols`) and height (`rows`).
205218

206219
This command triggers `resize` event.
207220

221+
#### setStyleMode
222+
223+
`setStyleMode` command allows changing the style mode during runtime to enable or
224+
disable styled output in snapshots.
225+
226+
```json
227+
{ "type": "setStyleMode", "mode": "styled" }
228+
{ "type": "setStyleMode", "mode": "plain" }
229+
```
230+
231+
This command doesn't trigger any event but affects subsequent snapshots.
232+
208233
### WebSocket API
209234

210235
The WebSocket API currently provides 2 endpoints:
@@ -280,6 +305,36 @@ Event data is an object with the following fields:
280305
- `text` - plain text snapshot as multi-line string, where each line represents a terminal row
281306
- `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)
282307

308+
When color mode is set to `styled`, additional fields are included:
309+
310+
- `charMap` - 2D array of characters, where `charMap[row][col]` gives the character at that position
311+
- `styleMap` - 2D array of style IDs, where `styleMap[row][col]` gives the style ID for the character at that position
312+
- `styles` - object mapping style IDs to style definitions with `fg`, `bg`, and `attrs` fields
313+
314+
Example styled snapshot for `$ ls foo/b*`:
315+
```json
316+
{
317+
"type": "snapshot",
318+
"data": {
319+
"cols": 15, "rows": 2,
320+
"text": "$ ls foo/b*\nbar.txt baz.sh",
321+
"charMap": [
322+
["$", " ", "l", "s", " ", "f", "o", "o", "/", "b", "*", " ", " ", " ", " "],
323+
["b", "a", "r", ".", "t", "x", "t", " ", " ", "b", "a", "z", ".", "s", "h"]
324+
],
325+
"styleMap": [
326+
[1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
327+
[0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 2, 2, 2, 2, 2]
328+
],
329+
"styles": {
330+
"0": {},
331+
"1": {"fg": {"indexed": 2}},
332+
"2": {"fg": {"rgb": [0, 255, 127]}, "attrs": ["bold"]}
333+
}
334+
}
335+
}
336+
```
337+
283338
## Testing on command line
284339

285340
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.
303358

304359
## Possible future work
305360

306-
* 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.
307361
* support subscribing to view updates, to avoid needing to poll (see [issue #9](https://github.com/andyk/ht/issues/9))
308362
* native integration with asciinema for recording terminal sessions (see [issue #8](https://github.com/andyk/ht/issues/8))
309363

src/api/http.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ async fn alis_message(
8686
use session::Event::*;
8787

8888
match event {
89-
Ok(Init(time, cols, rows, _pid, seq, _text)) => Some(Ok(json_message(json!({
89+
Ok(Init(time, cols, rows, _pid, seq, _text, _)) => Some(Ok(json_message(json!({
9090
"time": time,
9191
"cols": cols,
9292
"rows": rows,
@@ -101,7 +101,7 @@ async fn alis_message(
101101
format!("{cols}x{rows}")
102102
])))),
103103

104-
Ok(Snapshot(_, _, _, _)) => None,
104+
Ok(Snapshot(_, _, _, _, _)) => None,
105105

106106
Err(e) => Some(Err(axum::Error::new(e))),
107107
}
@@ -158,10 +158,10 @@ async fn event_stream_message(
158158
use session::Event::*;
159159

160160
match event {
161-
Ok(e @ Init(_, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))),
161+
Ok(e @ Init(_, _, _, _, _, _, _)) if sub.init => Some(Ok(json_message(e.to_json()))),
162162
Ok(e @ Output(_, _)) if sub.output => Some(Ok(json_message(e.to_json()))),
163163
Ok(e @ Resize(_, _, _)) if sub.resize => Some(Ok(json_message(e.to_json()))),
164-
Ok(e @ Snapshot(_, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))),
164+
Ok(e @ Snapshot(_, _, _, _, _)) if sub.snapshot => Some(Ok(json_message(e.to_json()))),
165165
Ok(_) => None,
166166
Err(e) => Some(Err(axum::Error::new(e))),
167167
}

src/api/stdio.rs

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use super::Subscription;
2+
use crate::cli::StyleMode;
23
use crate::command::{self, Command, InputSeq};
34
use crate::session;
45
use anyhow::Result;
@@ -24,10 +25,16 @@ struct ResizeArgs {
2425
rows: usize,
2526
}
2627

28+
#[derive(Debug, Deserialize)]
29+
struct SetStyleModeArgs {
30+
mode: String,
31+
}
32+
2733
pub async fn start(
2834
command_tx: mpsc::Sender<Command>,
2935
clients_tx: mpsc::Sender<session::Client>,
3036
sub: Subscription,
37+
_color_mode: StyleMode,
3138
) -> Result<()> {
3239
let (input_tx, mut input_rx) = mpsc::unbounded_channel();
3340
thread::spawn(|| read_stdin(input_tx));
@@ -52,7 +59,7 @@ pub async fn start(
5259
use session::Event::*;
5360

5461
match event {
55-
Some(Ok(e @ Init(_, _, _, _, _, _))) if sub.init => {
62+
Some(Ok(e @ Init(_, _, _, _, _, _, _))) if sub.init => {
5663
println!("{}", e.to_json());
5764
}
5865

@@ -64,7 +71,7 @@ pub async fn start(
6471
println!("{}", e.to_json());
6572
}
6673

67-
Some(Ok(e @ Snapshot(_, _, _, _))) if sub.snapshot => {
74+
Some(Ok(e @ Snapshot(_, _, _, _, _))) if sub.snapshot => {
6875
println!("{}", e.to_json());
6976
}
7077

@@ -113,6 +120,13 @@ fn build_command(value: serde_json::Value) -> Result<Command, String> {
113120

114121
Some("takeSnapshot") => Ok(Command::Snapshot),
115122

123+
Some("setStyleMode") => {
124+
let args: SetStyleModeArgs = args_from_json_value(value)?;
125+
let style_mode = args.mode.parse::<StyleMode>()
126+
.map_err(|e| format!("invalid style mode: {}", e))?;
127+
Ok(Command::SetStyleMode(style_mode))
128+
}
129+
116130
other => Err(format!("invalid command type: {other:?}")),
117131
}
118132
}
@@ -282,6 +296,7 @@ fn parse_key(key: String) -> InputSeq {
282296
mod test {
283297
use super::{cursor_key, parse_line, standard_key, Command};
284298
use crate::command::InputSeq;
299+
use crate::cli::StyleMode;
285300

286301
#[test]
287302
fn parse_input() {
@@ -483,6 +498,21 @@ mod test {
483498
assert!(matches!(command, Command::Snapshot));
484499
}
485500

501+
#[test]
502+
fn parse_set_style_mode() {
503+
let command = parse_line(r#"{ "type": "setStyleMode", "mode": "styled" }"#).unwrap();
504+
assert!(matches!(command, Command::SetStyleMode(StyleMode::Styled)));
505+
506+
let command = parse_line(r#"{ "type": "setStyleMode", "mode": "plain" }"#).unwrap();
507+
assert!(matches!(command, Command::SetStyleMode(StyleMode::Plain)));
508+
}
509+
510+
#[test]
511+
fn parse_set_style_mode_invalid() {
512+
parse_line(r#"{ "type": "setStyleMode", "mode": "invalid" }"#).expect_err("should fail");
513+
parse_line(r#"{ "type": "setStyleMode" }"#).expect_err("should fail");
514+
}
515+
486516
#[test]
487517
fn parse_invalid_json() {
488518
parse_line("{").expect_err("should fail");

src/cli.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ use clap::Parser;
44
use nix::pty;
55
use std::{fmt::Display, net::SocketAddr, ops::Deref, str::FromStr};
66

7+
#[derive(Debug, Clone, Copy, Default)]
8+
pub enum StyleMode {
9+
#[default]
10+
Plain,
11+
Styled,
12+
}
13+
14+
impl FromStr for StyleMode {
15+
type Err = String;
16+
17+
fn from_str(s: &str) -> Result<Self, Self::Err> {
18+
match s.to_lowercase().as_str() {
19+
"plain" => Ok(StyleMode::Plain),
20+
"styled" => Ok(StyleMode::Styled),
21+
_ => Err(format!("invalid style mode: {s}. Valid options: plain, styled")),
22+
}
23+
}
24+
}
25+
726
#[derive(Debug, Parser)]
827
#[clap(version, about)]
928
#[command(name = "ht")]
@@ -23,6 +42,10 @@ pub struct Cli {
2342
/// Subscribe to events
2443
#[arg(long, value_name = "EVENTS")]
2544
pub subscribe: Option<Subscription>,
45+
46+
/// Style mode for snapshots
47+
#[arg(short = 's', long = "style-mode", value_name = "MODE", default_value = "plain")]
48+
pub style_mode: StyleMode,
2649
}
2750

2851
impl Cli {

src/command.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use crate::cli::StyleMode;
2+
13
#[derive(Debug)]
24
pub enum Command {
35
Input(Vec<InputSeq>),
46
Snapshot,
57
Resize(usize, usize),
8+
SetStyleMode(StyleMode),
69
}
710

811
#[derive(Debug, PartialEq)]

src/main.rs

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,23 +22,24 @@ async fn main() -> Result<()> {
2222
let (clients_tx, clients_rx) = mpsc::channel(1);
2323

2424
start_http_api(cli.listen, clients_tx.clone()).await?;
25-
let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default());
25+
let api = start_stdio_api(command_tx, clients_tx, cli.subscribe.unwrap_or_default(), cli.style_mode);
2626
let (pid, pty) = start_pty(cli.command, &cli.size, input_rx, output_tx)?;
27-
let session = build_session(&cli.size, pid);
27+
let session = build_session(&cli.size, pid, cli.style_mode);
2828
run_event_loop(output_rx, input_tx, command_rx, clients_rx, session, api).await?;
2929
pty.await?
3030
}
3131

32-
fn build_session(size: &cli::Size, pid: i32) -> Session {
33-
Session::new(size.cols(), size.rows(), pid)
32+
fn build_session(size: &cli::Size, pid: i32, style_mode: cli::StyleMode) -> Session {
33+
Session::new(size.cols(), size.rows(), pid, style_mode)
3434
}
3535

3636
fn start_stdio_api(
3737
command_tx: mpsc::Sender<Command>,
3838
clients_tx: mpsc::Sender<session::Client>,
3939
sub: api::Subscription,
40+
style_mode: cli::StyleMode,
4041
) -> JoinHandle<Result<()>> {
41-
tokio::spawn(api::stdio::start(command_tx, clients_tx, sub))
42+
tokio::spawn(api::stdio::start(command_tx, clients_tx, sub, style_mode))
4243
}
4344

4445
fn start_pty(
@@ -106,6 +107,10 @@ async fn run_event_loop(
106107
session.resize(cols, rows);
107108
}
108109

110+
Some(Command::SetStyleMode(style_mode)) => {
111+
session.set_style_mode(style_mode);
112+
}
113+
109114
None => {
110115
eprintln!("stdin closed, shutting down...");
111116
break;

0 commit comments

Comments
 (0)