playing around with a TUI

This commit is contained in:
JasterV 2026-01-29 01:28:31 +01:00
commit 09304342e7
11 changed files with 2065 additions and 22 deletions

967
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,5 +1,5 @@
[workspace]
members = ["granc", "granc-core", "echo-service"]
members = ["granc", "granc-core", "granc-tui", "echo-service"]
resolver = "2"
[workspace.package]

28
granc-tui/Cargo.toml Normal file
View file

@ -0,0 +1,28 @@
[package]
name = "granc-tui"
version = "0.1.0"
edition = "2024"
[dependencies]
# Core Logic
granc_core = { path = "../granc-core" }
# Architecture
teatui = "0.1"
ratatui = "0.29.0"
crossterm = "0.29.0"
tui-textarea = "0.7.0"
# Async Runtime
tokio = { version = "1.43", features = ["full"] }
# Data & Config
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
directories = "5.0"
uuid = { version = "1.10", features = ["v4", "serde"] }
once_cell = "1.19"
# Error Handling
color-eyre = "0.6"
anyhow = "1.0"

68
granc-tui/src/config.rs Normal file
View file

@ -0,0 +1,68 @@
use anyhow::{Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq)]
pub struct AppConfig {
pub projects: Vec<Project>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct Project {
pub id: Uuid,
pub name: String,
pub connection: ConnectionConfig,
pub saved_requests: Vec<SavedRequest>,
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(tag = "type", content = "value")]
pub enum ConnectionConfig {
Reflection { url: String },
File { url: String, path: PathBuf },
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SavedRequest {
pub id: Uuid,
pub name: String,
pub service: String,
pub method: String,
pub body: String,
pub headers: Vec<(String, String)>,
}
pub struct ConfigManager {
config_path: PathBuf,
}
impl ConfigManager {
pub fn new() -> Result<Self> {
let proj_dirs = ProjectDirs::from("com", "granc", "granc-tui")
.context("Could not determine config directory")?;
let config_dir = proj_dirs.config_dir();
fs::create_dir_all(config_dir)?;
Ok(Self {
config_path: config_dir.join("config.json"),
})
}
pub fn load(&self) -> Result<AppConfig> {
if !self.config_path.exists() {
return Ok(AppConfig::default());
}
let content = fs::read_to_string(&self.config_path)?;
let config = serde_json::from_str(&content).unwrap_or_default();
Ok(config)
}
pub fn save(&self, config: &AppConfig) -> Result<()> {
let content = serde_json::to_string_pretty(config)?;
fs::write(&self.config_path, content)?;
Ok(())
}
}

243
granc-tui/src/effects.rs Normal file
View file

@ -0,0 +1,243 @@
use crate::config::{ConfigManager, ConnectionConfig, Project};
use crate::globals;
use crate::model::{MethodData, Model};
use crate::msg::Msg;
use color_eyre::Result;
use granc_core::client::{Descriptor, DynamicRequest, DynamicResponse, GrancClient};
#[derive(Debug, Clone)]
pub enum Effect {
LoadConfigFromDisk,
SaveConfigToDisk(crate::config::AppConfig),
FetchServices(Project),
FetchMethods {
project: Project,
service: String,
},
ExecuteCall {
project: Project,
service: String,
method: String,
body: String,
headers: Vec<(String, String)>,
},
}
pub fn handle_effect(_model: &Model, effect: Effect) -> Result<Option<Msg>> {
match effect {
Effect::LoadConfigFromDisk => {
let manager = ConfigManager::new().map_err(|e| color_eyre::eyre::eyre!(e))?;
match manager.load() {
Ok(cfg) => Ok(Some(Msg::ConfigLoaded(Ok(cfg)))),
Err(e) => Ok(Some(Msg::ConfigLoaded(Err(e.to_string())))),
}
}
Effect::SaveConfigToDisk(cfg) => {
if let Ok(manager) = ConfigManager::new() {
let _ = manager.save(&cfg);
}
Ok(None)
}
Effect::FetchServices(proj) => {
let handle = globals::get_handle();
let project_id = proj.id;
let result = handle.block_on(async move { fetch_services_async(&proj).await });
match result {
Ok(services) => Ok(Some(Msg::ServicesFetched {
project_id,
services,
})),
Err(e) => Ok(Some(Msg::CallResponse(Err(format!("Fetch failed: {}", e))))),
}
}
Effect::FetchMethods { project, service } => {
let handle = globals::get_handle();
let service_name = service.clone();
let result =
handle.block_on(async move { fetch_methods_async(&project, &service_name).await });
match result {
Ok(methods) => Ok(Some(Msg::MethodsFetched { service, methods })),
Err(e) => Ok(Some(Msg::CallResponse(Err(format!(
"Fetch methods failed: {}",
e
))))),
}
}
Effect::ExecuteCall {
project,
service,
method,
body,
headers,
} => {
let handle = globals::get_handle();
let result = handle.block_on(async move {
execute_call_async(&project, service, method, body, headers).await
});
Ok(Some(Msg::CallResponse(result)))
}
}
}
// --- Async Handlers ---
async fn fetch_services_async(proj: &Project) -> std::result::Result<Vec<String>, String> {
match &proj.connection {
ConnectionConfig::Reflection { url } => {
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
client.list_services().await.map_err(|e| e.to_string())
}
ConnectionConfig::File { url, path } => {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
if let Ok(c) = GrancClient::connect(url).await {
if let Ok(fc) = c.with_file_descriptor(bytes.clone()) {
return Ok(fc.list_services());
}
}
let client = GrancClient::offline(bytes).map_err(|e| e.to_string())?;
Ok(client.list_services())
}
}
}
async fn fetch_methods_async(
proj: &Project,
service: &str,
) -> std::result::Result<Vec<MethodData>, String> {
fn extract(descriptor: Descriptor) -> std::result::Result<Vec<MethodData>, String> {
match descriptor {
Descriptor::ServiceDescriptor(sd) => {
let methods = sd
.methods()
.map(|m| {
let input_desc = m.input();
let input = input_desc.name();
let output_desc = m.output();
let output = output_desc.name();
let client_stream = if m.is_client_streaming() {
"stream "
} else {
""
};
let server_stream = if m.is_server_streaming() {
"stream "
} else {
""
};
MethodData {
name: m.name().to_string(),
signature: format!(
"rpc {}({}{}) returns ({}{})",
m.name(),
client_stream,
input,
server_stream,
output
),
}
})
.collect();
Ok(methods)
}
_ => Err("Symbol is not a service".to_string()),
}
}
match &proj.connection {
ConnectionConfig::Reflection { url } => {
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
let descriptor = client
.get_descriptor_by_symbol(service)
.await
.map_err(|e| e.to_string())?;
extract(descriptor)
}
ConnectionConfig::File { url, path } => {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
if let Ok(c) = GrancClient::connect(url).await {
// Fixed: Removed 'mut' from fc
if let Ok(fc) = c.with_file_descriptor(bytes.clone()) {
if let Some(d) = fc.get_descriptor_by_symbol(service) {
return extract(d);
}
}
}
let client = GrancClient::offline(bytes).map_err(|e| e.to_string())?;
if let Some(d) = client.get_descriptor_by_symbol(service) {
return extract(d);
}
Err("Service not found".to_string())
}
}
}
async fn execute_call_async(
proj: &Project,
service: String,
method: String,
body: String,
headers: Vec<(String, String)>,
) -> std::result::Result<String, String> {
let json_body: serde_json::Value =
serde_json::from_str(&body).map_err(|e| format!("Invalid JSON: {}", e))?;
let req = DynamicRequest {
service,
method,
body: json_body,
headers,
};
match &proj.connection {
ConnectionConfig::Reflection { url } => {
let mut client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
let resp = client.dynamic(req).await.map_err(|e| e.to_string())?;
format_response(resp)
}
ConnectionConfig::File { url, path } => {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let client = GrancClient::connect(url).await.map_err(|e| e.to_string())?;
let mut client = client
.with_file_descriptor(bytes)
.map_err(|e| e.to_string())?;
let resp = client.dynamic(req).await.map_err(|e| e.to_string())?;
format_response(resp)
}
}
}
fn format_response(resp: DynamicResponse) -> std::result::Result<String, String> {
match resp {
DynamicResponse::Unary(Ok(v)) => Ok(serde_json::to_string_pretty(&v).unwrap_or_default()),
DynamicResponse::Unary(Err(s)) => {
Err(format!("gRPC Error: {} (Code: {})", s.message(), s.code()))
}
DynamicResponse::Streaming(r) => {
let mut out = String::new();
match r {
Ok(msgs) => {
for (i, msg) in msgs.into_iter().enumerate() {
match msg {
Ok(v) => out.push_str(&format!(
"Msg {}:\n{}\n",
i,
serde_json::to_string_pretty(&v).unwrap_or_default()
)),
Err(s) => out.push_str(&format!("Msg {} Error: {}\n", i, s.message())),
}
}
Ok(out)
}
Err(s) => Err(format!("Stream Error: {}", s.message())),
}
}
}
}

15
granc-tui/src/globals.rs Normal file
View file

@ -0,0 +1,15 @@
use once_cell::sync::OnceCell;
use tokio::runtime::Handle;
pub static TOKIO_HANDLE: OnceCell<Handle> = OnceCell::new();
pub fn init_handle() {
let handle = Handle::current();
TOKIO_HANDLE
.set(handle)
.expect("Failed to set global Tokio handle");
}
pub fn get_handle() -> &'static Handle {
TOKIO_HANDLE.get().expect("Tokio handle not initialized")
}

28
granc-tui/src/main.rs Normal file
View file

@ -0,0 +1,28 @@
mod config;
mod effects;
mod globals;
mod model;
mod msg;
mod update;
mod view;
use model::Model;
#[tokio::main]
async fn main() -> color_eyre::Result<()> {
color_eyre::install()?;
// Initialize global Tokio handle for effects thread
globals::init_handle();
let initial_model = Model::default();
teatui::start(
initial_model,
update::update,
view::view,
effects::handle_effect,
)?;
Ok(())
}

100
granc-tui/src/model.rs Normal file
View file

@ -0,0 +1,100 @@
use crate::config::{AppConfig, Project};
use tui_textarea::TextArea;
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq)]
pub enum Screen {
Dashboard,
NewProject,
ServiceBrowser,
MethodBrowser,
MethodView,
ResponseView,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Focus {
Body,
HeaderKey(usize),
HeaderValue(usize),
}
#[derive(Debug, Clone)]
pub struct MethodData {
pub name: String,
pub signature: String,
}
#[derive(Debug, Clone)]
pub struct HeaderPair {
pub key: String,
pub value: String,
}
#[derive(Debug, Clone)]
pub struct Model {
pub screen: Screen,
pub config: AppConfig,
// Global Input Buffer (New Project / URL)
pub input_buffer: String,
// Navigation Indices
pub project_list_idx: usize,
pub service_list_idx: usize,
pub method_list_idx: usize,
// Data
pub selected_project_id: Option<Uuid>,
pub services: Vec<String>,
pub methods: Vec<MethodData>, // Updated to hold signature
pub selected_service: Option<String>,
pub selected_method: Option<String>,
// Request Editor State
pub body_editor: TextArea<'static>,
pub headers: Vec<HeaderPair>,
pub focus: Focus,
// Results
pub response_output: String,
pub status_message: Option<String>,
}
impl Default for Model {
fn default() -> Self {
let mut editor = TextArea::default();
editor.set_block(
ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.title("Body (JSON)"),
);
editor.insert_str("{}");
Self {
screen: Screen::Dashboard,
config: AppConfig::default(),
input_buffer: String::new(),
project_list_idx: 0,
service_list_idx: 0,
method_list_idx: 0,
selected_project_id: None,
services: vec![],
methods: vec![],
selected_service: None,
selected_method: None,
body_editor: editor,
headers: vec![],
focus: Focus::Body,
response_output: String::new(),
status_message: None,
}
}
}
impl Model {
pub fn current_project(&self) -> Option<&Project> {
self.selected_project_id
.and_then(|id| self.config.projects.iter().find(|p| p.id == id))
}
}

46
granc-tui/src/msg.rs Normal file
View file

@ -0,0 +1,46 @@
use crate::config::AppConfig;
use crate::model::MethodData;
use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind};
use uuid::Uuid;
#[derive(Debug, Clone)]
pub enum Msg {
// --- User Input ---
Key(KeyEvent),
// --- Config Lifecycle ---
ConfigLoaded(Result<AppConfig, String>),
// --- Async Results ---
ServicesFetched {
project_id: Uuid,
services: Vec<String>,
},
MethodsFetched {
service: String,
methods: Vec<MethodData>,
},
CallResponse(Result<String, String>),
// --- System ---
NoOp,
Exit,
}
impl From<Event> for Msg {
fn from(event: Event) -> Self {
match event {
Event::Key(key) if key.kind == KeyEventKind::Press => match key.code {
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
Msg::Exit
}
_ => Msg::Key(key),
},
_ => Msg::NoOp,
}
}
}

