Add Ratatui terminal application

This commit is contained in:
Georg Bauer
2026-08-03 21:15:16 +02:00
parent 976742e173
commit 2534010d1d
13 changed files with 4414 additions and 375 deletions

1286
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/gitea", "crates/cli", "crates/app"] members = ["crates/gitea", "crates/cli", "crates/app", "crates/tui"]
resolver = "3" resolver = "3"
[workspace.package] [workspace.package]

View File

@@ -1,8 +1,8 @@
# Gotcha # Gotcha
Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a Gotcha is a lightweight Gitea and Forgejo client with a reusable Rust core, a
CLI, and a native iOS application. The iOS interface is UIKit/Swift; UniFFI CLI, a Ratatui terminal interface, and a native iOS application. The iOS
exposes the Rust application logic to Swift. interface is UIKit/Swift; UniFFI exposes the Rust application logic to Swift.
On the icon: git in a tea cup. it is obvious, isn't it? On the icon: git in a tea cup. it is obvious, isn't it?
@@ -20,6 +20,7 @@ not necessarily the app to use when you manage or frequent a large instance.
- `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the - `gotcha_gitea`: reusable asynchronous Gitea/Forgejo API client with the
complete typed Gitea 1.25-compatible API and model surface complete typed Gitea 1.25-compatible API and model surface
- `gotcha`: CLI for typed common operations and arbitrary API requests - `gotcha`: CLI for typed common operations and arbitrary API requests
- `gotcha-tui`: paned, keyboard-and-mouse terminal application
- `gotcha-app`: Rust application core and UniFFI API used by iOS - `gotcha-app`: Rust application core and UniFFI API used by iOS
- `ios`: native UIKit application for activity, repositories, favorites, - `ios`: native UIKit application for activity, repositories, favorites,
issues, milestones, pull requests, commit history, changed files, and diffs; bundle issues, milestones, pull requests, commit history, changed files, and diffs; bundle
@@ -88,12 +89,35 @@ derives the `owner/repository` scope. Use `--server gitea.example.com` when sele
ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never ambiguous, or `--url`/`GITEA_URL` for an unconfigured server. Tokens are never
accepted as command-line arguments or environment variables. accepted as command-line arguments or environment variables.
## Terminal UI
Build and run the standalone TUI with an existing CLI server profile:
```sh
cargo run -p gotcha-tui
cargo run -p gotcha-tui -- --server gitea.example.com
```
The five numbered panes mirror the iPhone app: Home, Issues, Repositories,
pull requests, and Milestones. Use `j`/`k` or the arrow keys to select rows,
Enter to open, Backspace to return, `/` for list filters, and `a`, `e`, `c`,
`x`, and `d` for mutations. Use `n`/`p` for API result pages; scrolling the
mouse wheel past a page boundary does the same. Repository lists support `*` favorites, commit
lists support `b` branch switching, and Home supports `v` activity filters.
Editors use Tab between fields and Ctrl-S to save. Mouse selection,
double-click, and wheel scrolling work in ordinary terminals and Herdr.
Overview panes refresh every five seconds by default. Press `,` to change the
interval or set it to zero. Refresh waits while an editor or confirmation is
open and after keyboard or mouse activity so it does not move the current
selection during interaction.
## Architecture ## Architecture
`gotcha_gitea` owns Gitea/Forgejo access, authentication, validation, `gotcha_gitea` owns Gitea/Forgejo access, authentication, validation,
mutations, and relationships between server objects. The CLI is a terminal presentation layer: mutations, relationships between server objects, and shared CLI/TUI server
it parses arguments and YAML, invokes shared client operations, and formats the configuration. The CLI and TUI are terminal presentation layers that invoke
results. `gotcha-app` owns application state, preferences, favorites, shared client operations. `gotcha-app` owns application state, preferences, favorites,
Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native Keychain-backed credentials, and view-ready UniFFI records. UIKit owns native
navigation, controls, layout, and other platform presentation behavior. navigation, controls, layout, and other platform presentation behavior.

View File

@@ -1,11 +1,11 @@
# Testing Gotcha # Testing Gotcha
This document is the source of truth for iOS visual verification. Every UI This document is the source of truth for iOS and terminal UI verification.
change must update its relevant scenarios in the same commit. For ordinary Every UI change must update its relevant scenarios in the same commit. For
commits, run the build gates and every scenario affected by the change. A ordinary commits, run the build gates and every scenario affected by the
release test means completing this entire checklist successfully on the stated change. A release test means completing this entire checklist successfully on
simulators and devices; record failures as open issues and do not sign off until the stated terminals, simulators, and devices; record failures as open issues
every scenario passes. and do not sign off until every scenario passes.
## Test record ## Test record
@@ -38,6 +38,43 @@ cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace cargo test --workspace
``` ```
## Terminal UI
Run `cargo run -p gotcha-tui -- --server NAME` in a normal terminal and in a
Herdr pane. Use a non-production account with at least two server profiles and
representative repositories.
- [ ] The five numbered panes open Home, Issues, Repos, PRs, and Milestones;
the selected pane and row remain visibly distinct at narrow and wide
terminal sizes.
- [ ] `j`/`k`, arrows, `g`/`G`, Page Up/Down, Enter, Backspace, and `q` provide
mutt-style list and history navigation; `n`/`p` and scrolling past a
boundary traverse paginated API results without losing history.
- [ ] Clicking a pane switches it, clicking a list row selects it,
double-clicking opens it, and the mouse wheel scrolls the list.
- [ ] Home activity filters cycle with `v`; activity targets route to their
repository, issue, pull request, or commit.
- [ ] Repository favorites toggle with `*`, sort first per pane, and persist
after relaunch. Commits switch branches with `b`; file trees, text and
binary files, commit changes, pull changes, and per-file diffs open.
- [ ] Issue and pull filters apply. Issues can be created, edited,
closed/reopened, and deleted after confirmation; issue comments can be
added and the current user's comments edited.
- [ ] Milestones can be created, edited, closed/reopened, and deleted after
confirmation; milestone details route to their issues and pulls.
- [ ] Server profiles can be added, authenticated, selected, edited, renamed,
and deleted. Blank token on edit preserves the existing token; removing
the last server leaves the server manager available.
- [ ] Editors move between fields with Tab/Shift-Tab, support cursor movement,
Unicode insertion, Delete, and Backspace without navigating back, mask
tokens, save with Ctrl-S, and cancel with Esc.
- [ ] The default overview refresh interval is five seconds. Changing it with
`,` persists after relaunch; refresh preserves selection and pauses while
editing, confirming, scrolling, or otherwise interacting.
- [ ] `cargo build --release -p gotcha-tui` produces
`target/release/gotcha-tui`, and the installed executable starts from
`PATH`.
Build and install the simulator app: Build and install the simulator app:
```sh ```sh

View File

@@ -1,346 +1 @@
use std::{ pub use gotcha_gitea::{Config, RepositoryId as RepositoryScope, Selection, server_url};
collections::BTreeMap,
env, fs,
io::Write,
path::PathBuf,
process::{self, Command},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use gotcha_gitea::{Client, Provider, RepositoryId, Url};
use serde::{Deserialize, Serialize};
type Result<T> = std::result::Result<T, String>;
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Server {
pub url: String,
pub token: String,
#[serde(default)]
pub provider: Provider,
}
#[derive(Default, Deserialize, Serialize)]
pub struct Config {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
pub servers: BTreeMap<String, Server>,
}
pub struct Selection {
pub name: Option<String>,
pub url: String,
pub token: Option<String>,
pub provider: Provider,
pub repository: Option<RepositoryScope>,
}
pub type RepositoryScope = RepositoryId;
impl Config {
pub fn load() -> Result<Self> {
let home = env::var_os("HOME").ok_or("HOME is not set")?;
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
}
fn load_from(path: PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self {
path,
..Self::default()
});
}
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let mut config: Self = serde_yaml::from_str(&text)
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
config.path = path;
for (name, server) in &config.servers {
validate_name(name)?;
Client::with_provider(&server.url, Some(&server.token), server.provider)
.map_err(|error| format!("invalid server {name}: {error}"))?;
}
Ok(config)
}
pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
let url = server_url(name)?;
Client::with_provider(&url, Some(token), provider).map_err(|error| error.to_string())?;
self.servers.insert(
name.into(),
Server {
url,
token: token.into(),
provider,
},
);
self.save()
}
pub fn logout(&mut self, name: &str) -> Result<()> {
if self.servers.remove(name).is_none() {
return Err(format!("server profile {name:?} does not exist"));
}
self.save()
}
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
if name.is_some() && url.is_some() {
return Err("use either --server or --url, not both".into());
}
let remotes = git_remotes().unwrap_or_default();
if let Some(name) = name {
let server = self
.servers
.get(name)
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
return Ok(selection(Some(name), server, &remotes));
}
if let Some(url) = url {
let matches: Vec<_> = self
.servers
.iter()
.filter(|(_, server)| same_instance(&server.url, url))
.collect();
return match matches.as_slice() {
[] => Ok(selection(
None,
&Server {
url: url.into(),
token: String::new(),
provider: Provider::Gitea,
},
&remotes,
)),
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
_ => Err("multiple profiles use that URL; select one with --server".into()),
};
}
let mut matches = BTreeMap::new();
for (name, server) in &self.servers {
if let Some(scope) = remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote))
{
matches.insert(name, (server, scope));
}
}
match matches.into_iter().collect::<Vec<_>>().as_slice() {
[] if self.servers.is_empty() => {
Err("no servers configured; run `gotcha auth login SERVER`".into())
}
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
[(name, (server, scope))] => Ok(Selection {
name: Some((*name).clone()),
url: server.url.clone(),
token: Some(server.token.clone()),
provider: server.provider,
repository: Some(scope.clone()),
}),
_ => Err("multiple server profiles match this Git repository; use --server".into()),
}
}
fn save(&self) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
let result = (|| -> std::io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
fs::rename(&temporary, &self.path)?;
#[cfg(unix)]
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
}
}
fn selection(name: Option<&str>, server: &Server, remotes: &[String]) -> Selection {
Selection {
name: name.map(str::to_owned),
url: server.url.clone(),
token: (!server.token.is_empty()).then(|| server.token.clone()),
provider: server.provider,
repository: remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote)),
}
}
fn validate_name(name: &str) -> Result<()> {
server_url(name)?;
Ok(())
}
pub fn server_url(name: &str) -> Result<String> {
if name.is_empty()
|| name.contains('/')
|| name.contains('@')
|| name.chars().any(char::is_whitespace)
{
return Err("server name must be a hostname, optionally followed by a port".into());
}
let url = format!("https://{name}");
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
if parsed.host_str().is_none() || parsed.path() != "/" {
return Err("server name must be a hostname, optionally followed by a port".into());
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("server name must be a hostname, optionally followed by a port".into());
}
Ok(url)
}
fn same_instance(left: &str, right: &str) -> bool {
let api_url = |url| {
Client::new(url, None)
.ok()
.map(|client| client.api_url().clone())
};
api_url(left) == api_url(right)
}
fn git_remotes() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
.output()
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(char::is_whitespace))
.map(|(_, url)| url.trim().to_owned())
.collect())
}
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryScope> {
let server = Url::parse(server_url).ok()?;
let server_host = server.host_str()?;
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
(
url.host_str()?.to_owned(),
url.path().trim_matches('/').to_owned(),
matches!(url.scheme(), "http" | "https"),
)
} else {
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
let (host, path) = remote.split_once(':')?;
(host.into(), path.trim_matches('/').into(), false)
};
if !server_host.eq_ignore_ascii_case(&remote_host) {
return None;
}
let prefix = server.path().trim_matches('/');
if is_http && !prefix.is_empty() {
remote_path = remote_path
.strip_prefix(prefix)?
.strip_prefix('/')?
.to_owned();
}
let parts: Vec<_> = remote_path
.split('/')
.filter(|part| !part.is_empty())
.collect();
let [.., owner, repository] = parts.as_slice() else {
return None;
};
let repository = repository.strip_suffix(".git").unwrap_or(repository);
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryScope {
owner: (*owner).into(),
repository: repository.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip_and_remote_scope() {
let directory = env::temp_dir().join(format!(
"gotcha-config-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = directory.join("config");
let mut config = Config {
path: path.clone(),
..Config::default()
};
assert_eq!(
config.select(None, None).err().unwrap(),
"no servers configured; run `gotcha auth login SERVER`"
);
config
.login("code.example", "secret", Provider::Forgejo)
.unwrap();
let loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.servers["code.example"].token, "secret");
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
assert_eq!(
loaded.select(Some("code.example"), None).unwrap().provider,
Provider::Forgejo
);
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
.contains("provider: forgejo")
);
let legacy: Server =
serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
assert_eq!(legacy.provider, Provider::Gitea);
let scope = repository_scope(
"https://code.example/gitea",
"https://code.example/gitea/alice/project.git",
)
.unwrap();
assert_eq!(
(scope.owner.as_str(), scope.repository.as_str()),
("alice", "project")
);
#[cfg(unix)]
assert_eq!(
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(directory).unwrap();
}
}

View File

@@ -11,4 +11,5 @@ gitea-openapi = { package = "gitea-client", version = "=1.25.2", default-feature
reqwest.workspace = true reqwest.workspace = true
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
serde_yaml.workspace = true
tokio.workspace = true tokio.workspace = true

512
crates/gitea/src/config.rs Normal file
View File

@@ -0,0 +1,512 @@
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
io::Write,
path::PathBuf,
process::{self, Command},
time::{SystemTime, UNIX_EPOCH},
};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use serde::{Deserialize, Serialize};
use crate::{Client, Provider, RepositoryId, Url};
type Result<T> = std::result::Result<T, String>;
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct ServerProfile {
pub url: String,
pub token: String,
#[serde(default)]
pub provider: Provider,
}
#[derive(Clone, Deserialize, Serialize)]
pub struct TuiPreferences {
#[serde(default = "default_refresh_seconds")]
pub refresh_seconds: u64,
#[serde(default)]
pub favorites: BTreeSet<String>,
}
impl Default for TuiPreferences {
fn default() -> Self {
Self {
refresh_seconds: default_refresh_seconds(),
favorites: BTreeSet::new(),
}
}
}
const fn default_refresh_seconds() -> u64 {
5
}
#[derive(Clone, Default, Deserialize, Serialize)]
pub struct Config {
#[serde(skip)]
path: PathBuf,
#[serde(default)]
pub servers: BTreeMap<String, ServerProfile>,
#[serde(default)]
pub tui: TuiPreferences,
}
pub struct Selection {
pub name: Option<String>,
pub url: String,
pub token: Option<String>,
pub provider: Provider,
pub repository: Option<RepositoryId>,
}
impl Config {
pub fn load() -> Result<Self> {
let home = env::var_os("HOME").ok_or("HOME is not set")?;
Self::load_from(PathBuf::from(home).join(".config/gotcha/config"))
}
fn load_from(path: PathBuf) -> Result<Self> {
if !path.exists() {
return Ok(Self {
path,
..Self::default()
});
}
let text = fs::read_to_string(&path)
.map_err(|error| format!("cannot read {}: {error}", path.display()))?;
let mut config: Self = serde_yaml::from_str(&text)
.map_err(|error| format!("invalid {}: {error}", path.display()))?;
config.path = path;
for (name, server) in &config.servers {
validate_name(name)?;
Client::with_provider(&server.url, Some(&server.token), server.provider)
.map_err(|error| format!("invalid server {name}: {error}"))?;
}
Ok(config)
}
pub fn login(&mut self, name: &str, token: &str, provider: Provider) -> Result<()> {
let url = server_url(name)?;
self.save_server(name, &url, token, provider)
}
pub fn save_server(
&mut self,
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<()> {
validate_name(name)?;
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
let mut updated = self.clone();
updated.servers.insert(
name.into(),
ServerProfile {
url: url.trim_end_matches('/').into(),
token: token.into(),
provider,
},
);
updated.save()?;
*self = updated;
Ok(())
}
pub fn replace_server(
&mut self,
original_name: &str,
name: &str,
url: &str,
token: &str,
provider: Provider,
) -> Result<()> {
if !self.servers.contains_key(original_name) {
return Err(format!("server profile {original_name:?} does not exist"));
}
validate_name(name)?;
Client::with_provider(url, Some(token), provider).map_err(|error| error.to_string())?;
let mut updated = self.clone();
let old_url = updated
.servers
.get(original_name)
.map(|server| server.url.clone())
.expect("profile existence checked above");
let new_url = url.trim_end_matches('/');
updated.servers.remove(original_name);
updated.servers.insert(
name.into(),
ServerProfile {
url: new_url.into(),
token: token.into(),
provider,
},
);
if old_url != new_url {
migrate_favorites(&mut updated.tui.favorites, &old_url, new_url);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn logout(&mut self, name: &str) -> Result<()> {
if !self.servers.contains_key(name) {
return Err(format!("server profile {name:?} does not exist"));
}
let mut updated = self.clone();
let removed_url = updated.servers[name].url.clone();
updated.servers.remove(name);
if !updated
.servers
.values()
.any(|server| server.url == removed_url)
{
remove_favorites(&mut updated.tui.favorites, &removed_url);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn set_tui_refresh_seconds(&mut self, seconds: u64) -> Result<()> {
let mut updated = self.clone();
updated.tui.refresh_seconds = seconds;
updated.save()?;
*self = updated;
Ok(())
}
pub fn is_tui_favorite(&self, pane: &str, server_url: &str, repository: &RepositoryId) -> bool {
self.tui
.favorites
.contains(&favorite_key(pane, server_url, repository))
}
pub fn toggle_tui_favorite(
&mut self,
pane: &str,
server_url: &str,
repository: &RepositoryId,
) -> Result<()> {
let key = favorite_key(pane, server_url, repository);
let mut updated = self.clone();
if !updated.tui.favorites.remove(&key) {
updated.tui.favorites.insert(key);
}
updated.save()?;
*self = updated;
Ok(())
}
pub fn select(&self, name: Option<&str>, url: Option<&str>) -> Result<Selection> {
if name.is_some() && url.is_some() {
return Err("use either --server or --url, not both".into());
}
let remotes = git_remotes().unwrap_or_default();
if let Some(name) = name {
let server = self
.servers
.get(name)
.ok_or_else(|| format!("server profile {name:?} does not exist"))?;
return Ok(selection(Some(name), server, &remotes));
}
if let Some(url) = url {
let matches: Vec<_> = self
.servers
.iter()
.filter(|(_, server)| same_instance(&server.url, url))
.collect();
return match matches.as_slice() {
[] => Ok(selection(
None,
&ServerProfile {
url: url.into(),
token: String::new(),
provider: Provider::Gitea,
},
&remotes,
)),
[(name, server)] => Ok(selection(Some(name.as_str()), server, &remotes)),
_ => Err("multiple profiles use that URL; select one with --server".into()),
};
}
let mut matches = BTreeMap::new();
for (name, server) in &self.servers {
if let Some(scope) = remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote))
{
matches.insert(name, (server, scope));
}
}
match matches.into_iter().collect::<Vec<_>>().as_slice() {
[] if self.servers.is_empty() => {
Err("no servers configured; run `gotcha auth login SERVER`".into())
}
[] => Err("no configured server matches this Git repository; use --server NAME".into()),
[(name, (server, scope))] => Ok(Selection {
name: Some((*name).clone()),
url: server.url.clone(),
token: Some(server.token.clone()),
provider: server.provider,
repository: Some(scope.clone()),
}),
_ => Err("multiple server profiles match this Git repository; use --server".into()),
}
}
fn save(&self) -> Result<()> {
let parent = self
.path
.parent()
.ok_or_else(|| format!("invalid config path: {}", self.path.display()))?;
fs::create_dir_all(parent)
.map_err(|error| format!("cannot create {}: {error}", parent.display()))?;
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|error| error.to_string())?
.as_nanos();
let temporary = parent.join(format!(".config.{}.{nonce}.tmp", process::id()));
let text = serde_yaml::to_string(self).map_err(|error| error.to_string())?;
let result = (|| -> std::io::Result<()> {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
options.mode(0o600);
let mut file = options.open(&temporary)?;
file.write_all(text.as_bytes())?;
file.sync_all()?;
fs::rename(&temporary, &self.path)?;
#[cfg(unix)]
fs::set_permissions(&self.path, fs::Permissions::from_mode(0o600))?;
Ok(())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|error| format!("cannot write {}: {error}", self.path.display()))
}
}
fn favorite_key(pane: &str, server_url: &str, repository: &RepositoryId) -> String {
format!(
"{pane}|{}|{}/{}",
server_url.trim_end_matches('/'),
repository.owner,
repository.repository
)
}
fn migrate_favorites(favorites: &mut BTreeSet<String>, old_url: &str, new_url: &str) {
let old = format!("|{}|", old_url.trim_end_matches('/'));
let new = format!("|{}|", new_url.trim_end_matches('/'));
let migrations: Vec<_> = favorites
.iter()
.filter(|key| key.contains(&old))
.map(|key| (key.clone(), key.replacen(&old, &new, 1)))
.collect();
for (old, new) in migrations {
favorites.remove(&old);
favorites.insert(new);
}
}
fn remove_favorites(favorites: &mut BTreeSet<String>, server_url: &str) {
let server = format!("|{}|", server_url.trim_end_matches('/'));
favorites.retain(|key| !key.contains(&server));
}
fn selection(name: Option<&str>, server: &ServerProfile, remotes: &[String]) -> Selection {
Selection {
name: name.map(str::to_owned),
url: server.url.clone(),
token: (!server.token.is_empty()).then(|| server.token.clone()),
provider: server.provider,
repository: remotes
.iter()
.find_map(|remote| repository_scope(&server.url, remote)),
}
}
fn validate_name(name: &str) -> Result<()> {
server_url(name)?;
Ok(())
}
pub fn server_url(name: &str) -> Result<String> {
if name.is_empty()
|| name.contains('/')
|| name.contains('@')
|| name.chars().any(char::is_whitespace)
{
return Err("server name must be a hostname, optionally followed by a port".into());
}
let url = format!("https://{name}");
let parsed = Url::parse(&url).map_err(|_| "invalid server name")?;
if parsed.host_str().is_none() || parsed.path() != "/" {
return Err("server name must be a hostname, optionally followed by a port".into());
}
if parsed.query().is_some() || parsed.fragment().is_some() {
return Err("server name must be a hostname, optionally followed by a port".into());
}
Ok(url)
}
fn same_instance(left: &str, right: &str) -> bool {
let api_url = |url| {
Client::new(url, None)
.ok()
.map(|client| client.api_url().clone())
};
api_url(left) == api_url(right)
}
fn git_remotes() -> Result<Vec<String>> {
let output = Command::new("git")
.args(["config", "--get-regexp", r"^remote\..*\.url$"])
.output()
.map_err(|error| format!("cannot inspect Git remotes: {error}"))?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(char::is_whitespace))
.map(|(_, url)| url.trim().to_owned())
.collect())
}
fn repository_scope(server_url: &str, remote: &str) -> Option<RepositoryId> {
let server = Url::parse(server_url).ok()?;
let server_host = server.host_str()?;
let (remote_host, mut remote_path, is_http) = if let Ok(url) = Url::parse(remote) {
(
url.host_str()?.to_owned(),
url.path().trim_matches('/').to_owned(),
matches!(url.scheme(), "http" | "https"),
)
} else {
let remote = remote.rsplit_once('@').map_or(remote, |(_, rest)| rest);
let (host, path) = remote.split_once(':')?;
(host.into(), path.trim_matches('/').into(), false)
};
if !server_host.eq_ignore_ascii_case(&remote_host) {
return None;
}
let prefix = server.path().trim_matches('/');
if is_http && !prefix.is_empty() {
remote_path = remote_path
.strip_prefix(prefix)?
.strip_prefix('/')?
.to_owned();
}
let parts: Vec<_> = remote_path
.split('/')
.filter(|part| !part.is_empty())
.collect();
let [.., owner, repository] = parts.as_slice() else {
return None;
};
let repository = repository.strip_suffix(".git").unwrap_or(repository);
(!owner.is_empty() && !repository.is_empty()).then(|| RepositoryId {
owner: (*owner).into(),
repository: repository.into(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_round_trip_and_remote_scope() {
let directory = env::temp_dir().join(format!(
"gotcha-config-test-{}-{}",
process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let path = directory.join("config");
let mut config = Config {
path: path.clone(),
..Config::default()
};
assert_eq!(config.tui.refresh_seconds, 5);
assert_eq!(
config.select(None, None).err().unwrap(),
"no servers configured; run `gotcha auth login SERVER`"
);
config
.login("code.example", "secret", Provider::Forgejo)
.unwrap();
config.set_tui_refresh_seconds(9).unwrap();
let mut loaded = Config::load_from(path).unwrap();
assert_eq!(loaded.tui.refresh_seconds, 9);
assert_eq!(loaded.servers["code.example"].token, "secret");
assert_eq!(loaded.servers["code.example"].provider, Provider::Forgejo);
assert_eq!(
loaded.select(Some("code.example"), None).unwrap().provider,
Provider::Forgejo
);
let repository = RepositoryId {
owner: "alice".into(),
repository: "project".into(),
};
loaded
.toggle_tui_favorite("issues", "https://code.example", &repository)
.unwrap();
assert!(loaded.is_tui_favorite("issues", "https://code.example", &repository));
loaded
.replace_server(
"code.example",
"new.example",
"https://new.example",
"new-secret",
Provider::Gitea,
)
.unwrap();
assert!(loaded.is_tui_favorite("issues", "https://new.example", &repository));
assert!(!loaded.servers.contains_key("code.example"));
assert_eq!(loaded.servers["new.example"].token, "new-secret");
assert!(
fs::read_to_string(&loaded.path)
.unwrap()
.contains("provider: gitea")
);
let legacy: ServerProfile =
serde_yaml::from_str("url: https://gitea.example.com\ntoken: secret\n").unwrap();
assert_eq!(legacy.provider, Provider::Gitea);
let scope = repository_scope(
"https://code.example/gitea",
"https://code.example/gitea/alice/project.git",
)
.unwrap();
assert_eq!(
(scope.owner.as_str(), scope.repository.as_str()),
("alice", "project")
);
#[cfg(unix)]
assert_eq!(
fs::metadata(&loaded.path).unwrap().permissions().mode() & 0o777,
0o600
);
fs::remove_dir_all(directory).unwrap();
}
}

View File

@@ -12,6 +12,7 @@ pub use gitea_openapi::models;
pub use models::{Repository, ServerVersion, User}; pub use models::{Repository, ServerVersion, User};
pub mod activity; pub mod activity;
mod config;
pub mod diff; pub mod diff;
mod domain; mod domain;
mod issues; mod issues;
@@ -20,6 +21,7 @@ mod pulls;
mod repositories; mod repositories;
pub use activity::ActivityFilter; pub use activity::ActivityFilter;
pub use config::{Config, Selection, ServerProfile, TuiPreferences, server_url};
pub use domain::{ pub use domain::{
CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft, CreateIssue, DEFAULT_PAGE_SIZE, EditIssue, HistoryCommit, HomeData, IssueDetails, IssueDraft,
IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId, IssueEditorData, IssueQuery, MilestoneDetails, MilestoneDraft, Page, PullDetails, RepositoryId,

17
crates/tui/Cargo.toml Normal file
View 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

File diff suppressed because it is too large Load Diff

326
crates/tui/src/editor.rs Normal file
View 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
View 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
View 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);
}
}