//! 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::() } 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); } } }