Add Ratatui terminal application
This commit is contained in:
17
crates/tui/Cargo.toml
Normal file
17
crates/tui/Cargo.toml
Normal file
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "gotcha-tui"
|
||||
version = "1.0.0"
|
||||
description = "Ratatui terminal client for Gitea and Forgejo"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "gotcha-tui"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
crossterm = "0.29"
|
||||
gotcha_gitea = { path = "../gitea" }
|
||||
ratatui = { version = "0.30", default-features = false, features = ["crossterm_0_29", "layout-cache", "macros", "underline-color"] }
|
||||
tokio.workspace = true
|
||||
1861
crates/tui/src/app.rs
Normal file
1861
crates/tui/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
326
crates/tui/src/editor.rs
Normal file
326
crates/tui/src/editor.rs
Normal file
@@ -0,0 +1,326 @@
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
use gotcha_gitea::{Provider, RepositoryId};
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum EditorAction {
|
||||
Issue {
|
||||
repository: RepositoryId,
|
||||
number: Option<i64>,
|
||||
},
|
||||
Comment {
|
||||
repository: RepositoryId,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
},
|
||||
Milestone {
|
||||
repository: RepositoryId,
|
||||
id: Option<i64>,
|
||||
},
|
||||
Server {
|
||||
original_name: Option<String>,
|
||||
},
|
||||
Settings,
|
||||
IssueFilter,
|
||||
PullFilter,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct Field {
|
||||
pub label: String,
|
||||
pub value: String,
|
||||
pub secret: bool,
|
||||
pub multiline: bool,
|
||||
}
|
||||
|
||||
impl Field {
|
||||
pub fn new(label: &str, value: impl Into<String>) -> Self {
|
||||
Self {
|
||||
label: label.into(),
|
||||
value: value.into(),
|
||||
secret: false,
|
||||
multiline: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn secret(mut self) -> Self {
|
||||
self.secret = true;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn multiline(mut self) -> Self {
|
||||
self.multiline = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Editor {
|
||||
pub title: String,
|
||||
pub fields: Vec<Field>,
|
||||
pub focus: usize,
|
||||
pub cursor: usize,
|
||||
pub action: EditorAction,
|
||||
}
|
||||
|
||||
impl Editor {
|
||||
pub fn issue(repository: RepositoryId, number: Option<i64>, fields: [String; 6]) -> Self {
|
||||
let [title, body, labels, milestone, due_date, state] = fields;
|
||||
Self::new(
|
||||
if number.is_some() {
|
||||
"Edit issue"
|
||||
} else {
|
||||
"New issue"
|
||||
},
|
||||
EditorAction::Issue { repository, number },
|
||||
vec![
|
||||
Field::new("Title", title),
|
||||
Field::new("Body", body).multiline(),
|
||||
Field::new("Labels (comma separated)", labels),
|
||||
Field::new("Milestone", milestone),
|
||||
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||
Field::new("State (open/closed)", state),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn comment(
|
||||
repository: RepositoryId,
|
||||
number: i64,
|
||||
comment_id: Option<i64>,
|
||||
body: String,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
if comment_id.is_some() {
|
||||
"Edit comment"
|
||||
} else {
|
||||
"New comment"
|
||||
},
|
||||
EditorAction::Comment {
|
||||
repository,
|
||||
number,
|
||||
comment_id,
|
||||
},
|
||||
vec![Field::new("Comment", body).multiline()],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self {
|
||||
Self::new(
|
||||
"Issue filters",
|
||||
EditorAction::IssueFilter,
|
||||
vec![
|
||||
Field::new("State (open/closed/all)", state),
|
||||
Field::new("Labels (comma separated)", labels),
|
||||
Field::new("Milestone", milestone),
|
||||
Field::new("Search", search),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self {
|
||||
Self::new(
|
||||
"Pull request filters",
|
||||
EditorAction::PullFilter,
|
||||
vec![
|
||||
Field::new("State (open/closed)", state),
|
||||
Field::new("Milestone", milestone),
|
||||
Field::new("Search", search),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn milestone(
|
||||
repository: RepositoryId,
|
||||
id: Option<i64>,
|
||||
title: String,
|
||||
description: String,
|
||||
due_date: String,
|
||||
state: String,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
if id.is_some() {
|
||||
"Edit milestone"
|
||||
} else {
|
||||
"New milestone"
|
||||
},
|
||||
EditorAction::Milestone { repository, id },
|
||||
vec![
|
||||
Field::new("Title", title),
|
||||
Field::new("Description", description).multiline(),
|
||||
Field::new("Due date (YYYY-MM-DD)", due_date),
|
||||
Field::new("State (open/closed)", state),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn server(
|
||||
original_name: Option<String>,
|
||||
name: String,
|
||||
url: String,
|
||||
provider: Provider,
|
||||
) -> Self {
|
||||
Self::new(
|
||||
if original_name.is_some() {
|
||||
"Edit server"
|
||||
} else {
|
||||
"Add server"
|
||||
},
|
||||
EditorAction::Server { original_name },
|
||||
vec![
|
||||
Field::new("Profile name (host[:port])", name),
|
||||
Field::new("Server URL", url),
|
||||
Field::new("Token (blank keeps existing)", "").secret(),
|
||||
Field::new("Provider (gitea/forgejo)", provider.to_string()),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn settings(refresh_seconds: u64) -> Self {
|
||||
Self::new(
|
||||
"TUI preferences",
|
||||
EditorAction::Settings,
|
||||
vec![Field::new(
|
||||
"Auto-reload seconds (0 disables)",
|
||||
refresh_seconds.to_string(),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn new(title: &str, action: EditorAction, fields: Vec<Field>) -> Self {
|
||||
let cursor = fields
|
||||
.first()
|
||||
.map_or(0, |field| field.value.chars().count());
|
||||
Self {
|
||||
title: title.into(),
|
||||
fields,
|
||||
focus: 0,
|
||||
cursor,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent {
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') {
|
||||
return EditorEvent::Submit;
|
||||
}
|
||||
match key.code {
|
||||
KeyCode::Esc => EditorEvent::Cancel,
|
||||
KeyCode::Tab | KeyCode::Down => {
|
||||
self.move_focus(1);
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::BackTab | KeyCode::Up => {
|
||||
self.move_focus(self.fields.len().saturating_sub(1));
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Left => {
|
||||
self.cursor = self.cursor.saturating_sub(1);
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Right => {
|
||||
self.cursor = (self.cursor + 1).min(self.current_len());
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Home => {
|
||||
self.cursor = 0;
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::End => {
|
||||
self.cursor = self.current_len();
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.cursor > 0 {
|
||||
let end = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||
let start = byte_index(&self.fields[self.focus].value, self.cursor - 1);
|
||||
self.fields[self.focus].value.replace_range(start..end, "");
|
||||
self.cursor -= 1;
|
||||
}
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if self.cursor < self.current_len() {
|
||||
let start = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||
let end = byte_index(&self.fields[self.focus].value, self.cursor + 1);
|
||||
self.fields[self.focus].value.replace_range(start..end, "");
|
||||
}
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Enter if self.fields[self.focus].multiline => {
|
||||
self.insert('\n');
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.move_focus(1);
|
||||
EditorEvent::Changed
|
||||
}
|
||||
KeyCode::Char(character)
|
||||
if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT =>
|
||||
{
|
||||
self.insert(character);
|
||||
EditorEvent::Changed
|
||||
}
|
||||
_ => EditorEvent::Ignored,
|
||||
}
|
||||
}
|
||||
|
||||
fn insert(&mut self, character: char) {
|
||||
let index = byte_index(&self.fields[self.focus].value, self.cursor);
|
||||
self.fields[self.focus].value.insert(index, character);
|
||||
self.cursor += 1;
|
||||
}
|
||||
|
||||
fn move_focus(&mut self, amount: usize) {
|
||||
self.focus = (self.focus + amount) % self.fields.len();
|
||||
self.cursor = self.current_len();
|
||||
}
|
||||
|
||||
fn current_len(&self) -> usize {
|
||||
self.fields[self.focus].value.chars().count()
|
||||
}
|
||||
|
||||
pub fn values(&self) -> Vec<String> {
|
||||
self.fields
|
||||
.iter()
|
||||
.map(|field| field.value.clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum EditorEvent {
|
||||
Changed,
|
||||
Submit,
|
||||
Cancel,
|
||||
Ignored,
|
||||
}
|
||||
|
||||
fn byte_index(value: &str, character: usize) -> usize {
|
||||
value
|
||||
.char_indices()
|
||||
.nth(character)
|
||||
.map_or(value.len(), |(index, _)| index)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn editor_backspace_edits_instead_of_navigating() {
|
||||
let mut editor = Editor::settings(5);
|
||||
editor.fields[0].value = "é5".into();
|
||||
editor.cursor = 1;
|
||||
assert_eq!(
|
||||
editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)),
|
||||
EditorEvent::Changed
|
||||
);
|
||||
assert_eq!(editor.fields[0].value, "5");
|
||||
assert_eq!(editor.cursor, 0);
|
||||
assert_eq!(
|
||||
editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
|
||||
EditorEvent::Changed
|
||||
);
|
||||
}
|
||||
}
|
||||
81
crates/tui/src/main.rs
Normal file
81
crates/tui/src/main.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
mod app;
|
||||
mod editor;
|
||||
mod ui;
|
||||
|
||||
use std::{env, error::Error, io, time::Duration};
|
||||
|
||||
use crossterm::{
|
||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyEventKind},
|
||||
execute,
|
||||
};
|
||||
use gotcha_gitea::Config;
|
||||
|
||||
const HELP: &str = "\
|
||||
Gotcha TUI
|
||||
|
||||
Usage: gotcha-tui [--server SERVER]
|
||||
|
||||
Keyboard:
|
||||
1..5 switch Home, Issues, Repos, PRs, Milestones
|
||||
j/k, arrows move selection
|
||||
n/p or ]/[ next/previous page
|
||||
Enter open selected item
|
||||
Backspace go back (edits text inside an editor)
|
||||
a/e/c/x/d add, edit, comment, toggle state, delete
|
||||
/, v, b, * filters, activity filter, branch, repository favorite
|
||||
f browse files from a repository commit view
|
||||
r reload
|
||||
s choose or manage servers
|
||||
, edit TUI preferences
|
||||
q back, or quit from a root pane
|
||||
|
||||
Editors use Tab/Shift-Tab between fields, normal cursor/editing keys, Ctrl-S to
|
||||
save, and Esc to cancel. Mouse clicks select items, double-click opens them,
|
||||
and the scroll wheel moves through lists.";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn Error>> {
|
||||
let requested_server = parse_args()?;
|
||||
let config = Config::load()?;
|
||||
let mut app = app::App::new(config, requested_server.as_deref()).await?;
|
||||
|
||||
let mut terminal = ratatui::init();
|
||||
execute!(io::stdout(), EnableMouseCapture)?;
|
||||
let result = run(&mut terminal, &mut app).await;
|
||||
execute!(io::stdout(), DisableMouseCapture)?;
|
||||
ratatui::restore();
|
||||
result
|
||||
}
|
||||
|
||||
async fn run(
|
||||
terminal: &mut ratatui::DefaultTerminal,
|
||||
app: &mut app::App,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
while !app.should_quit {
|
||||
terminal.draw(|frame| ui::draw(frame, app))?;
|
||||
if event::poll(Duration::from_millis(100))? {
|
||||
match event::read()? {
|
||||
Event::Key(key) if key.kind == KeyEventKind::Press => app.handle_key(key).await,
|
||||
Event::Mouse(mouse) => app.handle_mouse(mouse).await,
|
||||
Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
} else if app.should_auto_reload() {
|
||||
app.reload().await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_args() -> Result<Option<String>, String> {
|
||||
let args: Vec<_> = env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[] => Ok(None),
|
||||
[help] if matches!(help.as_str(), "-h" | "--help" | "help") => {
|
||||
println!("{HELP}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
[option, server] if option == "--server" => Ok(Some(server.clone())),
|
||||
_ => Err(format!("invalid arguments\n\n{HELP}")),
|
||||
}
|
||||
}
|
||||
271
crates/tui/src/ui.rs
Normal file
271
crates/tui/src/ui.rs
Normal file
@@ -0,0 +1,271 @@
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Alignment, Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap},
|
||||
};
|
||||
|
||||
use crate::{
|
||||
app::{App, Tab},
|
||||
editor::Editor,
|
||||
};
|
||||
|
||||
const ACCENT: Color = Color::Cyan;
|
||||
|
||||
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
|
||||
let area = frame.area();
|
||||
let rows = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(4),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
draw_tabs(frame, app, rows[0]);
|
||||
draw_content(frame, app, rows[1]);
|
||||
draw_footer(frame, app, rows[2]);
|
||||
|
||||
if let Some(editor) = &app.editor {
|
||||
draw_editor(frame, editor);
|
||||
} else if let Some((message, _)) = &app.confirm {
|
||||
draw_confirm(frame, message);
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_tabs(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let titles = Tab::ALL.map(|tab| Line::from(tab.title()));
|
||||
let selected = Tab::ALL.iter().position(|tab| *tab == app.tab).unwrap_or(0);
|
||||
let tabs = Tabs::new(titles)
|
||||
.select(selected)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" Gotcha · {} ", app.server_name)),
|
||||
)
|
||||
.highlight_style(Style::default().fg(ACCENT).add_modifier(Modifier::BOLD))
|
||||
.divider("│");
|
||||
frame.render_widget(tabs, area);
|
||||
|
||||
let inner = area.inner(ratatui::layout::Margin {
|
||||
horizontal: 1,
|
||||
vertical: 1,
|
||||
});
|
||||
app.tab_areas = Layout::horizontal([Constraint::Ratio(1, 5); 5])
|
||||
.split(inner)
|
||||
.to_vec();
|
||||
}
|
||||
|
||||
fn draw_content(frame: &mut Frame<'_>, app: &mut App, area: Rect) {
|
||||
let direction = content_direction(area.width);
|
||||
let panes = Layout::default()
|
||||
.direction(direction)
|
||||
.constraints(if direction == Direction::Horizontal {
|
||||
[Constraint::Percentage(42), Constraint::Percentage(58)]
|
||||
} else {
|
||||
[Constraint::Percentage(55), Constraint::Percentage(45)]
|
||||
})
|
||||
.split(area);
|
||||
app.list_area = panes[0];
|
||||
draw_list(frame, app, panes[0]);
|
||||
draw_detail(frame, app, panes[1]);
|
||||
}
|
||||
|
||||
fn content_direction(width: u16) -> Direction {
|
||||
if width >= 90 {
|
||||
Direction::Horizontal
|
||||
} else {
|
||||
Direction::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_list(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let items = if app.screen.items.is_empty() {
|
||||
vec![ListItem::new(Line::styled(
|
||||
"No items",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
))]
|
||||
} else {
|
||||
app.screen
|
||||
.items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
ListItem::new(vec![
|
||||
Line::from(Span::styled(
|
||||
item.title.clone(),
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(
|
||||
item.meta.clone(),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
])
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let list = List::new(items)
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", app.screen.title)),
|
||||
)
|
||||
.highlight_symbol("▸ ")
|
||||
.highlight_style(Style::default().fg(ACCENT));
|
||||
let mut state = ListState::default()
|
||||
.with_selected((!app.screen.items.is_empty()).then_some(app.screen.selected));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
fn draw_detail(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let mut detail = app.screen.detail.clone();
|
||||
if let Some(item) = app.screen.selected_item()
|
||||
&& !item.detail.is_empty()
|
||||
{
|
||||
if !detail.is_empty() {
|
||||
detail.push_str("\n\n────\n\n");
|
||||
}
|
||||
detail.push_str(&item.detail);
|
||||
}
|
||||
if detail.is_empty() {
|
||||
detail = "Select an item to see details.".into();
|
||||
}
|
||||
let paragraph = Paragraph::new(Text::from(detail))
|
||||
.block(Block::default().borders(Borders::ALL).title(" Preview "))
|
||||
.wrap(Wrap { trim: false })
|
||||
.scroll((app.screen.detail_scroll, 0));
|
||||
frame.render_widget(paragraph, area);
|
||||
}
|
||||
|
||||
fn draw_footer(frame: &mut Frame<'_>, app: &App, area: Rect) {
|
||||
let status = if app.status.is_empty() {
|
||||
format!(
|
||||
"Page {} · j/k move · Enter open · Backspace back · n/p pages · a/e/c/x/d actions · / filters · * favorite · r reload · q quit",
|
||||
app.screen.page
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} · Page {} · j/k move · Enter open · Backspace back · n/p pages · q quit",
|
||||
app.status, app.screen.page
|
||||
)
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(status)
|
||||
.style(Style::default().fg(Color::DarkGray))
|
||||
.alignment(Alignment::Center),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn draw_editor(frame: &mut Frame<'_>, editor: &Editor) {
|
||||
let area = centered(frame.area(), 86, 86);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(
|
||||
" {} · Tab fields · Ctrl-S save · Esc cancel ",
|
||||
editor.title
|
||||
))
|
||||
.border_style(Style::default().fg(ACCENT)),
|
||||
area,
|
||||
);
|
||||
let inner = area.inner(ratatui::layout::Margin {
|
||||
horizontal: 2,
|
||||
vertical: 1,
|
||||
});
|
||||
let heights = editor
|
||||
.fields
|
||||
.iter()
|
||||
.map(|field| Constraint::Length(if field.multiline { 5 } else { 3 }));
|
||||
let fields = Layout::vertical(heights).split(inner);
|
||||
for (index, (field, field_area)) in editor.fields.iter().zip(fields.iter()).enumerate() {
|
||||
let value = if field.secret {
|
||||
"•".repeat(field.value.chars().count())
|
||||
} else {
|
||||
field.value.clone()
|
||||
};
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", field.label))
|
||||
.border_style(if index == editor.focus {
|
||||
Style::default().fg(ACCENT)
|
||||
} else {
|
||||
Style::default()
|
||||
});
|
||||
frame.render_widget(
|
||||
Paragraph::new(value)
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false }),
|
||||
*field_area,
|
||||
);
|
||||
}
|
||||
if let Some(field_area) = fields.get(editor.focus) {
|
||||
let prefix: String = editor.fields[editor.focus]
|
||||
.value
|
||||
.chars()
|
||||
.take(editor.cursor)
|
||||
.collect();
|
||||
let lines: Vec<_> = prefix.split('\n').collect();
|
||||
let x = field_area.x
|
||||
+ 1
|
||||
+ lines
|
||||
.last()
|
||||
.map_or(0, |line| line.chars().count())
|
||||
.min(field_area.width.saturating_sub(3) as usize) as u16;
|
||||
let y = field_area.y
|
||||
+ 1
|
||||
+ (lines.len().saturating_sub(1)).min(field_area.height.saturating_sub(3) as usize)
|
||||
as u16;
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_confirm(frame: &mut Frame<'_>, message: &str) {
|
||||
let area = centered(frame.area(), 64, 24);
|
||||
frame.render_widget(Clear, area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(format!(
|
||||
"{message}\n\nPress y or Enter to confirm; n or Esc to cancel."
|
||||
))
|
||||
.block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Confirm destructive action ")
|
||||
.border_style(Style::default().fg(Color::Red)),
|
||||
)
|
||||
.alignment(Alignment::Center)
|
||||
.wrap(Wrap { trim: true }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn centered(area: Rect, width_percent: u16, height_percent: u16) -> Rect {
|
||||
let vertical = Layout::vertical([
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
Constraint::Percentage(height_percent),
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
])
|
||||
.split(area);
|
||||
Layout::horizontal([
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
Constraint::Percentage(width_percent),
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn responsive_popup_stays_inside_small_terminals() {
|
||||
let area = centered(Rect::new(0, 0, 40, 20), 86, 86);
|
||||
assert!(area.width <= 40);
|
||||
assert!(area.height <= 20);
|
||||
assert!(area.width > 0);
|
||||
assert!(area.height > 0);
|
||||
assert_eq!(content_direction(80), Direction::Vertical);
|
||||
assert_eq!(content_direction(120), Direction::Horizontal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user