Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
522 lines
18 KiB
Rust
522 lines
18 KiB
Rust
//! Smooth scrollbar widget with follow-mode awareness.
|
|
//!
|
|
//! This module provides scrollbar rendering using `tui-scrollbar` for smooth
|
|
//! Unicode-based scrollbars with sub-character precision.
|
|
//!
|
|
//! # Visual Design
|
|
//!
|
|
//! The scrollbar visibility indicates follow mode state:
|
|
//! - **Following (at bottom):** Very dim scrollbar (subtle indicator of content above)
|
|
//! - **Not following:** Brighter scrollbar (draws attention to "scrolled up" state)
|
|
//!
|
|
//! This helps users understand when they're viewing live content vs. scrolled back.
|
|
//!
|
|
//! # Layout
|
|
//!
|
|
//! Callers should reserve space for the scrollbar:
|
|
//! - 1 column gap (visual separation from content)
|
|
//! - 1 column track (the scrollbar itself)
|
|
//!
|
|
//! Use [`split_area_for_scrollbar`] to compute content and scrollbar areas.
|
|
//!
|
|
//! # TODO: Mouse Support
|
|
//!
|
|
//! `tui-scrollbar` already provides mouse interaction support via:
|
|
//! - [`tui_scrollbar::ScrollBarInteraction`] for drag state
|
|
//! - [`tui_scrollbar::ScrollEvent`] / [`tui_scrollbar::PointerEvent`] for input
|
|
//! - [`tui_scrollbar::ScrollBar::handle_event`] for hit testing and drag math
|
|
//!
|
|
//! To wire this up:
|
|
//! 1. Store `ScrollBarInteraction` in pane state
|
|
//! 2. Translate crossterm `MouseEvent` to `tui_scrollbar::PointerEvent`
|
|
//! 3. Call `scrollbar.handle_event()` to get `ScrollCommand::SetOffset`
|
|
//! 4. Update scroll position accordingly
|
|
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use ratatui::buffer::Buffer;
|
|
use ratatui::layout::Rect;
|
|
use ratatui::style::Style;
|
|
use ratatui_core::buffer::Buffer as CoreBuffer;
|
|
use ratatui_core::layout::Rect as CoreRect;
|
|
use ratatui_core::widgets::Widget as _;
|
|
use tui_scrollbar::ScrollBar;
|
|
use tui_scrollbar::ScrollLengths;
|
|
use tui_scrollbar::{SUBCELL, ScrollMetrics};
|
|
|
|
/// When set, every scrollbar renders as a no-op. The pager toggles this on in
|
|
/// minimal (scrollback-native) mode, where lists/dropdowns show
|
|
/// no scrollbar bar at all — they scroll internally and the footer carries the
|
|
/// "↑/↓ navigate" hint. Off (default) everywhere else, so the full TUI is
|
|
/// unaffected.
|
|
static SCROLLBARS_HIDDEN: AtomicBool = AtomicBool::new(false);
|
|
|
|
/// Globally hide or show all scrollbars. See [`SCROLLBARS_HIDDEN`].
|
|
pub fn set_scrollbars_hidden(hidden: bool) {
|
|
SCROLLBARS_HIDDEN.store(hidden, Ordering::Relaxed);
|
|
}
|
|
|
|
/// Whether scrollbars are currently globally hidden.
|
|
pub fn scrollbars_hidden() -> bool {
|
|
SCROLLBARS_HIDDEN.load(Ordering::Relaxed)
|
|
}
|
|
|
|
/// Number of columns reserved between content and the scrollbar track.
|
|
/// This creates the "X" gap in the XSXBXX pattern (gap between selection_right and scrollbar).
|
|
const SCROLLBAR_GAP_COLS: u16 = 1;
|
|
|
|
/// Width of the scrollbar track itself (in terminal cells).
|
|
const SCROLLBAR_TRACK_COLS: u16 = 1;
|
|
|
|
/// Total columns reserved for scrollbar UI (gap + track).
|
|
pub const SCROLLBAR_TOTAL_COLS: u16 = SCROLLBAR_GAP_COLS + SCROLLBAR_TRACK_COLS;
|
|
|
|
/// Split an area into content + scrollbar regions.
|
|
///
|
|
/// Layout:
|
|
/// - `content_area`: original area minus [`SCROLLBAR_TOTAL_COLS`] on the right
|
|
/// - `scrollbar_area`: the last column of the original area (1 cell wide)
|
|
/// - The column between them is the "gap" (left intentionally blank)
|
|
///
|
|
/// Returns `(content_area, None)` when the terminal is too narrow.
|
|
///
|
|
/// **Note**: This always reserves space for scrollbar. Use [`maybe_split_for_scrollbar`]
|
|
/// to only reserve space when the scrollbar will actually be shown.
|
|
pub fn split_area_for_scrollbar(area: Rect) -> (Rect, Option<Rect>) {
|
|
if area.width <= SCROLLBAR_TOTAL_COLS {
|
|
return (area, None);
|
|
}
|
|
|
|
let content_width = area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
|
|
let content_area = Rect {
|
|
x: area.x,
|
|
y: area.y,
|
|
width: content_width,
|
|
height: area.height,
|
|
};
|
|
let scrollbar_area = Rect {
|
|
x: area.right().saturating_sub(1),
|
|
y: area.y,
|
|
width: SCROLLBAR_TRACK_COLS,
|
|
height: area.height,
|
|
};
|
|
|
|
(content_area, Some(scrollbar_area))
|
|
}
|
|
|
|
/// Split an area only if scrollbar is actually needed.
|
|
///
|
|
/// Unlike [`split_area_for_scrollbar`], this gives full width to content
|
|
/// when scrollbar won't be shown (`total_lines <= viewport_lines`).
|
|
///
|
|
/// Use this when you know the content height before splitting.
|
|
pub fn maybe_split_for_scrollbar(area: Rect, total_lines: u16) -> (Rect, Option<Rect>) {
|
|
// Only reserve space if scrollbar will actually be shown
|
|
if needs_scrollbar(total_lines, area.height) {
|
|
split_area_for_scrollbar(area)
|
|
} else {
|
|
// No scrollbar needed - give full width to content
|
|
(area, None)
|
|
}
|
|
}
|
|
|
|
/// Whether the scrollbar should be shown (content overflows viewport).
|
|
pub fn needs_scrollbar(total_lines: u16, viewport_lines: u16) -> bool {
|
|
total_lines > viewport_lines
|
|
}
|
|
|
|
/// Whether the view is at the bottom (following mode position).
|
|
#[allow(dead_code)] // Useful helper, kept for future use
|
|
pub fn is_at_bottom(total_lines: u16, viewport_lines: u16, offset: u16) -> bool {
|
|
let max_offset = total_lines.saturating_sub(viewport_lines);
|
|
offset >= max_offset
|
|
}
|
|
|
|
/// Result of mapping a scrollbar click/drag position to a scroll offset.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ScrollbarClickResult {
|
|
/// Jump to the very top (click on first row of track).
|
|
Top,
|
|
/// Jump to the very bottom (click on last row of track).
|
|
Bottom,
|
|
/// Set scroll offset to this value (proportional position).
|
|
Offset(usize),
|
|
}
|
|
|
|
/// Map a click on the scrollbar gutter to a scroll offset.
|
|
///
|
|
/// Uses the same `tui_scrollbar::ScrollMetrics` that the renderer uses to
|
|
/// position the thumb, so the click is the exact inverse of the rendering.
|
|
/// Emulates `JumpToClick` behavior: centers the thumb on the click position.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `cell_index` — 0-based row within the scrollbar area (screen_y - sb.y)
|
|
/// * `track_cells` — height of the scrollbar area (sb.height)
|
|
/// * `total_lines` — total content height (pre-scaled)
|
|
/// * `viewport_lines` — viewport height
|
|
///
|
|
/// Returns `Top`/`Bottom` for clicks on the first/last row, otherwise
|
|
/// an offset that places the thumb centered on the click.
|
|
pub fn scrollbar_click_to_offset(
|
|
cell_index: u16,
|
|
track_cells: u16,
|
|
total_lines: u16,
|
|
viewport_lines: u16,
|
|
) -> ScrollbarClickResult {
|
|
if track_cells == 0 {
|
|
return ScrollbarClickResult::Top;
|
|
}
|
|
|
|
// First row → go to top.
|
|
if cell_index == 0 {
|
|
return ScrollbarClickResult::Top;
|
|
}
|
|
// Last row → go to bottom.
|
|
if cell_index >= track_cells.saturating_sub(1) {
|
|
return ScrollbarClickResult::Bottom;
|
|
}
|
|
|
|
let lengths = ScrollLengths {
|
|
content_len: total_lines as usize,
|
|
viewport_len: viewport_lines as usize,
|
|
};
|
|
let metrics = ScrollMetrics::new(lengths, 0, track_cells);
|
|
|
|
// Center the thumb on the clicked cell (same as tui_scrollbar JumpToClick).
|
|
let position = (cell_index as usize)
|
|
.saturating_mul(SUBCELL)
|
|
.saturating_add(SUBCELL / 2);
|
|
let half_thumb = metrics.thumb_len() / 2;
|
|
let thumb_start = position.saturating_sub(half_thumb);
|
|
let offset = metrics.offset_for_thumb_start(thumb_start);
|
|
|
|
ScrollbarClickResult::Offset(offset)
|
|
}
|
|
|
|
/// Render a scrollbar with follow-mode aware styling.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// * `buf` - The ratatui buffer to render into
|
|
/// * `scrollbar_area` - The 1-column area for the scrollbar track
|
|
/// * `total_lines` - Total content height in lines
|
|
/// * `viewport_lines` - Visible viewport height in lines
|
|
/// * `offset` - Current scroll offset (lines from top)
|
|
/// * `is_following` - Whether follow mode is active (dims the scrollbar)
|
|
///
|
|
/// The scrollbar is always rendered when content overflows, but styled differently
|
|
/// based on follow state:
|
|
/// - Following: very dim (subtle indicator)
|
|
/// - Not following: brighter (draws attention)
|
|
pub fn render_scrollbar(
|
|
buf: &mut Buffer,
|
|
scrollbar_area: Option<Rect>,
|
|
total_lines: u16,
|
|
viewport_lines: u16,
|
|
offset: u16,
|
|
is_following: bool,
|
|
) {
|
|
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
let Some(scrollbar_area) = scrollbar_area else {
|
|
return;
|
|
};
|
|
|
|
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
|
return;
|
|
}
|
|
|
|
if !needs_scrollbar(total_lines, viewport_lines) {
|
|
return;
|
|
}
|
|
|
|
let lengths = ScrollLengths {
|
|
content_len: total_lines as usize,
|
|
viewport_len: viewport_lines as usize,
|
|
};
|
|
|
|
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
|
|
|
|
// Render into ratatui-core scratch buffer
|
|
let core_area = CoreRect {
|
|
x: scrollbar_area.x,
|
|
y: scrollbar_area.y,
|
|
width: scrollbar_area.width,
|
|
height: scrollbar_area.height,
|
|
};
|
|
let mut scratch = CoreBuffer::empty(core_area);
|
|
(&scrollbar).render(core_area, &mut scratch);
|
|
|
|
// Copy to ratatui buffer with follow-aware styling
|
|
let (track_style, thumb_style) = scrollbar_styles(is_following);
|
|
for row in 0..scrollbar_area.height {
|
|
let x = scrollbar_area.x;
|
|
let y = scrollbar_area.y + row;
|
|
let src = &scratch[(x, y)];
|
|
let dst = &mut buf[(x, y)];
|
|
if src.symbol() == " " {
|
|
dst.set_symbol(" ");
|
|
dst.set_style(track_style);
|
|
} else {
|
|
dst.set_symbol("\u{2588}");
|
|
dst.set_style(thumb_style);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Get track and thumb styles based on follow mode.
|
|
///
|
|
/// Following mode: very dim colors (scrollbar recedes into background)
|
|
/// Not following: brighter colors (scrollbar "pops out")
|
|
fn scrollbar_styles(is_following: bool) -> (Style, Style) {
|
|
let theme = crate::theme::Theme::current();
|
|
if is_following {
|
|
// Very dim - scrollbar is subtle when following
|
|
let track_style = Style::new().bg(theme.scrollbar_bg);
|
|
let thumb_style = Style::new().fg(theme.scrollbar_fg).bg(theme.scrollbar_bg);
|
|
(track_style, thumb_style)
|
|
} else {
|
|
// Brighter - scrollbar stands out when scrolled up
|
|
let track_style = Style::new().bg(theme.bg_highlight);
|
|
let thumb_style = Style::new().fg(theme.gray).bg(theme.bg_highlight);
|
|
(track_style, thumb_style)
|
|
}
|
|
}
|
|
|
|
/// Render a scrollbar with custom track and thumb styles.
|
|
///
|
|
/// Like [`render_scrollbar`] but allows custom styling for theme integration.
|
|
pub fn render_scrollbar_styled(
|
|
buf: &mut Buffer,
|
|
scrollbar_area: Option<Rect>,
|
|
total_lines: u16,
|
|
viewport_lines: u16,
|
|
offset: u16,
|
|
track_style: Style,
|
|
thumb_style: Style,
|
|
) {
|
|
if SCROLLBARS_HIDDEN.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
|
|
let Some(scrollbar_area) = scrollbar_area else {
|
|
return;
|
|
};
|
|
|
|
if scrollbar_area.width == 0 || scrollbar_area.height == 0 {
|
|
return;
|
|
}
|
|
|
|
if !needs_scrollbar(total_lines, viewport_lines) {
|
|
return;
|
|
}
|
|
|
|
let lengths = ScrollLengths {
|
|
content_len: total_lines as usize,
|
|
viewport_len: viewport_lines as usize,
|
|
};
|
|
|
|
let scrollbar = ScrollBar::vertical(lengths).offset(offset as usize);
|
|
|
|
// Render into ratatui-core scratch buffer
|
|
let core_area = CoreRect {
|
|
x: scrollbar_area.x,
|
|
y: scrollbar_area.y,
|
|
width: scrollbar_area.width,
|
|
height: scrollbar_area.height,
|
|
};
|
|
let mut scratch = CoreBuffer::empty(core_area);
|
|
(&scrollbar).render(core_area, &mut scratch);
|
|
|
|
// Copy to ratatui buffer with custom styling
|
|
for row in 0..scrollbar_area.height {
|
|
let x = scrollbar_area.x;
|
|
let y = scrollbar_area.y + row;
|
|
let src = &scratch[(x, y)];
|
|
let dst = &mut buf[(x, y)];
|
|
if src.symbol() == " " {
|
|
dst.set_symbol(" ");
|
|
dst.set_style(track_style);
|
|
} else {
|
|
dst.set_symbol("\u{2588}");
|
|
dst.set_style(thumb_style);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ratatui::style::Color;
|
|
|
|
#[test]
|
|
fn test_split_area_normal() {
|
|
let area = Rect::new(0, 0, 40, 10);
|
|
let (content, scrollbar) = split_area_for_scrollbar(area);
|
|
|
|
// Content should be 40 - 2 = 38 wide (gap + track)
|
|
assert_eq!(content.width, 38);
|
|
assert_eq!(content.height, 10);
|
|
|
|
// Scrollbar should be at x=39, 1 column wide
|
|
let sb = scrollbar.expect("scrollbar area");
|
|
assert_eq!(sb.x, 39);
|
|
assert_eq!(sb.width, 1);
|
|
assert_eq!(sb.height, 10);
|
|
}
|
|
|
|
#[test]
|
|
fn test_split_area_too_narrow() {
|
|
let area = Rect::new(0, 0, 2, 10);
|
|
let (content, scrollbar) = split_area_for_scrollbar(area);
|
|
|
|
// Too narrow - return original area, no scrollbar
|
|
assert_eq!(content, area);
|
|
assert!(scrollbar.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_maybe_split_reserves_when_needed() {
|
|
let area = Rect::new(0, 0, 40, 10);
|
|
|
|
// Content overflows (20 > 10) - should reserve scrollbar space
|
|
let (content, scrollbar) = maybe_split_for_scrollbar(area, 20);
|
|
assert_eq!(content.width, 38); // Reduced by 2 for gap + scrollbar track
|
|
assert!(scrollbar.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn test_maybe_split_full_width_when_not_needed() {
|
|
let area = Rect::new(0, 0, 40, 10);
|
|
|
|
// Content fits (5 <= 10) - should give full width to content
|
|
let (content, scrollbar) = maybe_split_for_scrollbar(area, 5);
|
|
assert_eq!(content.width, 40); // Full width
|
|
assert!(scrollbar.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_needs_scrollbar() {
|
|
assert!(needs_scrollbar(100, 10)); // Content > viewport
|
|
assert!(!needs_scrollbar(10, 10)); // Content == viewport
|
|
assert!(!needs_scrollbar(5, 10)); // Content < viewport
|
|
}
|
|
|
|
#[test]
|
|
fn test_is_at_bottom() {
|
|
// total=100, viewport=10 -> max_offset=90
|
|
assert!(is_at_bottom(100, 10, 90)); // At bottom
|
|
assert!(is_at_bottom(100, 10, 95)); // Past bottom (clamped)
|
|
assert!(!is_at_bottom(100, 10, 89)); // One line above bottom
|
|
assert!(!is_at_bottom(100, 10, 0)); // At top
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_scrollbar_no_area() {
|
|
let mut buf = Buffer::empty(Rect::new(0, 0, 10, 10));
|
|
// Should not panic with None area
|
|
render_scrollbar(&mut buf, None, 100, 10, 0, false);
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_scrollbar_no_overflow() {
|
|
let area = Rect::new(0, 0, 10, 10);
|
|
let (_, scrollbar_area) = split_area_for_scrollbar(area);
|
|
let mut buf = Buffer::empty(area);
|
|
|
|
// Content fits - should not render anything
|
|
render_scrollbar(&mut buf, scrollbar_area, 5, 10, 0, false);
|
|
|
|
// Check scrollbar column is empty (spaces with no custom background)
|
|
let sb = scrollbar_area.unwrap();
|
|
for y in 0..sb.height {
|
|
let cell = &buf[(sb.x, sb.y + y)];
|
|
assert_eq!(cell.symbol(), " ");
|
|
// The cell should NOT have our scrollbar background colors
|
|
// (i.e., it should be reset/default, not Color::Rgb)
|
|
if let Some(Color::Rgb(_, _, _)) = cell.style().bg {
|
|
panic!("Should not have RGB background when no scrollbar rendered");
|
|
}
|
|
// Otherwise - Reset, None, or other default-like value
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_render_scrollbar_following_vs_not() {
|
|
let area = Rect::new(0, 0, 10, 10);
|
|
let (_, scrollbar_area) = split_area_for_scrollbar(area);
|
|
|
|
// Render following
|
|
let mut buf_following = Buffer::empty(area);
|
|
render_scrollbar(&mut buf_following, scrollbar_area, 100, 10, 90, true);
|
|
|
|
// Render not following
|
|
let mut buf_not_following = Buffer::empty(area);
|
|
render_scrollbar(&mut buf_not_following, scrollbar_area, 100, 10, 50, false);
|
|
|
|
// The styles should differ - not following should be brighter
|
|
let sb = scrollbar_area.unwrap();
|
|
let following_style = buf_following[(sb.x, sb.y)].style();
|
|
let not_following_style = buf_not_following[(sb.x, sb.y)].style();
|
|
|
|
// Both should have backgrounds set (non-default)
|
|
assert!(following_style.bg.is_some());
|
|
assert!(not_following_style.bg.is_some());
|
|
|
|
// At 256-color or truecolor, the backgrounds should be distinguishable.
|
|
// At Basic (16-color) level, both dark grays map to Black — expected.
|
|
if crate::theme::color_support::get().has_256() {
|
|
assert_ne!(following_style.bg, not_following_style.bg);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_scrollbar_thumb_position() {
|
|
let area = Rect::new(0, 0, 10, 10);
|
|
let (_, scrollbar_area) = split_area_for_scrollbar(area);
|
|
let sb = scrollbar_area.unwrap();
|
|
|
|
// At top
|
|
let mut buf_top = Buffer::empty(area);
|
|
render_scrollbar(&mut buf_top, scrollbar_area, 100, 10, 0, false);
|
|
|
|
// At bottom
|
|
let mut buf_bottom = Buffer::empty(area);
|
|
render_scrollbar(&mut buf_bottom, scrollbar_area, 100, 10, 90, false);
|
|
|
|
// Count thumb cells (non-space)
|
|
let count_thumb = |buf: &Buffer| -> usize {
|
|
(0..sb.height)
|
|
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
|
|
.count()
|
|
};
|
|
|
|
// Both should have a thumb
|
|
let top_thumb = count_thumb(&buf_top);
|
|
let bottom_thumb = count_thumb(&buf_bottom);
|
|
assert!(top_thumb > 0, "Should have thumb at top");
|
|
assert!(bottom_thumb > 0, "Should have thumb at bottom");
|
|
|
|
// Thumb size should be consistent
|
|
assert_eq!(top_thumb, bottom_thumb, "Thumb size should be consistent");
|
|
|
|
// Thumb position should differ (visual inspection would show top vs bottom)
|
|
// We can check that the thumb cells are in different positions
|
|
let thumb_positions = |buf: &Buffer| -> Vec<u16> {
|
|
(0..sb.height)
|
|
.filter(|&y| buf[(sb.x, sb.y + y)].symbol() != " ")
|
|
.collect()
|
|
};
|
|
|
|
let top_pos = thumb_positions(&buf_top);
|
|
let bottom_pos = thumb_positions(&buf_bottom);
|
|
assert_ne!(
|
|
top_pos, bottom_pos,
|
|
"Thumb should be at different positions"
|
|
);
|
|
}
|
|
}
|