Add pipeview-tui with ANSI escape sequence rendering
Full ratatui terminal application mirroring pipeview-gui: - Single-session telemetry console with Text/Hex/Plot views - Serial/TCP/UDP transport with full parameter config - DTR/RTS control, auto-reconnect, data send (Text/Hex modes) - Search with case-sensitive toggle and match navigation - Interactive config form with inline editing - ANSI SGR parsing via ansitok: 4-bit, 256-color, true color, bold/italic/underline/strikethrough rendering in text view - Persistent session state via JSON - Mouse clickable controls and mousewheel scroll/zoom Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3325,6 +3325,7 @@ dependencies = [
|
|||||||
name = "pipeview-tui"
|
name = "pipeview-tui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"ansitok",
|
||||||
"clap",
|
"clap",
|
||||||
"crossterm 0.28.1",
|
"crossterm 0.28.1",
|
||||||
"hex",
|
"hex",
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ image = { workspace = true }
|
|||||||
mlua = { workspace = true }
|
mlua = { workspace = true }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
ansitok = "0.3"
|
||||||
|
|||||||
295
crates/pipeview-tui/src/ansi.rs
Normal file
295
crates/pipeview-tui/src/ansi.rs
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
use ansitok::{AnsiColor, ElementKind, VisualAttribute, parse_ansi, parse_ansi_sgr};
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
use ratatui::text::Span;
|
||||||
|
|
||||||
|
/// Parse ANSI-encoded text and return a Vec of styled Spans.
|
||||||
|
///
|
||||||
|
/// ANSI SGR sequences (colors, bold, italic, underline, strikethrough)
|
||||||
|
/// are converted to ratatui `Style` applied on top of `base_style`.
|
||||||
|
/// Non-SGR sequences (cursor movement, etc.) are silently ignored.
|
||||||
|
pub fn ansi_to_spans(text: &str, base_style: Style) -> Vec<Span<'static>> {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
let mut style = AnsiStyleStack::default();
|
||||||
|
|
||||||
|
for element in parse_ansi(text) {
|
||||||
|
match element.kind() {
|
||||||
|
ElementKind::Text => {
|
||||||
|
let slice = &text[element.start()..element.end()];
|
||||||
|
if !slice.is_empty() {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
slice.to_string(),
|
||||||
|
style.merge(base_style),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ElementKind::Sgr => {
|
||||||
|
let sgr = &text[element.start()..element.end()];
|
||||||
|
apply_sgr_sequence(&mut style, sgr);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
spans
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract visible text from ANSI-encoded string (strips escape sequences).
|
||||||
|
///
|
||||||
|
/// This is the text that a user would actually see on a terminal —
|
||||||
|
/// useful for search, copy, and line counting.
|
||||||
|
pub fn ansi_visible_text(text: &str) -> String {
|
||||||
|
let mut visible = String::new();
|
||||||
|
for element in parse_ansi(text) {
|
||||||
|
if element.kind() == ElementKind::Text {
|
||||||
|
visible.push_str(&text[element.start()..element.end()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visible
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── internal style stack ──
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
struct AnsiStyleStack {
|
||||||
|
fg: Option<Color>,
|
||||||
|
bg: Option<Color>,
|
||||||
|
bold: bool,
|
||||||
|
italic: bool,
|
||||||
|
underline: bool,
|
||||||
|
strikethrough: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AnsiStyleStack {
|
||||||
|
fn apply_sgr_attr(&mut self, attr: VisualAttribute) {
|
||||||
|
match attr {
|
||||||
|
VisualAttribute::Reset(0) => *self = Self::default(),
|
||||||
|
VisualAttribute::Reset(22) => self.bold = false,
|
||||||
|
VisualAttribute::Reset(23) => self.italic = false,
|
||||||
|
VisualAttribute::Reset(24) => self.underline = false,
|
||||||
|
VisualAttribute::Reset(29) => self.strikethrough = false,
|
||||||
|
VisualAttribute::Reset(39) => self.fg = None,
|
||||||
|
VisualAttribute::Reset(49) => self.bg = None,
|
||||||
|
VisualAttribute::Reset(_) => {}
|
||||||
|
VisualAttribute::Bold => self.bold = true,
|
||||||
|
VisualAttribute::Faint => self.bold = false,
|
||||||
|
VisualAttribute::Italic => self.italic = true,
|
||||||
|
VisualAttribute::Underline => self.underline = true,
|
||||||
|
VisualAttribute::Crossedout => self.strikethrough = true,
|
||||||
|
VisualAttribute::FgColor(c) => self.fg = Some(ansi_color_to_ratatui(c)),
|
||||||
|
VisualAttribute::BgColor(c) => self.bg = Some(ansi_color_to_ratatui(c)),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sgr(&mut self, sgr: &str) {
|
||||||
|
// Strip ESC[ ... m wrapper and delegate to ansitok's SGR parser
|
||||||
|
let params = sgr
|
||||||
|
.strip_prefix("\x1b[")
|
||||||
|
.and_then(|s| s.strip_suffix('m'))
|
||||||
|
.unwrap_or(sgr);
|
||||||
|
|
||||||
|
if params.is_empty() {
|
||||||
|
*self = Self::default();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for output in parse_ansi_sgr(sgr) {
|
||||||
|
if let Some(attr) = output.as_escape() {
|
||||||
|
self.apply_sgr_attr(attr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge(&self, base: Style) -> Style {
|
||||||
|
let mut style = base;
|
||||||
|
|
||||||
|
if let Some(fg) = self.fg {
|
||||||
|
if self.bold {
|
||||||
|
style = style.fg(brighten(fg));
|
||||||
|
} else {
|
||||||
|
style = style.fg(fg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(bg) = self.bg {
|
||||||
|
style = style.bg(bg);
|
||||||
|
}
|
||||||
|
if self.italic {
|
||||||
|
style = style.add_modifier(Modifier::ITALIC);
|
||||||
|
}
|
||||||
|
if self.underline {
|
||||||
|
style = style.add_modifier(Modifier::UNDERLINED);
|
||||||
|
}
|
||||||
|
if self.strikethrough {
|
||||||
|
style = style.add_modifier(Modifier::CROSSED_OUT);
|
||||||
|
}
|
||||||
|
if self.bold {
|
||||||
|
// Bold applied only when there's no explicit fg (brighten handles it otherwise)
|
||||||
|
style = style.add_modifier(Modifier::BOLD);
|
||||||
|
}
|
||||||
|
|
||||||
|
style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_sgr_sequence(style: &mut AnsiStyleStack, sgr: &str) {
|
||||||
|
style.apply_sgr(sgr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── color conversion ──
|
||||||
|
|
||||||
|
fn ansi_color_to_ratatui(color: AnsiColor) -> Color {
|
||||||
|
match color {
|
||||||
|
AnsiColor::Bit4(c) => ansi_4bit(c),
|
||||||
|
AnsiColor::Bit8(c) => ansi_256(c),
|
||||||
|
AnsiColor::Bit24 { r, g, b } => Color::Rgb(r, g, b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_4bit(code: u8) -> Color {
|
||||||
|
match code {
|
||||||
|
30 => Color::Rgb(0, 0, 0),
|
||||||
|
31 => Color::Rgb(194, 54, 33),
|
||||||
|
32 => Color::Rgb(37, 188, 36),
|
||||||
|
33 => Color::Rgb(173, 173, 39),
|
||||||
|
34 => Color::Rgb(73, 46, 225),
|
||||||
|
35 => Color::Rgb(211, 56, 211),
|
||||||
|
36 => Color::Rgb(51, 187, 200),
|
||||||
|
37 => Color::Rgb(203, 204, 205),
|
||||||
|
|
||||||
|
90 => Color::Rgb(129, 131, 131),
|
||||||
|
91 => Color::Rgb(252, 57, 31),
|
||||||
|
92 => Color::Rgb(49, 231, 34),
|
||||||
|
93 => Color::Rgb(234, 236, 35),
|
||||||
|
94 => Color::Rgb(88, 51, 255),
|
||||||
|
95 => Color::Rgb(249, 53, 248),
|
||||||
|
96 => Color::Rgb(20, 240, 240),
|
||||||
|
97 => Color::Rgb(233, 235, 235),
|
||||||
|
|
||||||
|
// Background colors (same values as foreground but offset by 10)
|
||||||
|
40 => Color::Rgb(0, 0, 0),
|
||||||
|
41 => Color::Rgb(194, 54, 33),
|
||||||
|
42 => Color::Rgb(37, 188, 36),
|
||||||
|
43 => Color::Rgb(173, 173, 39),
|
||||||
|
44 => Color::Rgb(73, 46, 225),
|
||||||
|
45 => Color::Rgb(211, 56, 211),
|
||||||
|
46 => Color::Rgb(51, 187, 200),
|
||||||
|
47 => Color::Rgb(203, 204, 205),
|
||||||
|
|
||||||
|
100 => Color::Rgb(129, 131, 131),
|
||||||
|
101 => Color::Rgb(252, 57, 31),
|
||||||
|
102 => Color::Rgb(49, 231, 34),
|
||||||
|
103 => Color::Rgb(234, 236, 35),
|
||||||
|
104 => Color::Rgb(88, 51, 255),
|
||||||
|
105 => Color::Rgb(249, 53, 248),
|
||||||
|
106 => Color::Rgb(20, 240, 240),
|
||||||
|
107 => Color::Rgb(233, 235, 235),
|
||||||
|
|
||||||
|
_ => Color::Rgb(255, 255, 255),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ansi_256(code: u8) -> Color {
|
||||||
|
match code {
|
||||||
|
0..=15 => ansi_4bit(if code < 8 { code + 30 } else { code + 82 }),
|
||||||
|
16..=231 => {
|
||||||
|
let idx = code - 16;
|
||||||
|
let cube = [0, 95, 135, 175, 215, 255];
|
||||||
|
let r = cube[(idx / 36) as usize];
|
||||||
|
let g = cube[((idx / 6) % 6) as usize];
|
||||||
|
let b = cube[(idx % 6) as usize];
|
||||||
|
Color::Rgb(r, g, b)
|
||||||
|
}
|
||||||
|
232..=255 => {
|
||||||
|
let v = (code - 232) * 10 + 8;
|
||||||
|
Color::Rgb(v, v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn brighten(color: Color) -> Color {
|
||||||
|
match color {
|
||||||
|
Color::Rgb(r, g, b) => Color::Rgb(
|
||||||
|
((r as u32) * 13 / 10).min(255) as u8,
|
||||||
|
((g as u32) * 13 / 10).min(255) as u8,
|
||||||
|
((b as u32) * 13 / 10).min(255) as u8,
|
||||||
|
),
|
||||||
|
_ => color,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ratatui::style::Style;
|
||||||
|
|
||||||
|
fn visible<'a>(spans: &[Span<'a>]) -> String {
|
||||||
|
spans.iter().map(|s| s.content.as_ref()).collect::<String>()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plain_text_no_ansi() {
|
||||||
|
let spans = ansi_to_spans("hello", Style::default());
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(spans[0].content, "hello");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fg_red_reset() {
|
||||||
|
let spans = ansi_to_spans("\x1b[31mred\x1b[0m plain", Style::default());
|
||||||
|
assert!(spans.len() >= 2);
|
||||||
|
assert_eq!(visible(&spans), "red plain");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_sgr_resets_all() {
|
||||||
|
let base = Style::default().fg(Color::Rgb(1, 2, 3));
|
||||||
|
let spans = ansi_to_spans("\x1b[31mred\x1b[m plain", base);
|
||||||
|
assert_eq!(visible(&spans), "red plain");
|
||||||
|
assert!(spans.len() >= 2);
|
||||||
|
// First span should have red foreground
|
||||||
|
assert_ne!(spans[0].style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||||
|
// Last span should have base foreground
|
||||||
|
assert_eq!(spans.last().unwrap().style.fg, Some(Color::Rgb(1, 2, 3)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bold_brightens_color() {
|
||||||
|
let spans = ansi_to_spans("\x1b[1;31mbold red\x1b[0m", Style::default());
|
||||||
|
assert_eq!(spans.len(), 1);
|
||||||
|
assert_eq!(visible(&spans), "bold red");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_ansi_codes_from_output() {
|
||||||
|
let spans = ansi_to_spans("\x1b[32mgreen\x1b[0m", Style::default());
|
||||||
|
assert_eq!(visible(&spans), "green");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visible_text_strips_ansi() {
|
||||||
|
assert_eq!(ansi_visible_text("plain"), "plain");
|
||||||
|
assert_eq!(ansi_visible_text("\x1b[31mred\x1b[0m"), "red");
|
||||||
|
assert_eq!(
|
||||||
|
ansi_visible_text("\x1b[1;32mbold green\x1b[0m plain"),
|
||||||
|
"bold green plain"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn xterm_256_color_cube() {
|
||||||
|
assert_eq!(ansi_256(16), Color::Rgb(0, 0, 0));
|
||||||
|
assert_eq!(ansi_256(21), Color::Rgb(0, 0, 255));
|
||||||
|
assert_eq!(ansi_256(52), Color::Rgb(95, 0, 0));
|
||||||
|
assert_eq!(ansi_256(67), Color::Rgb(95, 135, 175));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn true_color_24bit() {
|
||||||
|
let spans = ansi_to_spans(
|
||||||
|
"\x1b[38;2;100;200;50mtruecolor\x1b[0m",
|
||||||
|
Style::default(),
|
||||||
|
);
|
||||||
|
assert_eq!(visible(&spans), "truecolor");
|
||||||
|
assert_eq!(spans[0].style.fg, Some(Color::Rgb(100, 200, 50)));
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,20 @@ use std::fs;
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use pipeview_client::SessionConfig;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use pipeview_client::SessionConfig;
|
|
||||||
|
use crate::app::{DisplayOptions, View, default_session_config};
|
||||||
|
|
||||||
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
const TUI_STATE_FILE_NAME: &str = "tui-state.json";
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct PersistedTuiState {
|
pub struct PersistedTuiState {
|
||||||
|
#[serde(default = "default_session")]
|
||||||
|
pub session: SessionConfig,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub sessions: Vec<SessionConfig>,
|
pub active_view: PersistedView,
|
||||||
#[serde(default)]
|
|
||||||
pub active: usize,
|
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub show_timestamp: bool,
|
pub show_timestamp: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
@@ -23,9 +25,54 @@ pub struct PersistedTuiState {
|
|||||||
pub show_pipeline: bool,
|
pub show_pipeline: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Default for PersistedTuiState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
session: default_session(),
|
||||||
|
active_view: PersistedView::Text,
|
||||||
|
show_timestamp: true,
|
||||||
|
show_direction: true,
|
||||||
|
show_pipeline: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
|
||||||
|
pub enum PersistedView {
|
||||||
|
#[default]
|
||||||
|
Text,
|
||||||
|
Hex,
|
||||||
|
Plot,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<View> for PersistedView {
|
||||||
|
fn from(value: View) -> Self {
|
||||||
|
match value {
|
||||||
|
View::Text => Self::Text,
|
||||||
|
View::Hex => Self::Hex,
|
||||||
|
View::Plot => Self::Plot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PersistedView> for View {
|
||||||
|
fn from(value: PersistedView) -> Self {
|
||||||
|
match value {
|
||||||
|
PersistedView::Text => View::Text,
|
||||||
|
PersistedView::Hex => View::Hex,
|
||||||
|
PersistedView::Plot => View::Plot,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_tui_state() -> PersistedTuiState {
|
pub fn load_tui_state() -> PersistedTuiState {
|
||||||
match load_tui_state_from_path(&tui_state_path()) {
|
match load_tui_state_from_path(&tui_state_path()) {
|
||||||
Ok(state) => state,
|
Ok(mut state) => {
|
||||||
|
if state.session.pipelines.is_empty() {
|
||||||
|
state.session = default_session();
|
||||||
|
}
|
||||||
|
state
|
||||||
|
}
|
||||||
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
Err(err) if err.kind() == io::ErrorKind::NotFound => PersistedTuiState::default(),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(error = %err, "Failed to load persisted TUI state");
|
warn!(error = %err, "Failed to load persisted TUI state");
|
||||||
@@ -34,16 +81,35 @@ pub fn load_tui_state() -> PersistedTuiState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_tui_state(state: &PersistedTuiState) {
|
pub fn save_tui_state(session: &SessionConfig, view: View, display: DisplayOptions) {
|
||||||
if let Err(err) = save_tui_state_to_path(state, &tui_state_path()) {
|
let state = PersistedTuiState {
|
||||||
|
session: session.clone(),
|
||||||
|
active_view: view.into(),
|
||||||
|
show_timestamp: display.show_timestamp,
|
||||||
|
show_direction: display.show_direction,
|
||||||
|
show_pipeline: display.show_pipeline,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(err) = save_tui_state_to_path(&state, &tui_state_path()) {
|
||||||
warn!(error = %err, "Failed to persist TUI state");
|
warn!(error = %err, "Failed to persist TUI state");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_session() -> SessionConfig {
|
||||||
|
default_session_config()
|
||||||
|
}
|
||||||
|
|
||||||
fn tui_state_path() -> PathBuf {
|
fn tui_state_path() -> PathBuf {
|
||||||
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
state_path_for_os_and_env(env::consts::OS, |key| env::var(key).ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn config_dir() -> PathBuf {
|
||||||
|
tui_state_path()
|
||||||
|
.parent()
|
||||||
|
.map(Path::to_path_buf)
|
||||||
|
.unwrap_or_else(|| PathBuf::from("."))
|
||||||
|
}
|
||||||
|
|
||||||
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
fn state_path_for_os_and_env(os: &str, get_env: impl Fn(&str) -> Option<String>) -> PathBuf {
|
||||||
match os {
|
match os {
|
||||||
"windows" => {
|
"windows" => {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
use std::collections::VecDeque;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use pipeview_client::{DecodedEntry, RingBuffer};
|
use pipeview_client::{DecodedEntry, RingBuffer};
|
||||||
use pipeview_core::protocol::DecodedData;
|
use pipeview_core::protocol::DecodedData;
|
||||||
|
use pipeview_core::protocol::plot::{PlotFormat, PlotFrame};
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum LineDirection {
|
pub enum LineDirection {
|
||||||
@@ -50,6 +52,10 @@ impl TextBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, index: usize) -> Option<&ConsoleLine> {
|
||||||
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.lines.len()
|
self.lines.len()
|
||||||
}
|
}
|
||||||
@@ -61,10 +67,6 @@ impl TextBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn recent(&self, count: usize) -> Vec<ConsoleLine> {
|
|
||||||
self.lines.drain_recent(count)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -111,6 +113,10 @@ impl HexBuffer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get(&self, index: usize) -> Option<&HexLine> {
|
||||||
|
self.lines.get(index)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn len(&self) -> usize {
|
pub fn len(&self) -> usize {
|
||||||
self.lines.len()
|
self.lines.len()
|
||||||
}
|
}
|
||||||
@@ -122,9 +128,494 @@ impl HexBuffer {
|
|||||||
pub fn set_limit(&mut self, limit: usize) {
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
self.lines.set_limit(limit);
|
self.lines.set_limit(limit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn recent(&self, count: usize) -> Vec<HexLine> {
|
pub struct PlotSeries {
|
||||||
self.lines.drain_recent(count)
|
pub name: String,
|
||||||
|
points: VecDeque<[f64; 2]>,
|
||||||
|
next_x: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotSeries {
|
||||||
|
fn new(name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
points: VecDeque::new(),
|
||||||
|
next_x: 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_samples(&mut self, samples: &[f64], limit: usize) {
|
||||||
|
for sample in samples {
|
||||||
|
if !sample.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.push_point([self.next_x, *sample], limit);
|
||||||
|
self.next_x += 1.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_point(&mut self, point: [f64; 2], limit: usize) -> Option<[f64; 2]> {
|
||||||
|
self.points.push_back(point);
|
||||||
|
if self.points.len() > limit {
|
||||||
|
self.points.pop_front()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn points(&self) -> impl Iterator<Item = [f64; 2]> + '_ {
|
||||||
|
self.points.iter().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.points.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn first_point(&self) -> Option<[f64; 2]> {
|
||||||
|
self.points.front().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_point(&self) -> Option<[f64; 2]> {
|
||||||
|
self.points.back().copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_points_time_series(
|
||||||
|
&self,
|
||||||
|
x_min: f64,
|
||||||
|
x_max: f64,
|
||||||
|
max_points: usize,
|
||||||
|
) -> Vec<[f64; 2]> {
|
||||||
|
if self.points.is_empty() || max_points == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut exact_visible = Vec::with_capacity(max_points.min(self.points.len()));
|
||||||
|
for point in self.points.iter().copied() {
|
||||||
|
if point[0] < x_min {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if point[0] > x_max {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
exact_visible.push(point);
|
||||||
|
if exact_visible.len() > max_points {
|
||||||
|
exact_visible.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !exact_visible.is_empty() {
|
||||||
|
return exact_visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
let bucket_count = (max_points / 2).max(1);
|
||||||
|
let width = (x_max - x_min).max(1.0);
|
||||||
|
let bucket_width = width / bucket_count as f64;
|
||||||
|
let mut rendered = Vec::with_capacity(bucket_count * 2);
|
||||||
|
let mut points = self
|
||||||
|
.points
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.skip_while(|point| point[0] < x_min)
|
||||||
|
.peekable();
|
||||||
|
|
||||||
|
for bucket_index in 0..bucket_count {
|
||||||
|
let bucket_start = x_min + bucket_width * bucket_index as f64;
|
||||||
|
let bucket_end = if bucket_index + 1 == bucket_count {
|
||||||
|
x_max
|
||||||
|
} else {
|
||||||
|
bucket_start + bucket_width
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut min_point: Option<[f64; 2]> = None;
|
||||||
|
let mut max_point: Option<[f64; 2]> = None;
|
||||||
|
|
||||||
|
while let Some(point) = points.peek().copied() {
|
||||||
|
if point[0] > bucket_end {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if point[0] >= bucket_start {
|
||||||
|
match min_point {
|
||||||
|
Some(current) if current[1] <= point[1] => {}
|
||||||
|
_ => min_point = Some(point),
|
||||||
|
}
|
||||||
|
match max_point {
|
||||||
|
Some(current) if current[1] >= point[1] => {}
|
||||||
|
_ => max_point = Some(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
points.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
match (min_point, max_point) {
|
||||||
|
(Some(a), Some(b)) if a[0] <= b[0] => {
|
||||||
|
rendered.push(a);
|
||||||
|
if a != b {
|
||||||
|
rendered.push(b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(a), Some(b)) => {
|
||||||
|
rendered.push(b);
|
||||||
|
if a != b {
|
||||||
|
rendered.push(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(Some(a), None) | (None, Some(a)) => rendered.push(a),
|
||||||
|
(None, None) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rendered.len() > max_points {
|
||||||
|
let stride = rendered.len().div_ceil(max_points);
|
||||||
|
rendered.into_iter().step_by(stride).collect()
|
||||||
|
} else {
|
||||||
|
rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_points_xy(&self, max_points: usize) -> Vec<[f64; 2]> {
|
||||||
|
if self.points.is_empty() || max_points == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
if self.points.len() <= max_points {
|
||||||
|
return self.points.iter().copied().collect();
|
||||||
|
}
|
||||||
|
|
||||||
|
let stride = self.points.len().div_ceil(max_points);
|
||||||
|
self.points.iter().copied().step_by(stride).collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PlotSeriesKind {
|
||||||
|
TimeSeries,
|
||||||
|
XY,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct BoundsRect {
|
||||||
|
min_x: f64,
|
||||||
|
max_x: f64,
|
||||||
|
min_y: f64,
|
||||||
|
max_y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BoundsRect {
|
||||||
|
fn from_point([x, y]: [f64; 2]) -> Option<Self> {
|
||||||
|
if !(x.is_finite() && y.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
min_x: x,
|
||||||
|
max_x: x,
|
||||||
|
min_y: y,
|
||||||
|
max_y: y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extend_with_point(&mut self, [x, y]: [f64; 2]) {
|
||||||
|
if !(x.is_finite() && y.is_finite()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.min_x = self.min_x.min(x);
|
||||||
|
self.max_x = self.max_x.max(x);
|
||||||
|
self.min_y = self.min_y.min(y);
|
||||||
|
self.max_y = self.max_y.max(y);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn touches(&self, [x, y]: [f64; 2]) -> bool {
|
||||||
|
x == self.min_x || x == self.max_x || y == self.min_y || y == self.max_y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct PlotBuffer {
|
||||||
|
limit: usize,
|
||||||
|
kind: PlotSeriesKind,
|
||||||
|
series: Vec<PlotSeries>,
|
||||||
|
time_series_y_bounds: Option<(f64, f64)>,
|
||||||
|
time_series_y_dirty: bool,
|
||||||
|
xy_bounds: Option<BoundsRect>,
|
||||||
|
xy_bounds_dirty: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlotBuffer {
|
||||||
|
pub fn new(limit: usize) -> Self {
|
||||||
|
Self {
|
||||||
|
limit,
|
||||||
|
kind: PlotSeriesKind::TimeSeries,
|
||||||
|
series: Vec::new(),
|
||||||
|
time_series_y_bounds: None,
|
||||||
|
time_series_y_dirty: false,
|
||||||
|
xy_bounds: None,
|
||||||
|
xy_bounds_dirty: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, entry: &DecodedEntry) {
|
||||||
|
if let DecodedData::Plot(frame) = &entry.data {
|
||||||
|
self.push_frame(&entry.pipeline_name, frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||||
|
let next_kind = match frame.format {
|
||||||
|
PlotFormat::XY => PlotSeriesKind::XY,
|
||||||
|
PlotFormat::Interleaved | PlotFormat::Block => PlotSeriesKind::TimeSeries,
|
||||||
|
};
|
||||||
|
if self.kind != next_kind {
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
self.kind = next_kind;
|
||||||
|
|
||||||
|
if matches!(frame.format, PlotFormat::XY) {
|
||||||
|
self.push_xy_frame(pipeline_name, frame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (index, channel) in frame.channels.iter().enumerate() {
|
||||||
|
let series_name = if frame.channels.len() == 1 {
|
||||||
|
pipeline_name.to_owned()
|
||||||
|
} else {
|
||||||
|
format!("{pipeline_name}:ch{}", index + 1)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(series_index) = self
|
||||||
|
.series
|
||||||
|
.iter()
|
||||||
|
.position(|series| series.name == series_name)
|
||||||
|
{
|
||||||
|
for sample in channel {
|
||||||
|
if !sample.is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let (point, removed) = {
|
||||||
|
let series = &mut self.series[series_index];
|
||||||
|
let point = [series.next_x, *sample];
|
||||||
|
let removed = series.push_point(point, self.limit);
|
||||||
|
series.next_x += 1.0;
|
||||||
|
(point, removed)
|
||||||
|
};
|
||||||
|
if let Some(removed) = removed {
|
||||||
|
self.note_time_series_removed(removed);
|
||||||
|
}
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let mut series = PlotSeries::new(series_name);
|
||||||
|
series.push_samples(channel, self.limit);
|
||||||
|
for point in series.points() {
|
||||||
|
self.note_time_series_point(point);
|
||||||
|
}
|
||||||
|
self.series.push(series);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_xy_frame(&mut self, pipeline_name: &str, frame: &PlotFrame) {
|
||||||
|
if frame.channels.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let x = &frame.channels[0];
|
||||||
|
let y = &frame.channels[1];
|
||||||
|
let len = x.len().min(y.len());
|
||||||
|
let series_name = format!("{pipeline_name}:xy");
|
||||||
|
|
||||||
|
let series_index = if let Some(index) = self
|
||||||
|
.series
|
||||||
|
.iter()
|
||||||
|
.position(|series| series.name == series_name)
|
||||||
|
{
|
||||||
|
index
|
||||||
|
} else {
|
||||||
|
self.series.push(PlotSeries::new(series_name));
|
||||||
|
self.series.len() - 1
|
||||||
|
};
|
||||||
|
|
||||||
|
for index in 0..len {
|
||||||
|
if !x[index].is_finite() || !y[index].is_finite() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let point = [x[index], y[index]];
|
||||||
|
let removed = {
|
||||||
|
let series = &mut self.series[series_index];
|
||||||
|
series.push_point(point, self.limit)
|
||||||
|
};
|
||||||
|
if let Some(removed) = removed {
|
||||||
|
self.note_xy_removed(removed);
|
||||||
|
}
|
||||||
|
self.note_xy_point(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.series.clear();
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_limit(&mut self, limit: usize) {
|
||||||
|
self.limit = limit;
|
||||||
|
for series in &mut self.series {
|
||||||
|
while series.points.len() > self.limit {
|
||||||
|
series.points.pop_front();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.invalidate_bounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.series.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kind(&self) -> PlotSeriesKind {
|
||||||
|
self.kind
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn iter(&self) -> impl Iterator<Item = &PlotSeries> {
|
||||||
|
self.series.iter()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn series_len(&self) -> usize {
|
||||||
|
self.series.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn total_points(&self) -> usize {
|
||||||
|
self.series.iter().map(PlotSeries::len).sum()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
match self.kind {
|
||||||
|
PlotSeriesKind::TimeSeries => self.time_series_bounds(),
|
||||||
|
PlotSeriesKind::XY => self.xy_bounds(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn invalidate_bounds(&mut self) {
|
||||||
|
self.time_series_y_bounds = None;
|
||||||
|
self.time_series_y_dirty = false;
|
||||||
|
self.xy_bounds = None;
|
||||||
|
self.xy_bounds_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_time_series_point(&mut self, point: [f64; 2]) {
|
||||||
|
if !point[1].is_finite() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match &mut self.time_series_y_bounds {
|
||||||
|
Some((min_y, max_y)) => {
|
||||||
|
*min_y = min_y.min(point[1]);
|
||||||
|
*max_y = max_y.max(point[1]);
|
||||||
|
}
|
||||||
|
None => self.time_series_y_bounds = Some((point[1], point[1])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_time_series_removed(&mut self, removed: [f64; 2]) {
|
||||||
|
if let Some((min_y, max_y)) = self.time_series_y_bounds
|
||||||
|
&& (removed[1] == min_y || removed[1] == max_y)
|
||||||
|
{
|
||||||
|
self.time_series_y_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_xy_point(&mut self, point: [f64; 2]) {
|
||||||
|
match &mut self.xy_bounds {
|
||||||
|
Some(bounds) => bounds.extend_with_point(point),
|
||||||
|
None => self.xy_bounds = BoundsRect::from_point(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn note_xy_removed(&mut self, removed: [f64; 2]) {
|
||||||
|
if let Some(bounds) = self.xy_bounds
|
||||||
|
&& bounds.touches(removed)
|
||||||
|
{
|
||||||
|
self.xy_bounds_dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn time_series_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
let mut min_x = f64::INFINITY;
|
||||||
|
let mut max_x = f64::NEG_INFINITY;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
if let Some([x, _]) = series.first_point() {
|
||||||
|
min_x = min_x.min(x);
|
||||||
|
}
|
||||||
|
if let Some([x, _]) = series.last_point() {
|
||||||
|
max_x = max_x.max(x);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !(min_x.is_finite() && max_x.is_finite()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.time_series_y_dirty {
|
||||||
|
self.recompute_time_series_y_bounds();
|
||||||
|
}
|
||||||
|
let (mut min_y, mut max_y) = self.time_series_y_bounds?;
|
||||||
|
if min_y == max_y {
|
||||||
|
min_y -= 1.0;
|
||||||
|
max_y += 1.0;
|
||||||
|
}
|
||||||
|
Some(([min_x, min_y], [max_x, max_y]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xy_bounds(&mut self) -> Option<([f64; 2], [f64; 2])> {
|
||||||
|
if self.xy_bounds_dirty {
|
||||||
|
self.recompute_xy_bounds();
|
||||||
|
}
|
||||||
|
let bounds = self.xy_bounds?;
|
||||||
|
let mut min_x = bounds.min_x;
|
||||||
|
let mut max_x = bounds.max_x;
|
||||||
|
let mut min_y = bounds.min_y;
|
||||||
|
let mut max_y = bounds.max_y;
|
||||||
|
if min_x == max_x {
|
||||||
|
min_x -= 1.0;
|
||||||
|
max_x += 1.0;
|
||||||
|
}
|
||||||
|
if min_y == max_y {
|
||||||
|
min_y -= 1.0;
|
||||||
|
max_y += 1.0;
|
||||||
|
}
|
||||||
|
Some(([min_x, min_y], [max_x, max_y]))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recompute_time_series_y_bounds(&mut self) {
|
||||||
|
let mut min_y = f64::INFINITY;
|
||||||
|
let mut max_y = f64::NEG_INFINITY;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
for [_, y] in series.points() {
|
||||||
|
if y.is_finite() {
|
||||||
|
min_y = min_y.min(y);
|
||||||
|
max_y = max_y.max(y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.time_series_y_bounds = if min_y.is_finite() && max_y.is_finite() {
|
||||||
|
Some((min_y, max_y))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
self.time_series_y_dirty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn recompute_xy_bounds(&mut self) {
|
||||||
|
let mut bounds: Option<BoundsRect> = None;
|
||||||
|
|
||||||
|
for series in &self.series {
|
||||||
|
for point in series.points() {
|
||||||
|
match &mut bounds {
|
||||||
|
Some(existing) => existing.extend_with_point(point),
|
||||||
|
None => bounds = BoundsRect::from_point(point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.xy_bounds = bounds;
|
||||||
|
self.xy_bounds_dirty = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod ansi;
|
||||||
mod app;
|
mod app;
|
||||||
mod app_state;
|
mod app_state;
|
||||||
mod buffers;
|
mod buffers;
|
||||||
@@ -6,33 +7,59 @@ mod ui;
|
|||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
|
event::{DisableMouseCapture, EnableMouseCapture},
|
||||||
execute,
|
execute,
|
||||||
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
|
||||||
};
|
};
|
||||||
use ratatui::{Terminal, backend::CrosstermBackend};
|
use ratatui::{Terminal, backend::CrosstermBackend};
|
||||||
use pipeview_client::SessionManager;
|
use tracing_appender::non_blocking::WorkerGuard;
|
||||||
|
use tracing_subscriber::{EnvFilter, fmt};
|
||||||
|
|
||||||
|
use crate::app::App;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> io::Result<()> {
|
async fn main() -> io::Result<()> {
|
||||||
tracing_subscriber::fmt()
|
let _log_guard = init_tracing();
|
||||||
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
||||||
.init();
|
|
||||||
|
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
|
||||||
|
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::new(backend)?;
|
let mut terminal = Terminal::new(backend)?;
|
||||||
|
|
||||||
let manager = SessionManager::new();
|
let result = async {
|
||||||
let rx = manager.subscribe();
|
let mut app = App::new();
|
||||||
|
app.run(&mut terminal).await?;
|
||||||
let result = app::run(&mut terminal, app::App::new(manager, rx));
|
app.shutdown().await;
|
||||||
|
io::Result::Ok(())
|
||||||
|
}
|
||||||
|
.await;
|
||||||
|
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
execute!(
|
||||||
|
terminal.backend_mut(),
|
||||||
|
LeaveAlternateScreen,
|
||||||
|
DisableMouseCapture
|
||||||
|
)?;
|
||||||
terminal.show_cursor()?;
|
terminal.show_cursor()?;
|
||||||
|
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn init_tracing() -> Option<WorkerGuard> {
|
||||||
|
let filter = EnvFilter::try_from_default_env()
|
||||||
|
.unwrap_or_else(|_| EnvFilter::new("pipeview_tui=info,pipeview_client=info"));
|
||||||
|
let log_dir = app_state::config_dir();
|
||||||
|
let _ = std::fs::create_dir_all(&log_dir);
|
||||||
|
let file_appender = tracing_appender::rolling::never(log_dir, "tui.log");
|
||||||
|
let (writer, guard) = tracing_appender::non_blocking(file_appender);
|
||||||
|
|
||||||
|
fmt()
|
||||||
|
.with_env_filter(filter)
|
||||||
|
.with_target(false)
|
||||||
|
.with_ansi(false)
|
||||||
|
.with_writer(writer)
|
||||||
|
.try_init()
|
||||||
|
.ok()
|
||||||
|
.map(|_| guard)
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user