365
granc-tui/src/update.rs Normal file
View file

@ -0,0 +1,365 @@
use color_eyre::Result;
use crossterm::event::{KeyCode, KeyModifiers};
use teatui::Update;
use uuid::Uuid;
use crate::config::{ConnectionConfig, Project};
use crate::effects::Effect;
use crate::model::{Focus, HeaderPair, Model, Screen};
use crate::msg::Msg;
pub fn update(mut model: Model, msg: Msg) -> Result<Update<Model, Effect>> {
match msg {
Msg::Exit => Ok(Update::Exit),
Msg::NoOp => Ok(Update::Next(model)),
Msg::ConfigLoaded(res) => {
match res {
Ok(cfg) => {
model.config = cfg;
model.status_message = Some("Config loaded".into());
}
Err(e) => model.status_message = Some(format!("Config Error: {}", e)),
}
Ok(Update::Next(model))
}
// Handle Editor Specific Messages (Method View)
Msg::Key(key) if model.screen == Screen::MethodView => match key.code {
KeyCode::Esc => {
model.screen = Screen::MethodBrowser;
Ok(Update::Next(model))
}
KeyCode::Tab => {
match model.focus {
Focus::Body => {
if !model.headers.is_empty() {
model.focus = Focus::HeaderKey(0);
}
}
Focus::HeaderKey(i) => model.focus = Focus::HeaderValue(i),
Focus::HeaderValue(i) => {
if i + 1 < model.headers.len() {
model.focus = Focus::HeaderKey(i + 1);
} else {
model.focus = Focus::Body;
}
}
}
Ok(Update::Next(model))
}
KeyCode::Char('h') if key.modifiers.contains(KeyModifiers::CONTROL) => {
model.headers.push(HeaderPair {
key: "".into(),
value: "".into(),
});
model.focus = Focus::HeaderKey(model.headers.len() - 1);
Ok(Update::Next(model))
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
match model.focus {
Focus::HeaderKey(i) | Focus::HeaderValue(i) => {
model.headers.remove(i);
model.focus = Focus::Body;
}
_ => {}
}
Ok(Update::Next(model))
}
KeyCode::Enter | KeyCode::Char('s')
if key.modifiers.contains(KeyModifiers::CONTROL) =>
{
execute_request(model)
}
_ => {
match model.focus {
Focus::Body => {
model.body_editor.input(to_ratatui_key(key));
}
Focus::HeaderKey(i) => {
handle_text_input(&mut model.headers[i].key, key);
}
Focus::HeaderValue(i) => {
handle_text_input(&mut model.headers[i].value, key);
}
}
Ok(Update::Next(model))
}
},
Msg::Key(key) => match (model.screen.clone(), key.code) {
// Global
(_, KeyCode::Char('q')) => Ok(Update::Exit),
// --- Dashboard ---
(Screen::Dashboard, KeyCode::Char('l')) => {
Ok(Update::NextWithEffect(model, Effect::LoadConfigFromDisk))
}
(Screen::Dashboard, KeyCode::Char('n')) => {
model.screen = Screen::NewProject;
model.input_buffer.clear();
Ok(Update::Next(model))
}
(Screen::Dashboard, KeyCode::Down) => {
if !model.config.projects.is_empty() {
model.project_list_idx =
(model.project_list_idx + 1) % model.config.projects.len();
}
Ok(Update::Next(model))
}
(Screen::Dashboard, KeyCode::Up) => {
if !model.config.projects.is_empty() {
model.project_list_idx = if model.project_list_idx == 0 {
model.config.projects.len() - 1
} else {
model.project_list_idx - 1
};
}
Ok(Update::Next(model))
}
(Screen::Dashboard, KeyCode::Enter) => {
let project_opt = model.config.projects.get(model.project_list_idx).cloned();
if let Some(proj) = project_opt {
model.selected_project_id = Some(proj.id);
model.screen = Screen::ServiceBrowser;
model.service_list_idx = 0;
model.status_message = Some("Fetching services...".into());
Ok(Update::NextWithEffect(model, Effect::FetchServices(proj)))
} else {
Ok(Update::Next(model))
}
}
// --- New Project ---
(Screen::NewProject, KeyCode::Enter) => {
let new_proj = Project {
id: Uuid::new_v4(),
name: if model.input_buffer.is_empty() {
"Untitled".to_string()
} else {
model.input_buffer.clone()
},
connection: ConnectionConfig::Reflection {
url: model.input_buffer.clone(),
},
saved_requests: vec![],
};
model.config.projects.push(new_proj);
model.screen = Screen::Dashboard;
model.project_list_idx = model.config.projects.len() - 1;
let effect = Effect::SaveConfigToDisk(model.config.clone());
Ok(Update::NextWithEffect(model, effect))
}
(Screen::NewProject, KeyCode::Char(c)) => {
model.input_buffer.push(c);
Ok(Update::Next(model))
}
(Screen::NewProject, KeyCode::Backspace) => {
model.input_buffer.pop();
Ok(Update::Next(model))
}
(Screen::NewProject, KeyCode::Esc) => {
model.screen = Screen::Dashboard;
Ok(Update::Next(model))
}
// --- Service Browser ---
(Screen::ServiceBrowser, KeyCode::Down) => {
if !model.services.is_empty() {
model.service_list_idx = (model.service_list_idx + 1) % model.services.len();
}
Ok(Update::Next(model))
}
(Screen::ServiceBrowser, KeyCode::Up) => {
if !model.services.is_empty() {
model.service_list_idx = if model.service_list_idx == 0 {
model.services.len() - 1
} else {
model.service_list_idx - 1
};
}
Ok(Update::Next(model))
}
(Screen::ServiceBrowser, KeyCode::Enter) => {
if let (Some(svc), Some(proj)) = (
model.services.get(model.service_list_idx).cloned(),
model.current_project().cloned(),
) {
model.selected_service = Some(svc.clone());
model.status_message = Some("Fetching methods...".into());
Ok(Update::NextWithEffect(
model,
Effect::FetchMethods {
project: proj,
service: svc,
},
))
} else {
Ok(Update::Next(model))
}
}
(Screen::ServiceBrowser, KeyCode::Esc) => {
model.screen = Screen::Dashboard;
Ok(Update::Next(model))
}
// --- Method Browser ---
(Screen::MethodBrowser, KeyCode::Down) => {
if !model.methods.is_empty() {
model.method_list_idx = (model.method_list_idx + 1) % model.methods.len();
}
Ok(Update::Next(model))
}
(Screen::MethodBrowser, KeyCode::Up) => {
if !model.methods.is_empty() {
model.method_list_idx = if model.method_list_idx == 0 {
model.methods.len() - 1
} else {
model.method_list_idx - 1
};
}
Ok(Update::Next(model))
}
(Screen::MethodBrowser, KeyCode::Enter) => {
if let Some(m) = model.methods.get(model.method_list_idx).cloned() {
model.selected_method = Some(m.name);
model.headers.clear();
model.focus = Focus::Body;
model.screen = Screen::MethodView;
}
Ok(Update::Next(model))
}
(Screen::MethodBrowser, KeyCode::Esc) => {
model.screen = Screen::ServiceBrowser;
Ok(Update::Next(model))
}
// --- Response View ---
(Screen::ResponseView, KeyCode::Esc) => {
model.screen = Screen::MethodView;
Ok(Update::Next(model))
}
_ => Ok(Update::Next(model)),
},
Msg::ServicesFetched {
project_id,
services,
} => {
if model.selected_project_id == Some(project_id) {
model.services = services;
model.service_list_idx = 0;
model.status_message = Some("Services loaded.".into());
}
Ok(Update::Next(model))
}
Msg::MethodsFetched { service, methods } => {
if model.selected_service.as_deref() == Some(&service) {
model.methods = methods;
model.method_list_idx = 0;
model.screen = Screen::MethodBrowser;
model.status_message = Some("Methods loaded.".into());
}
Ok(Update::Next(model))
}
Msg::CallResponse(res) => {
model.screen = Screen::ResponseView;
match res {
Ok(s) => {
model.response_output = s;
model.status_message = Some("Call success".into());
}
Err(e) => {
model.response_output = format!("Error: {}", e);
model.status_message = Some("Call failed".into());
}
}
Ok(Update::Next(model))
}
}
}
fn execute_request(mut model: Model) -> Result<Update<Model, Effect>> {
let execution_data = if let (Some(p), Some(s), Some(m)) = (
model.current_project(),
&model.selected_service,
&model.selected_method,
) {
Some((p.clone(), s.clone(), m.clone()))
} else {
None
};
if let Some((p, s, m)) = execution_data {
let headers: Vec<(String, String)> = model
.headers
.iter()
.filter(|h| !h.key.is_empty())
.map(|h| (h.key.clone(), h.value.clone()))
.collect();
let body_lines = model.body_editor.lines().to_vec();
let body = body_lines.join("\n");
let effect = Effect::ExecuteCall {
project: p,
service: s,
method: m,
body,
headers,
};
model.status_message = Some("Executing request...".into());
Ok(Update::NextWithEffect(model, effect))
} else {
model.status_message =
Some("Error: Missing execution context (project/service/method)".into());
Ok(Update::Next(model))
}
}
fn handle_text_input(target: &mut String, key: crossterm::event::KeyEvent) {
match key.code {
KeyCode::Char(c) => target.push(c),
KeyCode::Backspace => {
target.pop();
}
_ => {}
}
}
fn to_ratatui_key(key: crossterm::event::KeyEvent) -> ratatui::crossterm::event::KeyEvent {
let code = match key.code {
crossterm::event::KeyCode::Backspace => ratatui::crossterm::event::KeyCode::Backspace,
crossterm::event::KeyCode::Enter => ratatui::crossterm::event::KeyCode::Enter,
crossterm::event::KeyCode::Left => ratatui::crossterm::event::KeyCode::Left,
crossterm::event::KeyCode::Right => ratatui::crossterm::event::KeyCode::Right,
crossterm::event::KeyCode::Up => ratatui::crossterm::event::KeyCode::Up,
crossterm::event::KeyCode::Down => ratatui::crossterm::event::KeyCode::Down,
crossterm::event::KeyCode::Home => ratatui::crossterm::event::KeyCode::Home,
crossterm::event::KeyCode::End => ratatui::crossterm::event::KeyCode::End,
crossterm::event::KeyCode::PageUp => ratatui::crossterm::event::KeyCode::PageUp,
crossterm::event::KeyCode::PageDown => ratatui::crossterm::event::KeyCode::PageDown,
crossterm::event::KeyCode::Tab => ratatui::crossterm::event::KeyCode::Tab,
crossterm::event::KeyCode::BackTab => ratatui::crossterm::event::KeyCode::BackTab,
crossterm::event::KeyCode::Delete => ratatui::crossterm::event::KeyCode::Delete,
crossterm::event::KeyCode::Insert => ratatui::crossterm::event::KeyCode::Insert,
crossterm::event::KeyCode::F(n) => ratatui::crossterm::event::KeyCode::F(n),
crossterm::event::KeyCode::Char(c) => ratatui::crossterm::event::KeyCode::Char(c),
crossterm::event::KeyCode::Null => ratatui::crossterm::event::KeyCode::Null,
crossterm::event::KeyCode::Esc => ratatui::crossterm::event::KeyCode::Esc,
_ => ratatui::crossterm::event::KeyCode::Null,
};
let modifiers =
ratatui::crossterm::event::KeyModifiers::from_bits_truncate(key.modifiers.bits());
ratatui::crossterm::event::KeyEvent {
code,
modifiers,
kind: ratatui::crossterm::event::KeyEventKind::Press,
state: ratatui::crossterm::event::KeyEventState::empty(),
}
}

