Add asciicast v3 support, record and convert to v3 by default

This commit is contained in:
Marcin Kulik
2025-04-24 11:00:14 +02:00
parent 14b3746971
commit c2b3936d1a
13 changed files with 616 additions and 86 deletions

View File

@@ -1,6 +1,7 @@
mod util;
mod v1;
mod v2;
mod v3;
use std::collections::HashMap;
use std::fs;
@@ -10,7 +11,8 @@ use std::path::Path;
use anyhow::{anyhow, Result};
use crate::tty::TtyTheme;
pub use v2::Encoder;
pub use v2::V2Encoder;
pub use v3::V3Encoder;
pub struct Asciicast<'a> {
pub header: Header,
@@ -18,14 +20,16 @@ pub struct Asciicast<'a> {
}
pub struct Header {
pub cols: u16,
pub rows: u16,
pub term_cols: u16,
pub term_rows: u16,
pub term_type: Option<String>,
pub term_version: Option<String>,
pub term_theme: Option<TtyTheme>,
pub timestamp: Option<u64>,
pub idle_time_limit: Option<f64>,
pub command: Option<String>,
pub title: Option<String>,
pub env: Option<HashMap<String, String>>,
pub theme: Option<TtyTheme>,
}
pub struct Event {
@@ -44,14 +48,16 @@ pub enum EventData {
impl Default for Header {
fn default() -> Self {
Self {
cols: 80,
rows: 24,
term_cols: 80,
term_rows: 24,
term_type: None,
term_version: None,
term_theme: None,
timestamp: None,
idle_time_limit: None,
command: None,
title: None,
env: None,
theme: None,
}
}
}
@@ -68,14 +74,16 @@ pub fn open<'a, R: BufRead + 'a>(reader: R) -> Result<Asciicast<'a>> {
let mut lines = reader.lines();
let first_line = lines.next().ok_or(anyhow!("empty file"))??;
if let Ok(parser) = v2::open(&first_line) {
if let Ok(parser) = v3::open(&first_line) {
Ok(parser.parse(lines))
} else if let Ok(parser) = v2::open(&first_line) {
Ok(parser.parse(lines))
} else {
let json = std::iter::once(Ok(first_line))
.chain(lines)
.collect::<io::Result<String>>()?;
v1::load(json)
v1::load(json).map_err(|_| anyhow!("not a v1, v2, v3 asciicast file"))
}
}
@@ -155,7 +163,7 @@ pub fn accelerate(
#[cfg(test)]
mod tests {
use super::{Asciicast, Encoder, Event, EventData, Header};
use super::{Asciicast, Event, EventData, Header, V2Encoder};
use crate::tty::TtyTheme;
use anyhow::Result;
use rgb::RGB8;
@@ -168,8 +176,8 @@ mod tests {
let events = events.collect::<Result<Vec<Event>>>().unwrap();
assert_eq!((header.cols, header.rows), (100, 50));
assert!(header.theme.is_none());
assert_eq!((header.term_cols, header.term_rows), (100, 50));
assert!(header.term_theme.is_none());
assert_eq!(events[0].time, 1230000);
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello"));
@@ -180,7 +188,7 @@ mod tests {
let Asciicast { header, events } = super::open_from_path("tests/casts/full.json").unwrap();
let events = events.collect::<Result<Vec<Event>>>().unwrap();
assert_eq!((header.cols, header.rows), (100, 50));
assert_eq!((header.term_cols, header.term_rows), (100, 50));
assert_eq!(events[0].time, 1);
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "ż"));
@@ -198,8 +206,8 @@ mod tests {
super::open_from_path("tests/casts/minimal.cast").unwrap();
let events = events.collect::<Result<Vec<Event>>>().unwrap();
assert_eq!((header.cols, header.rows), (100, 50));
assert!(header.theme.is_none());
assert_eq!((header.term_cols, header.term_rows), (100, 50));
assert!(header.term_theme.is_none());
assert_eq!(events[0].time, 1230000);
assert!(matches!(events[0].data, EventData::Output(ref s) if s == "hello"));
@@ -209,9 +217,9 @@ mod tests {
fn open_v2_full() {
let Asciicast { header, events } = super::open_from_path("tests/casts/full.cast").unwrap();
let events = events.take(5).collect::<Result<Vec<Event>>>().unwrap();
let theme = header.theme.unwrap();
let theme = header.term_theme.unwrap();
assert_eq!((header.cols, header.rows), (100, 50));
assert_eq!((header.term_cols, header.term_rows), (100, 50));
assert_eq!(theme.fg, RGB8::new(0, 0, 0));
assert_eq!(theme.bg, RGB8::new(0xff, 0xff, 0xff));
assert_eq!(theme.palette[0], RGB8::new(0x24, 0x1f, 0x31));
@@ -237,23 +245,12 @@ mod tests {
#[test]
fn encoder() {
let mut data = Vec::new();
let header = Header {
cols: 80,
rows: 24,
timestamp: None,
idle_time_limit: None,
command: None,
title: None,
env: Default::default(),
theme: None,
};
let mut enc = Encoder::new(0);
let header = Header::default();
let mut enc = V2Encoder::new(0);
data.extend(enc.header(&header));
data.extend(enc.event(&Event::output(1000000, "hello\r\n".to_owned())));
let mut enc = Encoder::new(1000001);
let mut enc = V2Encoder::new(1000001);
data.extend(enc.event(&Event::output(1000001, "world".to_owned())));
data.extend(enc.event(&Event::input(2000002, " ".to_owned())));
data.extend(enc.event(&Event::resize(3000003, (100, 40))));
@@ -284,7 +281,7 @@ mod tests {
#[test]
fn header_encoding() {
let mut enc = Encoder::new(0);
let mut enc = V2Encoder::new(0);
let mut env = HashMap::new();
env.insert("SHELL".to_owned(), "/usr/bin/fish".to_owned());
env.insert("TERM".to_owned(), "xterm256-color".to_owned());
@@ -313,14 +310,13 @@ mod tests {
};
let header = Header {
cols: 80,
rows: 24,
timestamp: Some(1704719152),
idle_time_limit: Some(1.5),
command: Some("/bin/bash".to_owned()),
title: Some("Demo".to_owned()),
env: Some(env),
theme: Some(theme),
term_theme: Some(theme),
..Default::default()
};
let data = enc.header(&header);

View File

@@ -31,15 +31,23 @@ pub fn load(json: String) -> Result<Asciicast<'static>> {
bail!("unsupported asciicast version")
}
let term_type = asciicast
.env
.as_ref()
.and_then(|env| env.get("TERM"))
.cloned();
let header = Header {
cols: asciicast.width,
rows: asciicast.height,
term_cols: asciicast.width,
term_rows: asciicast.height,
term_type,
term_version: None,
term_theme: None,
timestamp: None,
idle_time_limit: None,
command: asciicast.command.clone(),
title: asciicast.title.clone(),
env: asciicast.env.clone(),
theme: None,
};
let events = Box::new(

View File

@@ -61,26 +61,28 @@ pub fn open(header_line: &str) -> Result<Parser> {
let header = serde_json::from_str::<V2Header>(header_line)?;
if header.version != 2 {
bail!("unsupported asciicast version")
bail!("not an asciicast v2 file")
}
Ok(Parser(header))
}
impl Parser {
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(
&self,
lines: I,
) -> Asciicast<'a> {
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(self, lines: I) -> Asciicast<'a> {
let term_type = self.0.env.as_ref().and_then(|env| env.get("TERM").cloned());
let term_theme = self.0.theme.as_ref().map(|t| t.into());
let header = Header {
cols: self.0.width,
rows: self.0.height,
term_cols: self.0.width,
term_rows: self.0.height,
term_type,
term_version: None,
term_theme,
timestamp: self.0.timestamp,
idle_time_limit: self.0.idle_time_limit,
command: self.0.command.clone(),
title: self.0.title.clone(),
env: self.0.env.clone(),
theme: self.0.theme.as_ref().map(|t| t.into()),
};
let events = Box::new(lines.filter_map(parse_line));
@@ -104,7 +106,8 @@ fn parse_line(line: io::Result<String>) -> Option<Result<Event>> {
}
fn parse_event(line: String) -> Result<Event> {
let event = serde_json::from_str::<V2Event>(&line)?;
let event = serde_json::from_str::<V2Event>(&line)
.map_err(|e| anyhow!("asciicast v2 parse error: {e}"))?;
let data = match event.code {
V2EventCode::Output => EventData::Output(event.data),
@@ -157,11 +160,11 @@ where
}
}
pub struct Encoder {
pub struct V2Encoder {
time_offset: u64,
}
impl Encoder {
impl V2Encoder {
pub fn new(time_offset: u64) -> Self {
Self { time_offset }
}
@@ -356,14 +359,14 @@ impl From<&Header> for V2Header {
fn from(header: &Header) -> Self {
V2Header {
version: 2,
width: header.cols,
height: header.rows,
width: header.term_cols,
height: header.term_rows,
timestamp: header.timestamp,
idle_time_limit: header.idle_time_limit,
command: header.command.clone(),
title: header.title.clone(),
env: header.env.clone(),
theme: header.theme.as_ref().map(|t| t.into()),
theme: header.term_theme.as_ref().map(|t| t.into()),
}
}
}

461
src/asciicast/v3.rs Normal file
View File

@@ -0,0 +1,461 @@
use std::collections::HashMap;
use std::fmt;
use std::io;
use anyhow::{anyhow, bail, Result};
use serde::{Deserialize, Deserializer, Serialize};
use super::{util, Asciicast, Event, EventData, Header};
use crate::tty::TtyTheme;
#[derive(Deserialize)]
struct V3Header {
version: u8,
term: V3Term,
timestamp: Option<u64>,
idle_time_limit: Option<f64>,
command: Option<String>,
title: Option<String>,
env: Option<HashMap<String, String>>,
}
#[derive(Deserialize)]
struct V3Term {
cols: u16,
rows: u16,
#[serde(rename = "type")]
type_: Option<String>,
version: Option<String>,
theme: Option<V3Theme>,
}
#[derive(Deserialize, Serialize, Clone)]
struct V3Theme {
#[serde(deserialize_with = "deserialize_color")]
fg: RGB8,
#[serde(deserialize_with = "deserialize_color")]
bg: RGB8,
#[serde(deserialize_with = "deserialize_palette")]
palette: V3Palette,
}
#[derive(Clone)]
struct RGB8(rgb::RGB8);
#[derive(Clone)]
struct V3Palette(Vec<RGB8>);
#[derive(Debug, Deserialize)]
struct V3Event {
#[serde(deserialize_with = "util::deserialize_time")]
time: u64,
#[serde(deserialize_with = "deserialize_code")]
code: V3EventCode,
data: String,
}
#[derive(PartialEq, Debug)]
enum V3EventCode {
Output,
Input,
Resize,
Marker,
Other(char),
}
pub struct Parser {
header: V3Header,
prev_time: u64,
}
pub fn open(header_line: &str) -> Result<Parser> {
let header = serde_json::from_str::<V3Header>(header_line)?;
if header.version != 3 {
bail!("not an asciicast v3 file")
}
Ok(Parser {
header,
prev_time: 0,
})
}
impl Parser {
pub fn parse<'a, I: Iterator<Item = io::Result<String>> + 'a>(
mut self,
lines: I,
) -> Asciicast<'a> {
let term_theme = self.header.term.theme.as_ref().map(|t| t.into());
let header = Header {
term_cols: self.header.term.cols,
term_rows: self.header.term.rows,
term_type: self.header.term.type_.clone(),
term_version: self.header.term.version.clone(),
term_theme,
timestamp: self.header.timestamp,
idle_time_limit: self.header.idle_time_limit,
command: self.header.command.clone(),
title: self.header.title.clone(),
env: self.header.env.clone(),
};
let events = Box::new(lines.filter_map(move |line| self.parse_line(line)));
Asciicast { header, events }
}
fn parse_line(&mut self, line: io::Result<String>) -> Option<Result<Event>> {
match line {
Ok(line) => {
if line.is_empty() || line.starts_with("#") {
None
} else {
Some(self.parse_event(line))
}
}
Err(e) => Some(Err(e.into())),
}
}
fn parse_event(&mut self, line: String) -> Result<Event> {
let event = serde_json::from_str::<V3Event>(&line)
.map_err(|e| anyhow!("asciicast v3 parse error: {e}"))?;
let data = match event.code {
V3EventCode::Output => EventData::Output(event.data),
V3EventCode::Input => EventData::Input(event.data),
V3EventCode::Resize => match event.data.split_once('x') {
Some((cols, rows)) => {
let cols: u16 = cols
.parse()
.map_err(|e| anyhow!("invalid cols value in resize event: {e}"))?;
let rows: u16 = rows
.parse()
.map_err(|e| anyhow!("invalid rows value in resize event: {e}"))?;
EventData::Resize(cols, rows)
}
None => {
bail!("invalid size value in resize event");
}
},
V3EventCode::Marker => EventData::Marker(event.data),
V3EventCode::Other(c) => EventData::Other(c, event.data),
};
let time = self.prev_time + event.time;
self.prev_time = time;
Ok(Event { time, data })
}
}
fn deserialize_code<'de, D>(deserializer: D) -> Result<V3EventCode, D::Error>
where
D: Deserializer<'de>,
{
use serde::de::Error;
use V3EventCode::*;
let value: &str = Deserialize::deserialize(deserializer)?;
match value {
"o" => Ok(Output),
"i" => Ok(Input),
"r" => Ok(Resize),
"m" => Ok(Marker),
"" => Err(Error::custom("missing event code")),
s => Ok(Other(s.chars().next().unwrap())),
}
}
pub struct V3Encoder {
prev_time: u64,
}
impl V3Encoder {
pub fn new() -> Self {
Self { prev_time: 0 }
}
pub fn header(&mut self, header: &Header) -> Vec<u8> {
let header: V3Header = header.into();
let mut data = serde_json::to_string(&header).unwrap().into_bytes();
data.push(b'\n');
data
}
pub fn event(&mut self, event: &Event) -> Vec<u8> {
let mut data = self.serialize_event(event).unwrap().into_bytes();
data.push(b'\n');
data
}
fn serialize_event(&mut self, event: &Event) -> Result<String, serde_json::Error> {
use EventData::*;
let (code, data) = match &event.data {
Output(data) => ('o', serde_json::to_string(data)?),
Input(data) => ('i', serde_json::to_string(data)?),
Resize(cols, rows) => ('r', serde_json::to_string(&format!("{cols}x{rows}"))?),
Marker(data) => ('m', serde_json::to_string(data)?),
Other(code, data) => (*code, serde_json::to_string(data)?),
};
let time = event.time - self.prev_time;
self.prev_time = event.time;
Ok(format!(
"[{}, {}, {}]",
format_time(time),
serde_json::to_string(&code)?,
data,
))
}
}
fn format_time(time: u64) -> String {
let mut formatted_time = format!("{}.{:0>6}", time / 1_000_000, time % 1_000_000);
let dot_idx = formatted_time.find('.').unwrap();
for idx in (dot_idx + 2..=formatted_time.len() - 1).rev() {
if formatted_time.as_bytes()[idx] != b'0' {
break;
}
formatted_time.truncate(idx);
}
formatted_time
}
impl serde::Serialize for V3Header {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut len = 2;
if self.timestamp.is_some() {
len += 1;
}
if self.idle_time_limit.is_some() {
len += 1;
}
if self.command.is_some() {
len += 1;
}
if self.title.is_some() {
len += 1;
}
if self.env.as_ref().is_some_and(|env| !env.is_empty()) {
len += 1;
}
let mut map = serializer.serialize_map(Some(len))?;
map.serialize_entry("version", &3)?;
map.serialize_entry("term", &self.term)?;
if let Some(timestamp) = self.timestamp {
map.serialize_entry("timestamp", &timestamp)?;
}
if let Some(limit) = self.idle_time_limit {
map.serialize_entry("idle_time_limit", &limit)?;
}
if let Some(command) = &self.command {
map.serialize_entry("command", &command)?;
}
if let Some(title) = &self.title {
map.serialize_entry("title", &title)?;
}
if let Some(env) = &self.env {
if !env.is_empty() {
map.serialize_entry("env", &env)?;
}
}
map.end()
}
}
impl serde::Serialize for V3Term {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeMap;
let mut len = 2;
if self.type_.is_some() {
len += 1;
}
if self.version.is_some() {
len += 1;
}
if self.theme.is_some() {
len += 1;
}
let mut map = serializer.serialize_map(Some(len))?;
map.serialize_entry("cols", &self.cols)?;
map.serialize_entry("rows", &self.rows)?;
if let Some(type_) = &self.type_ {
map.serialize_entry("type", &type_)?;
}
if let Some(version) = &self.version {
map.serialize_entry("version", &version)?;
}
if let Some(theme) = &self.theme {
map.serialize_entry("theme", &theme)?;
}
map.end()
}
}
fn deserialize_color<'de, D>(deserializer: D) -> Result<RGB8, D::Error>
where
D: Deserializer<'de>,
{
let value: &str = Deserialize::deserialize(deserializer)?;
parse_hex_color(value).ok_or(serde::de::Error::custom("invalid hex triplet"))
}
fn parse_hex_color(rgb: &str) -> Option<RGB8> {
if rgb.len() != 7 {
return None;
}
let r = u8::from_str_radix(&rgb[1..3], 16).ok()?;
let g = u8::from_str_radix(&rgb[3..5], 16).ok()?;
let b = u8::from_str_radix(&rgb[5..7], 16).ok()?;
Some(RGB8(rgb::RGB8::new(r, g, b)))
}
fn deserialize_palette<'de, D>(deserializer: D) -> Result<V3Palette, D::Error>
where
D: Deserializer<'de>,
{
let value: &str = Deserialize::deserialize(deserializer)?;
let mut colors: Vec<RGB8> = value.split(':').filter_map(parse_hex_color).collect();
let len = colors.len();
if len == 8 {
colors.extend_from_within(..);
} else if len != 16 {
return Err(serde::de::Error::custom("expected 8 or 16 hex triplets"));
}
Ok(V3Palette(colors))
}
impl serde::Serialize for RGB8 {
fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&self.to_string())
}
}
impl fmt::Display for RGB8 {
fn fmt(&self, f: &mut std::fmt::Formatter) -> fmt::Result {
write!(f, "#{:0>2x}{:0>2x}{:0>2x}", self.0.r, self.0.g, self.0.b)
}
}
impl serde::Serialize for V3Palette {
fn serialize<S>(&self, serializer: S) -> std::prelude::v1::Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
let palette = self
.0
.iter()
.map(|c| c.to_string())
.collect::<Vec<_>>()
.join(":");
serializer.serialize_str(&palette)
}
}
impl From<&Header> for V3Header {
fn from(header: &Header) -> Self {
V3Header {
version: 3,
term: V3Term {
cols: header.term_cols,
rows: header.term_rows,
type_: header.term_type.clone(),
version: header.term_version.clone(),
theme: header.term_theme.as_ref().map(|t| t.into()),
},
timestamp: header.timestamp,
idle_time_limit: header.idle_time_limit,
command: header.command.clone(),
title: header.title.clone(),
env: header.env.clone(),
}
}
}
impl From<&TtyTheme> for V3Theme {
fn from(theme: &TtyTheme) -> Self {
let palette = theme.palette.iter().copied().map(RGB8).collect();
V3Theme {
fg: RGB8(theme.fg),
bg: RGB8(theme.bg),
palette: V3Palette(palette),
}
}
}
impl From<&V3Theme> for TtyTheme {
fn from(theme: &V3Theme) -> Self {
let palette = theme.palette.0.iter().map(|c| c.0).collect();
TtyTheme {
fg: theme.fg.0,
bg: theme.bg.0,
palette,
}
}
}
#[cfg(test)]
mod tests {
#[test]
fn format_time() {
assert_eq!(super::format_time(0), "0.0");
assert_eq!(super::format_time(1000001), "1.000001");
assert_eq!(super::format_time(12300000), "12.3");
assert_eq!(super::format_time(12000003), "12.000003");
}
}

View File

@@ -62,7 +62,7 @@ pub struct Record {
#[arg(short, long)]
pub append: bool,
/// Recording file format [default: asciicast]
/// Recording file format [default: asciicast-v3]
#[arg(short, long, value_enum)]
pub format: Option<Format>,
@@ -179,7 +179,7 @@ pub struct Session {
#[arg(short, long)]
pub append: bool,
/// Recording file format [default: asciicast]
/// Recording file format [default: asciicast-v3]
#[arg(short, long, value_enum)]
pub format: Option<Format>,
@@ -241,7 +241,7 @@ pub struct Convert {
pub output_filename: String,
/// Output file format [default: asciicast]
/// Output file format [default: asciicast-v3]
#[arg(short, long, value_enum)]
pub format: Option<Format>,
@@ -261,7 +261,8 @@ pub struct Auth {}
#[derive(Clone, Copy, Debug, PartialEq, ValueEnum)]
pub enum Format {
Asciicast,
AsciicastV3,
AsciicastV2,
Raw,
Txt,
}

View File

@@ -9,7 +9,7 @@ use crate::config::Config;
impl cli::Cat {
pub fn run(self, _config: &Config) -> Result<()> {
let mut encoder = asciicast::Encoder::new(0);
let mut encoder = asciicast::V2Encoder::new(0);
let mut stdout = io::stdout();
let mut time_offset: u64 = 0;
let mut first = true;

View File

@@ -6,7 +6,9 @@ use anyhow::{bail, Result};
use crate::asciicast;
use crate::cli::{self, Format};
use crate::config::Config;
use crate::encoder::{self, AsciicastEncoder, EncoderExt, RawEncoder, TextEncoder};
use crate::encoder::{
self, AsciicastV2Encoder, AsciicastV3Encoder, EncoderExt, RawEncoder, TextEncoder,
};
use crate::util;
impl cli::Convert {
@@ -24,12 +26,13 @@ impl cli::Convert {
if self.output_filename.to_lowercase().ends_with(".txt") {
Format::Txt
} else {
Format::Asciicast
Format::AsciicastV3
}
});
match format {
Format::Asciicast => Box::new(AsciicastEncoder::new(false, 0)),
Format::AsciicastV3 => Box::new(AsciicastV3Encoder::new(false)),
Format::AsciicastV2 => Box::new(AsciicastV2Encoder::new(false, 0)),
Format::Raw => Box::new(RawEncoder::new(false)),
Format::Txt => Box::new(TextEncoder::new()),
}

View File

@@ -21,7 +21,7 @@ use crate::api;
use crate::asciicast;
use crate::cli::{self, Format, RelayTarget};
use crate::config::{self, Config};
use crate::encoder::{AsciicastEncoder, RawEncoder, TextEncoder};
use crate::encoder::{AsciicastV2Encoder, AsciicastV3Encoder, RawEncoder, TextEncoder};
use crate::file_writer::{FileWriterStarter, Metadata};
use crate::forwarder;
use crate::locale;
@@ -43,6 +43,7 @@ impl cli::Session {
let keys = get_key_bindings(cmd_config)?;
let notifier = notifier::threaded(get_notifier(config));
let record_input = self.input || cmd_config.input;
let term_type = self.get_term_type();
let term_version = self.get_term_version()?;
let env = capture_env(self.env.clone(), cmd_config);
@@ -58,6 +59,7 @@ impl cli::Session {
self.get_file_writer(
path,
cmd_config,
term_type.clone(),
term_version.clone(),
&env,
notifier.clone(),
@@ -75,7 +77,7 @@ impl cli::Session {
let mut relay = self
.relay
.take()
.map(|target| get_relay(target, config, term_version, &env))
.map(|target| get_relay(target, config, term_type, term_version, &env))
.transpose()?;
let relay_id = relay.as_ref().map(|r| r.id());
@@ -225,6 +227,7 @@ impl cli::Session {
&self,
path: &str,
config: &config::Session,
term_type: Option<String>,
term_version: Option<String>,
env: &HashMap<String, String>,
notifier: N,
@@ -233,7 +236,7 @@ impl cli::Session {
if path.to_lowercase().ends_with(".txt") {
Format::Txt
} else {
Format::Asciicast
Format::AsciicastV3
}
});
@@ -264,19 +267,31 @@ impl cli::Session {
.truncate(overwrite)
.open(path)?;
let time_offset = if append && format == Format::Asciicast {
let time_offset = if append && format == Format::AsciicastV2 {
asciicast::get_duration(path)?
} else {
0
};
let metadata = self.build_asciicast_metadata(term_version, env, config);
let metadata = self.build_asciicast_metadata(term_type, term_version, env, config);
let notifier = Box::new(notifier);
let writer = match format {
Format::Asciicast => {
Format::AsciicastV3 => {
let writer = Box::new(LineWriter::new(file));
let encoder = Box::new(AsciicastEncoder::new(append, time_offset));
let encoder = Box::new(AsciicastV3Encoder::new(append));
FileWriterStarter {
writer,
encoder,
metadata,
notifier,
}
}
Format::AsciicastV2 => {
let writer = Box::new(LineWriter::new(file));
let encoder = Box::new(AsciicastV2Encoder::new(append, time_offset));
FileWriterStarter {
writer,
@@ -314,6 +329,10 @@ impl cli::Session {
Ok(writer)
}
fn get_term_type(&self) -> Option<String> {
env::var("TERM").ok()
}
fn get_term_version(&self) -> Result<Option<String>> {
self.get_tty(false).map(|tty| tty.get_version())
}
@@ -324,6 +343,7 @@ impl cli::Session {
fn build_asciicast_metadata(
&self,
term_type: Option<String>,
term_version: Option<String>,
env: &HashMap<String, String>,
config: &config::Session,
@@ -332,6 +352,7 @@ impl cli::Session {
let command = self.get_command(config);
Metadata {
term_type,
term_version,
idle_time_limit,
command,
@@ -396,13 +417,15 @@ impl Relay {
fn get_relay(
target: RelayTarget,
config: &Config,
term_type: Option<String>,
term_version: Option<String>,
env: &HashMap<String, String>,
) -> Result<Relay> {
match target {
RelayTarget::StreamId(id) => {
let stream = api::create_user_stream(id, config)?;
let ws_producer_url = build_producer_url(&stream.ws_producer_url, term_version, env)?;
let ws_producer_url =
build_producer_url(&stream.ws_producer_url, term_type, term_version, env)?;
Ok(Relay {
ws_producer_url,
@@ -419,14 +442,15 @@ fn get_relay(
fn build_producer_url(
url: &str,
term_type: Option<String>,
term_version: Option<String>,
env: &HashMap<String, String>,
) -> Result<Url> {
let mut url: Url = url.parse()?;
let mut params = Vec::new();
if let Ok(term_type) = env::var("TERM") {
params.push(("term[type]".to_string(), term_type));
if let Some(type_) = term_type {
params.push(("term[type]".to_string(), type_));
}
if let Some(version) = term_version {

View File

@@ -1,19 +1,50 @@
use crate::asciicast::{Encoder, Event, Header};
use crate::asciicast::{Event, Header, V2Encoder, V3Encoder};
pub struct AsciicastEncoder {
inner: Encoder,
pub struct AsciicastV2Encoder {
inner: V2Encoder,
append: bool,
}
impl AsciicastEncoder {
impl AsciicastV2Encoder {
pub fn new(append: bool, time_offset: u64) -> Self {
let inner = Encoder::new(time_offset);
let inner = V2Encoder::new(time_offset);
Self { inner, append }
}
}
impl super::Encoder for AsciicastEncoder {
impl super::Encoder for AsciicastV2Encoder {
fn header(&mut self, header: &Header) -> Vec<u8> {
if self.append {
Vec::new()
} else {
self.inner.header(header)
}
}
fn event(&mut self, event: Event) -> Vec<u8> {
self.inner.event(&event)
}
fn flush(&mut self) -> Vec<u8> {
Vec::new()
}
}
pub struct AsciicastV3Encoder {
inner: V3Encoder,
append: bool,
}
impl AsciicastV3Encoder {
pub fn new(append: bool) -> Self {
let inner = V3Encoder::new();
Self { inner, append }
}
}
impl super::Encoder for AsciicastV3Encoder {
fn header(&mut self, header: &Header) -> Vec<u8> {
if self.append {
Vec::new()

View File

@@ -9,7 +9,7 @@ use anyhow::Result;
use crate::asciicast::Event;
use crate::asciicast::Header;
pub use asciicast::AsciicastEncoder;
pub use asciicast::{AsciicastV2Encoder, AsciicastV3Encoder};
pub use raw::RawEncoder;
pub use txt::TextEncoder;

View File

@@ -15,7 +15,7 @@ impl super::Encoder for RawEncoder {
if self.append {
Vec::new()
} else {
format!("\x1b[8;{};{}t", header.rows, header.cols).into_bytes()
format!("\x1b[8;{};{}t", header.term_rows, header.term_cols).into_bytes()
}
}
@@ -43,8 +43,8 @@ mod tests {
let mut enc = RawEncoder::new(false);
let header = Header {
cols: 100,
rows: 50,
term_cols: 100,
term_rows: 50,
..Default::default()
};

View File

@@ -15,7 +15,7 @@ impl TextEncoder {
impl super::Encoder for TextEncoder {
fn header(&mut self, header: &Header) -> Vec<u8> {
let vt = avt::Vt::builder()
.size(header.cols as usize, header.rows as usize)
.size(header.term_cols as usize, header.term_rows as usize)
.scrollback_limit(100)
.build();
@@ -63,8 +63,8 @@ mod tests {
let mut enc = TextEncoder::new();
let header = Header {
cols: 3,
rows: 1,
term_cols: 3,
term_rows: 1,
..Default::default()
};

View File

@@ -22,6 +22,7 @@ pub struct FileWriter {
}
pub struct Metadata {
pub term_type: Option<String>,
pub term_version: Option<String>,
pub idle_time_limit: Option<f64>,
pub command: Option<String>,
@@ -39,10 +40,12 @@ impl session::OutputStarter for FileWriterStarter {
let timestamp = time.duration_since(UNIX_EPOCH).unwrap().as_secs();
let header = asciicast::Header {
cols: tty_size.0,
rows: tty_size.1,
term_cols: tty_size.0,
term_rows: tty_size.1,
term_type: self.metadata.term_type,
term_version: self.metadata.term_version,
term_theme: theme,
timestamp: Some(timestamp),
theme,
idle_time_limit: self.metadata.idle_time_limit,
command: self.metadata.command.as_ref().cloned(),
title: self.metadata.title.as_ref().cloned(),