use std::collections::VecDeque;
use color_eyre::Result;
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
use super::Component;
use crate::action::Action;
use crate::log_capture::{CockpitCapture, CockpitEntry, LogCapture, LogEntry};
use crate::state::{LOG_PANE_MAX_HEIGHT, LOG_PANE_MIN_HEIGHT};
use crate::theme;
const BEE_TAB_RING_CAPACITY: usize = 500;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogTab {
Errors,
Warning,
Info,
Debug,
BeeHttp,
SelfHttp,
Cockpit,
}
impl LogTab {
pub const ALL: [LogTab; 7] = [
LogTab::Errors,
LogTab::Warning,
LogTab::Info,
LogTab::Debug,
LogTab::BeeHttp,
LogTab::SelfHttp,
LogTab::Cockpit,
];
pub fn from_kebab(s: &str) -> Self {
match s {
"errors" => Self::Errors,
"warning" => Self::Warning,
"info" => Self::Info,
"debug" => Self::Debug,
"bee-http" => Self::BeeHttp,
"self-http" => Self::SelfHttp,
"cockpit" => Self::Cockpit,
_ => Self::SelfHttp,
}
}
pub fn to_kebab(self) -> &'static str {
match self {
Self::Errors => "errors",
Self::Warning => "warning",
Self::Info => "info",
Self::Debug => "debug",
Self::BeeHttp => "bee-http",
Self::SelfHttp => "self-http",
Self::Cockpit => "cockpit",
}
}
pub fn label(self) -> &'static str {
match self {
Self::Errors => "Errors",
Self::Warning => "Warn",
Self::Info => "Info",
Self::Debug => "Debug",
Self::BeeHttp => "Bee HTTP",
Self::SelfHttp => "bee::http",
Self::Cockpit => "Cockpit",
}
}
fn index(self) -> usize {
Self::ALL.iter().position(|t| *t == self).unwrap_or(5)
}
fn from_index(i: usize) -> Self {
Self::ALL.get(i).copied().unwrap_or(Self::SelfHttp)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BeeLogLine {
pub timestamp: String,
pub logger: String,
pub message: String,
}
pub enum LogRow<'a> {
Self_(&'a LogEntry),
Bee(&'a BeeLogLine),
}
#[derive(Debug, Default)]
pub struct BeeLogBuffers {
pub errors: VecDeque<BeeLogLine>,
pub warning: VecDeque<BeeLogLine>,
pub info: VecDeque<BeeLogLine>,
pub debug: VecDeque<BeeLogLine>,
pub bee_http: VecDeque<BeeLogLine>,
}
impl BeeLogBuffers {
fn buffer_for(&self, tab: LogTab) -> Option<&VecDeque<BeeLogLine>> {
match tab {
LogTab::Errors => Some(&self.errors),
LogTab::Warning => Some(&self.warning),
LogTab::Info => Some(&self.info),
LogTab::Debug => Some(&self.debug),
LogTab::BeeHttp => Some(&self.bee_http),
LogTab::SelfHttp | LogTab::Cockpit => None,
}
}
pub fn count(&self, tab: LogTab) -> usize {
self.buffer_for(tab).map(|b| b.len()).unwrap_or(0)
}
}
pub struct LogPane {
capture: Option<LogCapture>,
self_http_entries: Vec<LogEntry>,
cockpit_capture: Option<CockpitCapture>,
cockpit_entries: Vec<CockpitEntry>,
bee_buffers: BeeLogBuffers,
active_tab: LogTab,
height: u16,
spawn_active: bool,
scroll_offset: usize,
h_scroll_offset: u16,
}
impl LogPane {
pub fn new(capture: Option<LogCapture>, initial_tab: LogTab, initial_height: u16) -> Self {
Self {
capture,
self_http_entries: Vec::new(),
cockpit_capture: None,
cockpit_entries: Vec::new(),
bee_buffers: BeeLogBuffers::default(),
active_tab: initial_tab,
height: initial_height.clamp(LOG_PANE_MIN_HEIGHT, LOG_PANE_MAX_HEIGHT),
spawn_active: false,
scroll_offset: 0,
h_scroll_offset: 0,
}
}
pub fn set_cockpit_capture(&mut self, cap: CockpitCapture) {
self.cockpit_capture = Some(cap);
}
pub fn active_tab(&self) -> LogTab {
self.active_tab
}
pub fn height(&self) -> u16 {
self.height
}
pub fn set_spawn_active(&mut self, active: bool) {
self.spawn_active = active;
}
pub fn next_tab(&mut self) -> LogTab {
let i = (self.active_tab.index() + 1) % LogTab::ALL.len();
self.active_tab = LogTab::from_index(i);
self.scroll_offset = 0;
self.h_scroll_offset = 0;
self.active_tab
}
pub fn prev_tab(&mut self) -> LogTab {
let len = LogTab::ALL.len();
let i = (self.active_tab.index() + len - 1) % len;
self.active_tab = LogTab::from_index(i);
self.scroll_offset = 0;
self.h_scroll_offset = 0;
self.active_tab
}
pub fn scroll_up(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_add(lines);
}
pub fn scroll_down(&mut self, lines: usize) {
self.scroll_offset = self.scroll_offset.saturating_sub(lines);
}
pub fn resume_tail(&mut self) {
self.scroll_offset = 0;
self.h_scroll_offset = 0;
}
pub fn scroll_right(&mut self, cols: u16) {
self.h_scroll_offset = self.h_scroll_offset.saturating_add(cols);
}
pub fn scroll_left(&mut self, cols: u16) {
self.h_scroll_offset = self.h_scroll_offset.saturating_sub(cols);
}
pub fn reset_h_scroll(&mut self) {
self.h_scroll_offset = 0;
}
pub fn h_scroll_offset(&self) -> u16 {
self.h_scroll_offset
}
pub fn is_tailing(&self) -> bool {
self.scroll_offset == 0
}
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
pub fn grow(&mut self) -> u16 {
self.height = (self.height + 1).min(LOG_PANE_MAX_HEIGHT);
self.height
}
pub fn shrink(&mut self) -> u16 {
self.height = self.height.saturating_sub(1).max(LOG_PANE_MIN_HEIGHT);
self.height
}
pub fn push_bee(&mut self, tab: LogTab, line: BeeLogLine) {
let buf = match tab {
LogTab::Errors => &mut self.bee_buffers.errors,
LogTab::Warning => &mut self.bee_buffers.warning,
LogTab::Info => &mut self.bee_buffers.info,
LogTab::Debug => &mut self.bee_buffers.debug,
LogTab::BeeHttp => &mut self.bee_buffers.bee_http,
LogTab::SelfHttp | LogTab::Cockpit => return, };
let was_full = buf.len() == BEE_TAB_RING_CAPACITY;
if was_full {
buf.pop_front();
}
buf.push_back(line);
if tab == self.active_tab && self.scroll_offset > 0 && !was_full {
self.scroll_offset = self.scroll_offset.saturating_add(1);
}
}
fn pull_self_http(&mut self) {
if let Some(c) = &self.capture {
let new = c.snapshot();
if self.active_tab == LogTab::SelfHttp && self.scroll_offset > 0 {
let delta = new.len().saturating_sub(self.self_http_entries.len());
if delta > 0 {
self.scroll_offset = self.scroll_offset.saturating_add(delta);
}
}
self.self_http_entries = new;
}
}
fn pull_cockpit(&mut self) {
if let Some(c) = &self.cockpit_capture {
let new = c.snapshot();
if self.active_tab == LogTab::Cockpit && self.scroll_offset > 0 {
let delta = new.len().saturating_sub(self.cockpit_entries.len());
if delta > 0 {
self.scroll_offset = self.scroll_offset.saturating_add(delta);
}
}
self.cockpit_entries = new;
}
}
}
impl Component for LogPane {
fn update(&mut self, action: Action) -> Result<Option<Action>> {
if matches!(action, Action::Tick) {
self.pull_self_http();
self.pull_cockpit();
}
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let t = theme::active();
let active = self.active_tab;
let content_h = (area.height as usize).saturating_sub(2);
let total_lines = self.active_tab_total_lines();
let max_offset = total_lines.saturating_sub(content_h);
if self.scroll_offset > max_offset {
self.scroll_offset = max_offset;
}
let block = Block::default().borders(Borders::ALL).title(tab_title_line(
active,
&self.bee_buffers,
self.scroll_offset,
self.h_scroll_offset,
t,
));
let inner = block.inner(area);
frame.render_widget(block, area);
let content_area = Layout::vertical([Constraint::Min(0)])
.split(inner)
.first()
.copied()
.unwrap_or(inner);
let lines: Vec<Line> = match active {
LogTab::SelfHttp => render_self_http(&self.self_http_entries, t),
LogTab::Cockpit => render_cockpit(&self.cockpit_entries, t),
tab => render_bee_tab(&self.bee_buffers, tab, self.spawn_active, t),
};
let render_h = content_area.height as usize;
let visible: Vec<Line> = if lines.len() > render_h {
let end = lines.len().saturating_sub(self.scroll_offset);
let start = end.saturating_sub(render_h);
lines.into_iter().skip(start).take(end - start).collect()
} else {
lines
};
frame.render_widget(
Paragraph::new(visible).scroll((0, self.h_scroll_offset)),
content_area,
);
Ok(())
}
}
impl LogPane {
pub fn active_tab_total_lines(&self) -> usize {
match self.active_tab {
LogTab::SelfHttp => self.self_http_entries.len(),
LogTab::Cockpit => self.cockpit_entries.len(),
tab => self.bee_buffers.count(tab),
}
}
}
fn tab_title_line<'a>(
active: LogTab,
bufs: &BeeLogBuffers,
scroll_offset: usize,
h_scroll_offset: u16,
t: &theme::Theme,
) -> Line<'a> {
let mut spans: Vec<Span> = Vec::with_capacity(LogTab::ALL.len() * 2 + 2);
spans.push(Span::raw(" "));
for tab in LogTab::ALL {
let count = bufs.count(tab);
let label = if count == 0 || matches!(tab, LogTab::SelfHttp | LogTab::Cockpit) {
format!(" {} ", tab.label())
} else {
format!(" {} {} ", tab.label(), human_count(count))
};
let style = if tab == active {
Style::default()
.fg(t.tab_active_fg)
.bg(t.tab_active_bg)
.add_modifier(Modifier::BOLD)
} else {
tab_severity_color(tab, t)
};
spans.push(Span::styled(label, style));
spans.push(Span::raw(" "));
}
if scroll_offset > 0 {
spans.push(Span::styled(
format!(" paused {scroll_offset} ↑ "),
Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
));
}
if h_scroll_offset > 0 {
spans.push(Span::styled(
format!(" → {h_scroll_offset} "),
Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
));
}
Line::from(spans)
}
fn tab_severity_color(tab: LogTab, t: &theme::Theme) -> Style {
match tab {
LogTab::Errors => Style::default().fg(t.fail),
LogTab::Warning => Style::default().fg(t.warn),
LogTab::Info => Style::default().fg(t.info),
LogTab::Debug => Style::default().fg(t.dim),
LogTab::BeeHttp => Style::default().fg(t.accent),
LogTab::SelfHttp => Style::default().fg(t.dim),
LogTab::Cockpit => Style::default().fg(t.accent),
}
}
fn human_count(n: usize) -> String {
if n < 1_000 {
n.to_string()
} else if n < 1_000_000 {
format!("{:.1}k", n as f64 / 1000.0)
} else {
format!("{:.1}m", n as f64 / 1_000_000.0)
}
}
fn render_cockpit<'a>(entries: &'a [CockpitEntry], t: &theme::Theme) -> Vec<Line<'a>> {
if entries.is_empty() {
return vec![Line::from(Span::styled(
" (no cockpit-internal events captured yet)",
Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
))];
}
entries.iter().map(|e| cockpit_line(e, t)).collect()
}
fn cockpit_line<'a>(e: &'a CockpitEntry, t: &theme::Theme) -> Line<'a> {
let level_style = match e.level.as_str() {
"ERROR" => Style::default().fg(t.fail).add_modifier(Modifier::BOLD),
"WARN" => Style::default().fg(t.warn).add_modifier(Modifier::BOLD),
"INFO" => Style::default().fg(t.info),
_ => Style::default().fg(t.dim),
};
Line::from(vec![
Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
Span::styled(format!("{:<5}", e.level), level_style),
Span::raw(" "),
Span::styled(
format!("{:<22}", trim_target(&e.target)),
Style::default().fg(t.accent),
),
Span::raw(" "),
Span::raw(e.message.clone()),
])
}
fn trim_target(target: &str) -> &str {
target.strip_prefix("bee_tui::").unwrap_or(target)
}
fn render_self_http<'a>(entries: &'a [LogEntry], t: &theme::Theme) -> Vec<Line<'a>> {
if entries.is_empty() {
return vec![Line::from(Span::styled(
" (waiting for first request…)",
Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
))];
}
entries.iter().map(|e| self_http_line(e, t)).collect()
}
fn render_bee_tab<'a>(
bufs: &'a BeeLogBuffers,
tab: LogTab,
spawn_active: bool,
t: &theme::Theme,
) -> Vec<Line<'a>> {
let buf = match bufs.buffer_for(tab) {
Some(b) => b,
None => return Vec::new(),
};
if buf.is_empty() {
let msg = if spawn_active {
" (awaiting bee log entries on this severity…)"
} else {
" (no bee child — set [bee] in config or pass --bee-bin / --bee-config)"
};
return vec![Line::from(Span::styled(
msg,
Style::default().fg(t.dim).add_modifier(Modifier::ITALIC),
))];
}
buf.iter()
.map(|line| {
Line::from(vec![
Span::styled(format!("{} ", line.timestamp), Style::default().fg(t.dim)),
Span::styled(
format!("{:<22}", line.logger),
Style::default().fg(t.accent).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw(line.message.clone()),
])
})
.collect()
}
fn self_http_line<'a>(e: &'a LogEntry, t: &theme::Theme) -> Line<'a> {
let status_style = match e.status {
Some(s) if (200..300).contains(&s) => Style::default().fg(t.pass),
Some(s) if (300..400).contains(&s) => Style::default().fg(t.info),
Some(s) if (400..500).contains(&s) => Style::default().fg(t.warn),
Some(_) => Style::default().fg(t.fail),
None => Style::default().fg(t.dim),
};
let method_style = Style::default()
.fg(method_color(&e.method))
.add_modifier(Modifier::BOLD);
let elapsed = e
.elapsed_ms
.map(|ms| format!("{ms:>4}ms"))
.unwrap_or_else(|| " —".into());
let path = path_only(&e.url);
Line::from(vec![
Span::styled(format!("{} ", e.ts), Style::default().fg(t.dim)),
Span::styled(format!("{:<5}", e.method), method_style),
Span::raw(" "),
Span::raw(path),
Span::raw(" "),
Span::styled(
e.status
.map(|s| s.to_string())
.unwrap_or_else(|| "—".into()),
status_style,
),
Span::raw(" "),
Span::styled(elapsed, Style::default().fg(t.dim)),
])
}
fn method_color(method: &str) -> Color {
match method {
"GET" => Color::Blue,
"POST" => Color::Green,
"PUT" => Color::Yellow,
"DELETE" => Color::Red,
"PATCH" => Color::Magenta,
"HEAD" => Color::Cyan,
_ => Color::White,
}
}
fn path_only(url: &str) -> String {
if let Some(rest) = url.split_once("//").and_then(|(_, r)| r.split_once('/')) {
format!("/{}", rest.1)
} else {
url.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn next_tab_wraps() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
for expected in [
LogTab::Warning,
LogTab::Info,
LogTab::Debug,
LogTab::BeeHttp,
LogTab::SelfHttp,
LogTab::Cockpit,
LogTab::Errors,
] {
assert_eq!(pane.next_tab(), expected);
}
}
#[test]
fn prev_tab_wraps() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
for expected in [
LogTab::Cockpit,
LogTab::SelfHttp,
LogTab::BeeHttp,
LogTab::Debug,
LogTab::Info,
LogTab::Warning,
LogTab::Errors,
] {
assert_eq!(pane.prev_tab(), expected);
}
}
#[test]
fn grow_clamps_at_max() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MAX_HEIGHT - 1);
pane.grow();
assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
pane.grow();
pane.grow();
assert_eq!(pane.height(), LOG_PANE_MAX_HEIGHT);
}
#[test]
fn shrink_clamps_at_min() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT + 1);
pane.shrink();
assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
pane.shrink();
pane.shrink();
assert_eq!(pane.height(), LOG_PANE_MIN_HEIGHT);
}
#[test]
fn fresh_pane_is_tailing() {
let pane = LogPane::new(None, LogTab::Errors, 10);
assert!(pane.is_tailing());
assert_eq!(pane.scroll_offset(), 0);
}
#[test]
fn scroll_up_disables_tail_and_remembers_offset() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.scroll_up(3);
assert!(!pane.is_tailing());
assert_eq!(pane.scroll_offset(), 3);
pane.scroll_up(2);
assert_eq!(pane.scroll_offset(), 5);
}
#[test]
fn scroll_down_eventually_resumes_tail() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.scroll_up(5);
pane.scroll_down(2);
assert_eq!(pane.scroll_offset(), 3);
pane.scroll_down(100);
assert_eq!(pane.scroll_offset(), 0);
assert!(pane.is_tailing());
}
#[test]
fn resume_tail_resets_offset() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.scroll_up(7);
pane.resume_tail();
assert!(pane.is_tailing());
}
#[test]
fn tab_switch_resets_scroll_offset() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.scroll_up(4);
pane.next_tab();
assert_eq!(pane.scroll_offset(), 0);
assert!(pane.is_tailing());
pane.scroll_up(4);
pane.prev_tab();
assert_eq!(pane.scroll_offset(), 0);
}
#[test]
fn push_bee_bumps_offset_for_active_tab_when_scrolled() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.push_bee(LogTab::Errors, line("err1"));
pane.push_bee(LogTab::Errors, line("err2"));
pane.scroll_up(2);
pane.push_bee(LogTab::Errors, line("err3"));
assert_eq!(pane.scroll_offset(), 3);
}
#[test]
fn push_bee_doesnt_bump_offset_when_tailing() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
for i in 0..5 {
pane.push_bee(LogTab::Errors, line(&format!("e{i}")));
}
assert_eq!(pane.scroll_offset(), 0);
assert!(pane.is_tailing());
}
#[test]
fn push_bee_doesnt_bump_offset_for_inactive_tab() {
let mut pane = LogPane::new(None, LogTab::Errors, 10);
pane.push_bee(LogTab::Errors, line("err1"));
pane.scroll_up(1);
let before = pane.scroll_offset();
pane.push_bee(LogTab::Debug, line("dbg1"));
assert_eq!(pane.scroll_offset(), before);
}
fn line(msg: &str) -> BeeLogLine {
BeeLogLine {
timestamp: "t".into(),
logger: "node/test".into(),
message: msg.into(),
}
}
#[test]
fn ring_capacity_is_enforced() {
let mut pane = LogPane::new(None, LogTab::Debug, 10);
for i in 0..(BEE_TAB_RING_CAPACITY + 100) {
pane.push_bee(
LogTab::Debug,
BeeLogLine {
timestamp: format!("t{i}"),
logger: "node/test".into(),
message: format!("msg {i}"),
},
);
}
assert_eq!(pane.bee_buffers.debug.len(), BEE_TAB_RING_CAPACITY);
assert_eq!(pane.bee_buffers.debug.front().unwrap().timestamp, "t100");
assert_eq!(
pane.bee_buffers.debug.back().unwrap().timestamp,
format!("t{}", BEE_TAB_RING_CAPACITY + 99)
);
}
#[test]
fn push_bee_to_self_http_is_noop() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, 10);
pane.push_bee(
LogTab::SelfHttp,
BeeLogLine {
timestamp: "t".into(),
logger: "x".into(),
message: "m".into(),
},
);
for tab in LogTab::ALL {
assert_eq!(pane.bee_buffers.count(tab), 0, "tab {tab:?} got an entry");
}
}
#[test]
fn human_count_formats_thousands() {
assert_eq!(human_count(0), "0");
assert_eq!(human_count(42), "42");
assert_eq!(human_count(999), "999");
assert_eq!(human_count(1000), "1.0k");
assert_eq!(human_count(1234), "1.2k");
assert_eq!(human_count(999_999), "1000.0k");
assert_eq!(human_count(1_000_000), "1.0m");
}
#[test]
fn from_kebab_unknown_falls_back_to_self_http() {
assert_eq!(LogTab::from_kebab("future-tab"), LogTab::SelfHttp);
assert_eq!(LogTab::from_kebab(""), LogTab::SelfHttp);
}
#[test]
fn kebab_round_trips() {
for tab in LogTab::ALL {
assert_eq!(LogTab::from_kebab(tab.to_kebab()), tab);
}
}
#[test]
fn path_only_strips_scheme_and_host() {
assert_eq!(path_only("http://localhost:1633/status"), "/status");
assert_eq!(
path_only("https://bee.example.com:1633/stamps/abc"),
"/stamps/abc"
);
}
#[test]
fn path_only_handles_root_only() {
assert_eq!(path_only("http://localhost:1633"), "http://localhost:1633");
}
#[test]
fn h_scroll_starts_at_zero() {
let pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
assert_eq!(pane.h_scroll_offset(), 0);
}
#[test]
fn scroll_right_then_left_returns_to_zero() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
pane.scroll_right(8);
pane.scroll_right(8);
assert_eq!(pane.h_scroll_offset(), 16);
pane.scroll_left(16);
assert_eq!(pane.h_scroll_offset(), 0);
}
#[test]
fn scroll_left_saturates_at_zero() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
pane.scroll_left(100);
assert_eq!(pane.h_scroll_offset(), 0);
}
#[test]
fn switching_tabs_resets_h_scroll() {
let mut pane = LogPane::new(None, LogTab::Errors, LOG_PANE_MIN_HEIGHT);
pane.scroll_right(40);
assert_eq!(pane.h_scroll_offset(), 40);
pane.next_tab();
assert_eq!(pane.h_scroll_offset(), 0);
pane.scroll_right(20);
pane.prev_tab();
assert_eq!(pane.h_scroll_offset(), 0);
}
#[test]
fn resume_tail_resets_both_axes() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
pane.scroll_up(5);
pane.scroll_right(24);
pane.resume_tail();
assert_eq!(pane.scroll_offset(), 0);
assert_eq!(pane.h_scroll_offset(), 0);
}
#[test]
fn reset_h_scroll_only_touches_horizontal() {
let mut pane = LogPane::new(None, LogTab::SelfHttp, LOG_PANE_MIN_HEIGHT);
pane.scroll_up(7);
pane.scroll_right(16);
pane.reset_h_scroll();
assert_eq!(pane.scroll_offset(), 7);
assert_eq!(pane.h_scroll_offset(), 0);
}
}