use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use crate::keybindings::{EffectiveBindings, InputContext, KeyBindings, TuiAction};
use crate::views::divergence::ViewDivergence;
use crate::views::plan::ViewPlan;
use crate::views::registry::{ScopeResolution, ViewDiagnostic, ViewId, ViewRefusal, ViewScope};
use super::modal::modal_rect;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ViewFeedback {
pub indicator: String,
pub lines: Vec<String>,
}
impl ViewFeedback {
#[must_use]
pub fn new(indicator: impl Into<String>, lines: Vec<String>) -> Self {
Self {
indicator: indicator.into(),
lines,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ViewRow {
pub id: ViewId,
pub name: String,
pub scope: String,
pub active: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ViewsPrompt {
Create(String),
Rename { id: ViewId, buffer: String },
ConfirmDelete { id: ViewId, name: String },
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ViewsModal {
pub rows: Vec<ViewRow>,
pub selected: usize,
pub prompt: Option<ViewsPrompt>,
pub feedback: Option<ViewFeedback>,
pub note: Option<String>,
}
impl ViewsModal {
#[must_use]
pub fn new(rows: Vec<ViewRow>) -> Self {
let selected = rows.iter().position(|row| row.active).unwrap_or(0);
Self {
rows,
selected,
prompt: None,
feedback: None,
note: None,
}
}
#[must_use]
pub fn selected_row(&self) -> Option<&ViewRow> {
self.rows.get(self.selected)
}
pub fn move_selected(&mut self, delta: isize) {
if self.rows.is_empty() {
self.selected = 0;
return;
}
let last = self.rows.len() - 1;
let next = if delta < 0 {
self.selected.saturating_sub(delta.unsigned_abs())
} else {
self.selected.saturating_add(delta as usize)
};
self.selected = next.min(last);
}
pub fn move_selected_to(&mut self, row: usize) {
if self.rows.is_empty() {
self.selected = 0;
return;
}
self.selected = row.min(self.rows.len() - 1);
}
#[must_use]
pub const fn is_prompting(&self) -> bool {
self.prompt.is_some()
}
pub fn clear_prompt(&mut self) {
self.prompt = None;
}
fn window_start(&self, inner_h: usize) -> usize {
let row_budget = inner_h.min(self.rows.len());
if self.rows.len() > row_budget {
(self.selected + 1).saturating_sub(row_budget)
} else {
0
}
}
#[must_use]
pub fn row_hit_regions(&self, area: Rect) -> Vec<(Rect, usize)> {
let rect = modal_rect(area);
let inner_h = rect.height.saturating_sub(2) as usize;
let start = self.window_start(inner_h);
let visible = inner_h.min(self.rows.len());
(0..visible)
.map(|offset| {
(
Rect {
x: rect.x + 1,
y: rect.y + 1 + offset as u16,
width: rect.width.saturating_sub(2),
height: 1,
},
start + offset,
)
})
.collect()
}
pub fn render(&self, f: &mut Frame, area: Rect) {
let bindings =
KeyBindings::resolve(&KeyBindings::default()).expect("built-in bindings are valid");
self.render_with_bindings(f, area, &bindings, InputContext::Views);
}
pub fn render_with_bindings(
&self,
f: &mut Frame,
area: Rect,
bindings: &EffectiveBindings,
context: InputContext,
) {
let rect = modal_rect(area);
let inner_h = rect.height.saturating_sub(2) as usize;
let mut lines: Vec<Line<'static>> = Vec::new();
let row_budget = inner_h.min(self.rows.len());
let start = self.window_start(inner_h);
for (offset, row) in self.rows[start..].iter().take(row_budget).enumerate() {
let is_cursor = start + offset == self.selected;
let marker = if row.active { "*" } else { " " };
let cursor = if is_cursor { "> " } else { " " };
let style = if is_cursor {
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::White)
};
lines.push(Line::from(vec![
Span::styled(cursor, style),
Span::styled(
format!("{marker} {}", row.name),
if row.active {
style.fg(Color::Cyan).add_modifier(Modifier::BOLD)
} else {
style
},
),
Span::styled(
format!(" [{}]", row.scope),
Style::default().fg(Color::DarkGray),
),
]));
}
if self.rows.is_empty() {
lines.push(Line::from(Span::styled(
" (no saved views)",
Style::default().fg(Color::DarkGray),
)));
}
let mut budget = inner_h.saturating_sub(lines.len());
let mut push = |line: Line<'static>, budget: &mut usize| {
if *budget > 0 {
lines.push(line);
*budget -= 1;
}
};
if let Some(note) = &self.note {
push(
Line::from(Span::styled(
note.clone(),
Style::default().fg(Color::Yellow),
)),
&mut budget,
);
}
match &self.prompt {
Some(ViewsPrompt::Create(buffer)) => push(
Line::from(Span::styled(
format!("New view name: {buffer}_"),
Style::default().fg(Color::Cyan),
)),
&mut budget,
),
Some(ViewsPrompt::Rename { buffer, .. }) => push(
Line::from(Span::styled(
format!("Rename to: {buffer}_"),
Style::default().fg(Color::Cyan),
)),
&mut budget,
),
Some(ViewsPrompt::ConfirmDelete { name, .. }) => push(
Line::from(Span::styled(
format!("Delete view '{name}' (definition only)? [y/N]"),
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
)),
&mut budget,
),
None => {
if let Some(feedback) = &self.feedback {
push(
Line::from(Span::styled(
feedback.indicator.clone(),
Style::default().fg(Color::Green),
)),
&mut budget,
);
for line in &feedback.lines {
push(
Line::from(Span::styled(
line.clone(),
Style::default().fg(Color::DarkGray),
)),
&mut budget,
);
}
}
}
}
if budget > 0 {
let footer = match &self.prompt {
Some(ViewsPrompt::ConfirmDelete { .. }) => format!(
"{}: delete · {}: cancel",
bindings.label(InputContext::ConfirmDelete, TuiAction::Confirm),
bindings.label(InputContext::ConfirmDelete, TuiAction::Cancel),
),
Some(_) => format!(
"{}: save · {}: cancel",
bindings.label(context, TuiAction::Submit),
bindings.label(context, TuiAction::Cancel),
),
None => format!(
"{}: apply · {}: new · {}: rename · {}: update from live · \
{}: duplicate · {}: delete · {}: close",
bindings.label(context, TuiAction::ApplyView),
bindings.label(context, TuiAction::NewView),
bindings.label(context, TuiAction::RenameView),
bindings.label(context, TuiAction::UpdateView),
bindings.label(context, TuiAction::DuplicateView),
bindings.label(context, TuiAction::DeleteView),
bindings.label(context, TuiAction::Close),
),
};
lines.push(Line::from(Span::styled(
footer,
Style::default().fg(Color::DarkGray),
)));
}
let block = Block::default()
.borders(Borders::ALL)
.title(format!(" Saved views ({}) ", self.rows.len()))
.style(Style::default().fg(Color::Cyan));
let para = Paragraph::new(lines).block(block);
f.render_widget(Clear, rect);
f.render_widget(para, rect);
}
}
#[must_use]
pub fn diagnostic_line(diagnostic: &ViewDiagnostic) -> String {
ViewRefusal::from_diagnostic(diagnostic.clone()).render()
}
#[must_use]
pub fn scope_label(scope: &ViewScope) -> String {
match scope.kind {
crate::views::registry::ViewScopeKind::Repository => "repository".to_string(),
crate::views::registry::ViewScopeKind::Project => {
format!("project {}", scope.project_id.as_deref().unwrap_or(""))
}
crate::views::registry::ViewScopeKind::UserShare => {
let mut parts = vec!["user".to_string()];
if let Some(owner) = scope.owner.as_deref() {
parts.push(format!("owner={owner}"));
}
if !scope.shared_with.is_empty() {
parts.push(format!("shared_with={}", scope.shared_with.join(",")));
}
parts.join(" ")
}
}
}
#[must_use]
pub fn scope_summary(scope: &ViewScope, resolution: ScopeResolution) -> String {
format!("{} ({resolution})", scope_label(scope))
}
#[must_use]
pub fn plan_summary(plan: &ViewPlan) -> String {
let mut parts = vec![format!("filter: {}", plan.filter())];
if let Some(query) = plan.text_query() {
parts.push(format!("query: {query}"));
}
if let Some(sort) = plan.sort() {
parts.push(format!("sort: {sort}"));
}
if let Some(group) = plan.group() {
parts.push(format!("group: {group}"));
}
if let Some(visibility) = plan.visibility() {
parts.push(format!(
"hide-columns: {}",
visibility.hidden_columns().join(",")
));
parts.push(format!(
"show-mismatch: {}",
visibility
.show_mismatch()
.map_or_else(|| "inherit".to_string(), |value| value.to_string())
));
}
parts.join(" · ")
}
#[must_use]
pub fn difference_lines(divergence: &ViewDivergence) -> Vec<String> {
divergence
.differences()
.iter()
.map(|difference| {
format!(
"{}: saved={} live={}",
difference.field(),
difference.saved().unwrap_or("(unset)"),
difference.live().unwrap_or("(unset)"),
)
})
.collect()
}
#[must_use]
pub fn indicator_line(name: &str, divergence: &ViewDivergence) -> String {
if !divergence.in_sync() {
let fields: Vec<&str> = divergence
.differences()
.iter()
.map(|difference| difference.field())
.collect();
let mut summary = if fields.is_empty() {
"diverged".to_string()
} else {
format!("diverged ({})", fields.join(", "))
};
if !divergence.diagnostics().is_empty() {
summary.push_str(" +diagnostics");
}
return format!("view {name} · {summary}");
}
format!("view {name} · in sync")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::search::StructuredFilter;
use crate::views::registry::ViewDiagnosticKind;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
fn row(id: &str, name: &str, active: bool) -> ViewRow {
ViewRow {
id: ViewId::new(id).unwrap(),
name: name.to_string(),
scope: "repository (resolved)".to_string(),
active,
}
}
#[test]
fn the_cursor_opens_on_the_active_view() {
let modal = ViewsModal::new(vec![row("v-1", "One", false), row("v-2", "Two", true)]);
assert_eq!(modal.selected, 1);
assert_eq!(modal.selected_row().unwrap().name, "Two");
}
#[test]
fn the_cursor_clamps_at_both_ends() {
let mut modal = ViewsModal::new(vec![row("v-1", "One", false), row("v-2", "Two", false)]);
modal.move_selected(-1);
assert_eq!(modal.selected, 0);
modal.move_selected(5);
assert_eq!(modal.selected, 1);
modal.move_selected_to(99);
assert_eq!(modal.selected, 1);
modal.move_selected_to(0);
assert_eq!(modal.selected, 0);
}
#[test]
fn an_empty_registry_renders_and_keeps_the_cursor_at_zero() {
let mut modal = ViewsModal::new(Vec::new());
modal.move_selected(3);
assert_eq!(modal.selected, 0);
assert!(modal.selected_row().is_none());
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
}
#[test]
fn scope_labels_name_the_family_and_its_metadata() {
assert_eq!(scope_label(&ViewScope::repository()), "repository");
assert_eq!(
scope_label(&ViewScope::project("p-platform")),
"project p-platform"
);
assert_eq!(
scope_label(&ViewScope::user_share(
Some("alice".to_string()),
vec!["bob".to_string()]
)),
"user owner=alice shared_with=bob"
);
assert_eq!(
scope_summary(&ViewScope::repository(), ScopeResolution::Resolved),
"repository (resolved)"
);
}
#[test]
fn a_diagnostic_line_is_the_shared_refusal_spelling() {
let diagnostic = ViewDiagnostic::new(ViewDiagnosticKind::UnknownMetadata, "kept")
.with_field("extensions.colour");
assert_eq!(
diagnostic_line(&diagnostic),
"error[E-tix-views]: unknown_metadata: kept"
);
}
#[test]
fn an_in_sync_indicator_names_the_view_only() {
let plan = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
let divergence = ViewDivergence::between(&plan, &plan);
assert_eq!(
indicator_line("Active", &divergence),
"view Active · in sync"
);
assert_eq!(
difference_lines(&divergence),
[] as [std::string::String; 0]
);
}
#[test]
fn a_diverged_indicator_names_the_divergent_fields() {
let saved = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
let live = ViewPlan::from_parts(StructuredFilter::default(), "sort:created");
let divergence = ViewDivergence::between(&saved, &live);
assert_eq!(
indicator_line("Active", &divergence),
"view Active · diverged (plan.sort)"
);
assert_eq!(
difference_lines(&divergence),
vec!["plan.sort: saved=due live=created".to_string()]
);
}
#[test]
fn a_missing_marker_indicator_says_so_rather_than_in_sync() {
let live = ViewPlan::from_parts(StructuredFilter::default(), "sort:due");
let divergence = ViewDivergence::missing_marker(&live);
assert!(!divergence.in_sync());
assert_eq!(
indicator_line("Active", &divergence),
"view Active · diverged +diagnostics"
);
}
#[test]
fn a_plan_summary_lists_the_set_fields_in_the_cli_vocabulary() {
let plan = ViewPlan::from_parts(StructuredFilter::default(), "sort:due group:cycle");
assert_eq!(
plan_summary(&plan),
"filter: (no active criteria) · sort: due · group: cycle"
);
}
#[test]
fn render_smoke_at_several_sizes() {
for (w, h) in [(120, 40), (80, 24), (20, 5)] {
let mut modal =
ViewsModal::new(vec![row("v-1", "One", true), row("v-2", "Two", false)]);
modal.feedback = Some(ViewFeedback::new(
"view One · in sync",
vec!["scope: repository (resolved)".to_string()],
));
modal.note = Some("Saved v-1".to_string());
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| modal.render(f, f.area())).unwrap();
}
}
}