217
granc-tui/src/view.rs Normal file
View file

@ -0,0 +1,217 @@
use crate::model::{Focus, Model, Screen};
use color_eyre::Result;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Style, Stylize},
widgets::ListState,
widgets::{Block, Borders, List, ListItem, Paragraph, StatefulWidget, Widget, WidgetRef},
};
use teatui::View;
struct RootWidget {
model: Model,
}
impl WidgetRef for RootWidget {
fn render_ref(&self, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(0), Constraint::Length(1)])
.split(area);
let main_area = chunks[0];
let status_bar = chunks[1];
match self.model.screen {
Screen::Dashboard => draw_dashboard(&self.model, main_area, buf),
Screen::NewProject => draw_new_project(&self.model, main_area, buf),
Screen::ServiceBrowser => draw_services(&self.model, main_area, buf),
Screen::MethodBrowser => draw_method_browser(&self.model, main_area, buf),
Screen::MethodView => draw_method_execution(&self.model, main_area, buf),
Screen::ResponseView => draw_response(&self.model, main_area, buf),
}
let msg = self.model.status_message.as_deref().unwrap_or("Ready");
let status_text = format!(
" {} | Screen: {:?} | [Q] Quit | [L] Load",
msg, self.model.screen
);
Paragraph::new(status_text)
.style(Style::default().bg(Color::Blue).fg(Color::White))
.render_ref(status_bar, buf);
}
}
pub fn view(model: &Model) -> Result<View> {
Ok(View::new(RootWidget {
model: model.clone(),
}))
}
// --- Helpers ---
fn draw_dashboard(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let items: Vec<ListItem> = model
.config
.projects
.iter()
.map(|p| ListItem::new(p.name.as_str()))
.collect();
let list = List::new(items)
.block(
Block::default()
.title("Projects (Press 'n' for new)")
.borders(Borders::ALL),
)
.highlight_style(Style::default().bg(Color::DarkGray).bold());
let mut state = ListState::default().with_selected(Some(model.project_list_idx));
StatefulWidget::render(list, area, buf, &mut state);
}
fn draw_new_project(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let text = Paragraph::new(format!("Server URL: {}", model.input_buffer)).block(
Block::default()
.title("New Project (Enter URL)")
.borders(Borders::ALL),
);
text.render_ref(area, buf);
}
fn draw_services(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let items: Vec<ListItem> = model
.services
.iter()
.map(|s| ListItem::new(s.as_str()))
.collect();
let list = List::new(items)
.block(Block::default().title("Services").borders(Borders::ALL))
.highlight_style(Style::default().bg(Color::DarkGray).bold());
let mut state = ListState::default().with_selected(Some(model.service_list_idx));
StatefulWidget::render(list, area, buf, &mut state);
}
fn draw_method_browser(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let items: Vec<ListItem> = model
.methods
.iter()
.map(|m| ListItem::new(m.signature.as_str()))
.collect();
let list = List::new(items)
.block(
Block::default()
.title(format!(
"Methods of {}",
model.selected_service.as_deref().unwrap_or("?")
))
.borders(Borders::ALL),
)
.highlight_style(Style::default().bg(Color::DarkGray).bold());
let mut state = ListState::default().with_selected(Some(model.method_list_idx));
StatefulWidget::render(list, area, buf, &mut state);
}
fn draw_method_execution(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // Title
Constraint::Percentage(60), // Body
Constraint::Percentage(30), // Headers
Constraint::Length(1), // Hint
])
.split(area);
let title = format!(
"Executing: {}",
model.selected_method.as_deref().unwrap_or("?")
);
Paragraph::new(title).bold().render_ref(chunks[0], buf);
// Body Editor
let mut editor = model.body_editor.clone();
let body_block = Block::default()
.borders(Borders::ALL)
.title("Request Body (JSON)");
if model.focus == Focus::Body {
editor.set_style(Style::default());
editor.set_block(body_block.border_style(Style::default().fg(Color::Yellow)));
} else {
editor.set_style(Style::default().fg(Color::DarkGray));
editor.set_block(body_block);
}
editor.render(chunks[1], buf);
// Headers
let header_block = Block::default()
.borders(Borders::ALL)
.title("Headers (Ctrl+H to add)");
header_block.render_ref(chunks[2], buf);
let header_area = chunks[2].inner(ratatui::layout::Margin {
horizontal: 1,
vertical: 1,
});
let header_rows = Layout::default()
.direction(Direction::Vertical)
.constraints(vec![Constraint::Length(1); model.headers.len()])
.split(header_area);
for (i, header) in model.headers.iter().enumerate() {
if i >= header_rows.len() {
break;
}
let row_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(45),
Constraint::Length(1),
Constraint::Percentage(45),
])
.split(header_rows[i]);
let k_style = if model.focus == Focus::HeaderKey(i) {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
Paragraph::new(header.key.as_str())
.style(k_style)
.render_ref(row_chunks[0], buf);
Paragraph::new(":").render_ref(row_chunks[1], buf);
let v_style = if model.focus == Focus::HeaderValue(i) {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
Paragraph::new(header.value.as_str())
.style(v_style)
.render_ref(row_chunks[2], buf);
}
Paragraph::new(
"[Tab] Cycle Focus | [Ctrl+Enter/S] Send | [Ctrl+H] Add Header | [Ctrl+D] Remove Header",
)
.style(Style::default().fg(Color::Gray))
.render_ref(chunks[3], buf);
}
fn draw_response(model: &Model, area: Rect, buf: &mut ratatui::prelude::Buffer) {
let p = Paragraph::new(model.response_output.as_str()).block(
Block::default()
.title("Response (Esc to back)")
.borders(Borders::ALL),
);
p.render_ref(area, buf);
}