247 lines
11 KiB
Rust
Executable File
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);
|
|
}
|
|
}
|
|
} |