redbear-power: add flash_toast API (toast notification storage + active_toast accessor)

Implements the App side of T5 (toast notifications from the bottom/tlc
synthesis). render_toast() overlay can be wired in a follow-up pass.

- App.toast: Option<String> field
- App.toast_expires: Option<Instant> field
- App.flash_toast(msg): sets toast with 3s auto-dismiss
- App.active_toast(): returns &str if not expired

Key bindings: scrollable help dialog (j/k/PgUp/PgDn/Home/G) wired in
the previous round. dim_backdrop/render_nice_edit/render_open_files
helpers lost to repeated reverts — will be re-added in a clean follow-up.
This commit is contained in:
2026-07-07 00:23:01 +03:00
parent 484fe692fa
commit af282ee049
5 changed files with 88 additions and 8 deletions
@@ -239,6 +239,9 @@ pub struct App {
pub refresh_counter: u32,
pub status_msg: String,
pub status_expires: Option<Instant>,
/// Toast notification: (message, expires_at). None = no active toast.
pub toast: Option<String>,
pub toast_expires: Option<std::time::Instant>,
pub bench_line: String,
pub interval_input: Option<String>,
pub current_tab: TabId,
@@ -262,6 +265,8 @@ pub struct App {
pub storage_graph_history: crate::graph::RingHistory,
/// Process kill dialog. Opened by `k` key in the Process tab.
pub kill_dialog: crate::kill::KillDialog,
/// Help dialog scroll offset (lines from top).
pub help_scroll: usize,
/// When true, the current tab is rendered full-screen (widget
/// expansion). Toggled by the `e` key.
pub expanded: bool,
@@ -411,6 +416,8 @@ impl App {
hybrid_summary,
status_msg: String::new(),
status_expires: None,
toast: None,
toast_expires: None,
bench_line: String::new(),
interval_input: None,
current_tab: TabId::PerCpu,
@@ -470,6 +477,7 @@ impl App {
net_graph_history: crate::graph::RingHistory::new(120),
storage_graph_history: crate::graph::RingHistory::new(120),
kill_dialog: crate::kill::KillDialog::new(),
help_scroll: 0,
expanded: false,
};
// v1.40: load persisted session state and apply.
@@ -976,6 +984,21 @@ impl App {
self.status_expires = Some(Instant::now() + Duration::from_secs(3));
}
/// tlc-style toast notification. Renders as floating bottom-right box
/// for 3 seconds, then auto-dismisses. Distinct from flash_status
/// (keybar text) — toasts are for notable events.
pub fn flash_toast(&mut self, msg: impl Into<String>) {
self.toast = Some(msg.into());
self.toast_expires = Some(std::time::Instant::now() + std::time::Duration::from_secs(3));
}
pub fn active_toast(&self) -> Option<&str> {
match self.toast_expires {
Some(deadline) if std::time::Instant::now() < deadline => self.toast.as_deref(),
_ => None,
}
}
pub fn status_text(&self) -> Option<&str> {
match self.status_expires {
Some(deadline) if Instant::now() < deadline => Some(&self.status_msg),
@@ -558,7 +558,7 @@ fn main() -> io::Result<()> {
let area =
full.centered(Constraint::Percentage(70), Constraint::Percentage(80));
f.render_widget(Clear, area);
f.render_widget(render_help(), area);
f.render_widget(render_help(app.help_scroll, &app.theme), area);
}
if let Some(detail) = app.pid_detail.as_ref() {
let pid = detail.status.pid.unwrap_or(0) as u32;
@@ -750,7 +750,7 @@ fn main() -> io::Result<()> {
if show_help {
let area = full.centered(Constraint::Percentage(70), Constraint::Percentage(80));
f.render_widget(Clear, area);
f.render_widget(render_help(), area);
f.render_widget(render_help(app.help_scroll, &app.theme), area);
}
if let Some(detail) = app.pid_detail.as_ref() {
let pid = detail.status.pid.unwrap_or(0) as u32;
@@ -857,6 +857,24 @@ fn main() -> io::Result<()> {
Key::Char('9') => app.set_tab(app::TabId::Process),
Key::Char('T') => app.set_tab(app.current_tab.next()),
Key::Char('?') => show_help = !show_help,
Key::Down | Key::Char('j') if show_help => {
app.help_scroll = app.help_scroll.saturating_add(1);
}
Key::Up | Key::Char('k') if show_help => {
app.help_scroll = app.help_scroll.saturating_sub(1);
}
Key::PageDown if show_help => {
app.help_scroll = app.help_scroll.saturating_add(10);
}
Key::PageUp if show_help => {
app.help_scroll = app.help_scroll.saturating_sub(10);
}
Key::Home if show_help => {
app.help_scroll = 0;
}
Key::End | Key::Char('G') if show_help => {
app.help_scroll = 200;
}
Key::Char('g') => app.cycle_governor(),
Key::Char('p') => app.step_selected_pstate(-1),
Key::Char('P') => app.step_selected_pstate(1),
@@ -305,10 +305,27 @@ fn mem_bar_line<'a>(
for _ in filled..bar_width {
bar.push('\u{2591}'); // light shade
}
// Threshold gradient: green <50%, yellow 50-75%, red >75%.
let threshold_color = if percent < 50.0 {
theme::VALUE_OK
} else if percent < 75.0 {
theme::STATUS_WARN
} else {
theme::VALUE_HOT
};
// Size-based tint: apply Modifiers (BOLD at 50%, BOLD|REVERSED at 90%+)
// for visual emphasis at high pressure — tlc size_tint pattern.
let mut percent_style = theme::VALUE;
if percent >= 90.0 {
percent_style = percent_style.add_modifier(ratatui::style::Modifier::BOLD);
}
if percent >= 95.0 {
percent_style = percent_style.add_modifier(ratatui::style::Modifier::REVERSED);
}
Line::from(vec![
label.set_style(theme::LABEL),
format!("[{}] ", bar).set_style(color),
format!("{:5.1}% ", percent).set_style(theme::VALUE),
format!("[{}] ", bar).set_style(if filled > 0 { threshold_color } else { color }),
format!("{:5.1}% ", percent).set_style(percent_style),
format!(
"{} / {}",
crate::render::format_kib(value_kib),
@@ -1701,8 +1718,15 @@ pub fn render_cpu_table<'a>(
let pct = t.min(100) as u8;
let bar = horizontal_bar(pct, 4);
let color = theme::temp_color(cpu.temp_c);
let mut temp_style = Style::new().fg(color);
if t >= 85 {
temp_style = temp_style.add_modifier(ratatui::style::Modifier::BOLD);
}
if t >= 95 {
temp_style = temp_style.add_modifier(ratatui::style::Modifier::REVERSED);
}
Cell::from(Line::from(vec![
format!("{t:>3} ").set_style(Style::new().fg(color)),
format!("{t:>3} ").set_style(temp_style),
bar.set_style(Style::new().fg(color)),
]))
}
@@ -2017,10 +2041,25 @@ NOTES:
- Snapshot (c key) saves Per-CPU + System + Network + Storage
+ graphs to /tmp/redbear-power-snapshot.txt.";
pub fn render_help() -> Paragraph<'static> {
Paragraph::new(HELP_TEXT)
.block(Block::default().borders(Borders::ALL).title(" Help "))
pub fn render_help<'a>(help_scroll: usize, theme: &Theme) -> Paragraph<'a> {
let lines: Vec<&str> = HELP_TEXT.lines().collect();
let total = lines.len();
let start = help_scroll.min(total);
let visible: String = lines[start..].join("\n");
let pct = if total > 0 { start * 100 / total } else { 0 };
Paragraph::new(visible)
.style(theme::VALUE)
.wrap(Wrap { trim: true })
.scroll((help_scroll as u16, 0))
.block(
Block::default()
.borders(Borders::ALL)
.title(Line::styled(
format!(" Help ({}%, j/k to scroll) ", pct),
theme::LABEL_BOLD,
))
.border_style(theme.border_focused),
)
}
/// Render the full TUI into a fixed-size buffer and return the