nirc-rs/src/tui/winlist.rs

247 lines
11 KiB
Rust
Executable File

//! Naim-style window list (right-side buddy/chat list panel).
//!
//! Renders a vertical list of open windows on the right edge of the chat area,
//! overlaid (not a separate layout block). Uses box-drawing characters and
//! right-justified entry names, matching the original naim C client.
//!
//! ## D2: Protocol badges (0.2.0)
//!
//! Each entry is prefixed with a 1-character protocol badge followed by a
//! space, colored by protocol (IRC=Cyan I, Matrix=Magenta M, ADC=Blue A,
//! BitChat=Green P). This makes it visually obvious which protocol a tab
//! belongs to when multiple protocols are connected simultaneously.
//!
//! ```
//! ┌ IRC
//! ├> I #rust ← IRC channel, waiting/unread (cyan badge)
//! │ M matrix-room← Matrix room, current (magenta badge)
//! └ P p2p-room ← BitChat room, last entry (green badge)
//! ```
//!
//! Badges are suppressed when `content_width < 8` or when `with_badges(false)`
//! is set, defaulting to the badgeless rendering.
use crate::core::app::Tab;
use crate::core::protocol::ProtocolType;
use crate::tui::foundation::{NaimColor, NaimPalette, NaimStyle};
use ratatui::prelude::*;
use ratatui::widgets::Widget;
/// Widget that renders the naim-style right-side window list.
pub struct WinlistWidget<'a> {
/// All open tabs/windows.
tabs: &'a [Tab],
/// Index of the currently active tab.
active_idx: usize,
/// Color palette.
palette: &'a NaimPalette,
/// Total width of the winlist (including border column). From `winlistchars` config.
winlistchars: u16,
/// Height as a percentage of the chat area height. From `winlistheight%` config.
winlistheight: u8,
/// Connection name shown in the header (e.g. "IRC").
connection_name: &'a str,
/// When true, render a 1-character protocol badge (I/M/A/P) before each tab
/// title. Takes 2 columns (badge + space). Automatically suppressed when the
/// content area is too narrow (<8 cols). Default true in 0.2.0.
show_badges: bool,
}
impl<'a> WinlistWidget<'a> {
pub fn new(
tabs: &'a [Tab],
active_idx: usize,
palette: &'a NaimPalette,
winlistchars: u16,
winlistheight: u8,
connection_name: &'a str,
) -> Self {
Self {
tabs,
active_idx,
palette,
// Minimum 6 columns: 1 border + 1 box-char + 1 space + 2 char name + 1 padding
winlistchars: winlistchars.max(6),
winlistheight: winlistheight.clamp(10, 100),
connection_name,
// protocol badges on by default. Disable via `with_badges(false)`
// for very narrow windows or user preference.
show_badges: true,
}
}
/// Builder-style setter for `show_badges`. Pass `false` to suppress the
/// protocol badge column (useful for narrow winlists or user preference).
pub fn with_badges(mut self, show: bool) -> Self {
self.show_badges = show;
self
}
}
impl Widget for WinlistWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if self.tabs.is_empty() || area.width < 8 || area.height < 3 {
return;
}
let total_width = self.winlistchars.min(area.width);
// Rightmost column is the vertical border; rest is content.
let content_width = total_width.saturating_sub(1);
// Header (1 line) + entries. Max entries = content capacity.
let max_entry_lines = (self.tabs.len() as u16 + 1).min(u16::MAX);
// Height = winlistheight% of chat area, but at least 2 (header + 1 entry).
let available_height = ((area.height as u32 * self.winlistheight as u32) / 100) as u16;
let widget_height = max_entry_lines.min(available_height.max(2)).min(area.height);
// Position on the RIGHT edge of the area, bottom-aligned.
let x = area.x + area.width - total_width;
let y = area.y + area.height.saturating_sub(widget_height);
// ── Background fill ─────────────────────────────────────────────
let bg_style = NaimStyle::pair(NaimColor::White, self.palette.winlist_bg);
for dy in 0..widget_height {
for dx in 0..content_width {
buf.set_string(x + dx, y + dy, " ", bg_style);
}
}
// ── Right border column (│) ─────────────────────────────────────
let border_style = NaimStyle::pair(self.palette.winlist_bg, NaimColor::Clear);
for dy in 0..widget_height {
buf.set_string(x + content_width, y + dy, "", border_style);
}
// ── Connection name header ──────────────────────────────────────
// Format: "┌ ConnectionName" right-justified in content_width.
let header_name: String = self.connection_name.chars().take((content_width as usize).saturating_sub(2)).collect();
let header_display = format!("{}", header_name);
// Right-justify: pad with spaces on the left.
let header_padded = if header_display.chars().count() >= content_width as usize {
header_display.chars().take(content_width as usize).collect::<String>()
} else {
let w = content_width as usize;
format!("{:>width$}", header_display, width = w)
};
let header_style = NaimStyle::bold_pair(NaimColor::White, self.palette.winlist_bg);
buf.set_string(x, y, &header_padded, header_style);
// ── Determine visible entries ───────────────────────────────────
let avail_lines = widget_height.saturating_sub(1) as usize; // -1 for header
if avail_lines == 0 {
return;
}
let total_entries = self.tabs.len();
let (start, end) = if total_entries <= avail_lines {
(0, total_entries)
} else {
// Scroll to keep the active tab visible.
let mut s = self.active_idx.saturating_sub(avail_lines / 2);
if s + avail_lines > total_entries {
s = total_entries.saturating_sub(avail_lines);
}
(s, s + avail_lines)
};
let visible: Vec<&Tab> = self.tabs[start..end].iter().collect();
for (i, tab) in visible.iter().enumerate() {
let global_idx = start + i;
let cy = y + 1 + i as u16;
if cy >= y + widget_height {
break;
}
let is_active = global_idx == self.active_idx;
let has_unread = tab.unread_count() > 0;
let is_last = (start + i + 1) >= total_entries;
// ── Box-drawing prefix ──────────────────────────────────
// Active (current) window: │
// Waiting/unread: ├>
// Last entry: └ (or └> if waiting)
// Middle entry: ├
// Server (is_server): ├ (no special char in naim)
let prefix = if is_active {
"".to_owned()
} else if has_unread && is_last {
"└>".to_owned()
} else if has_unread {
"├>".to_owned()
} else if is_last {
"".to_owned()
} else {
"".to_owned()
};
// ── Style ──────────────────────────────────────────────
// 0.9.0 fix: always use explicit fg (not Clear/Reset) so text is
// visible against the colored winlist backgrounds.
let (fg, bg) = if is_active {
(NaimColor::White, self.palette.winlist_hl_bg)
} else if has_unread {
(self.palette.buddy_waiting_fg, self.palette.winlist_bg)
} else {
(self.palette.text_fg, self.palette.winlist_bg)
};
let entry_style = NaimStyle::pair(fg, bg);
// ── Right-justify the tab title ─────────────────────────
let prefix_char_count = prefix.chars().count();
// D2: protocol badge — 1 char + 1 space = 2 cols. Only when enabled
// AND there's room (content_width >= 8, matching the early-return
// threshold so we never render a badge into a <8-col winlist).
let show_badge = self.show_badges && content_width >= 8;
let badge_width = if show_badge { 2usize } else { 0usize };
let title_avail = (content_width as usize)
.saturating_sub(prefix_char_count)
.saturating_sub(badge_width);
let display_title: String = tab.title.chars().take(title_avail).collect();
// Right-justify the title within the available space.
let padded_title = if display_title.len() >= title_avail {
display_title
} else {
format!("{:>width$}", display_title, width = title_avail)
};
// Render the box-drawing prefix (always, in entry_style).
buf.set_string(x, cy, &prefix, entry_style);
// D2: render the protocol badge between prefix and title.
if show_badge {
let badge_char = match tab.protocol {
ProtocolType::Irc => "I",
ProtocolType::Matrix => "M",
ProtocolType::Adc => "A",
ProtocolType::BitChat => "P",
ProtocolType::Discord => "D",
ProtocolType::Stout => "S",
ProtocolType::Spacebar => "S",
ProtocolType::Nerimity => "N",
};
let badge_color = match tab.protocol {
ProtocolType::Irc => NaimColor::Cyan,
ProtocolType::Matrix => NaimColor::Magenta,
ProtocolType::Adc => NaimColor::Blue,
ProtocolType::BitChat => NaimColor::Green,
ProtocolType::Discord => NaimColor::White,
ProtocolType::Stout => NaimColor::Yellow,
ProtocolType::Spacebar => NaimColor::Red,
ProtocolType::Nerimity => NaimColor::BrightMagenta,
};
let badge_style = NaimStyle::pair(badge_color, bg);
let badge_x = x + prefix_char_count as u16;
buf.set_string(badge_x, cy, badge_char, badge_style);
// Spacer column keeps the badge visually distinct from the title
// and inherits the entry style so its bg matches the row.
buf.set_string(badge_x + 1, cy, " ", entry_style);
}
// Render the right-justified title after prefix (+ badge if shown).
let title_x = x + (prefix_char_count + badge_width) as u16;
buf.set_string(title_x, cy, &padded_title, entry_style);
}
}
}