mirror of
https://github.com/asciinema/asciinema.git
synced 2025-12-15 19:28:00 +01:00
Add asciicast v3 support, record and convert to v3 by default
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
461
src/asciicast/v3.rs
Normal 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", ×tamp)?;
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user