dialog is a feature
This commit is contained in:
85
client/src/dialog/functions.rs
Normal file
85
client/src/dialog/functions.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
// src/dialog/functions.rs
|
||||
|
||||
use crate::dialog::DialogState;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
|
||||
impl AppState {
|
||||
pub fn show_dialog(
|
||||
&mut self,
|
||||
title: &str,
|
||||
message: &str,
|
||||
buttons: Vec<String>,
|
||||
purpose: DialogPurpose,
|
||||
) {
|
||||
self.ui.dialog.dialog_title = title.to_string();
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons = buttons;
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
self.ui.focus_outside_canvas = true;
|
||||
}
|
||||
|
||||
pub fn show_loading_dialog(&mut self, title: &str, message: &str) {
|
||||
self.ui.dialog.dialog_title = title.to_string();
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons.clear();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.dialog.is_loading = true;
|
||||
self.ui.dialog.dialog_show = true;
|
||||
self.ui.focus_outside_canvas = true;
|
||||
}
|
||||
|
||||
pub fn update_dialog_content(
|
||||
&mut self,
|
||||
message: &str,
|
||||
buttons: Vec<String>,
|
||||
purpose: DialogPurpose,
|
||||
) {
|
||||
if self.ui.dialog.dialog_show {
|
||||
self.ui.dialog.dialog_message = message.to_string();
|
||||
self.ui.dialog.dialog_buttons = buttons;
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = Some(purpose);
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hide_dialog(&mut self) {
|
||||
self.ui.dialog.dialog_show = false;
|
||||
self.ui.dialog.dialog_title.clear();
|
||||
self.ui.dialog.dialog_message.clear();
|
||||
self.ui.dialog.dialog_buttons.clear();
|
||||
self.ui.dialog.dialog_active_button_index = 0;
|
||||
self.ui.dialog.purpose = None;
|
||||
self.ui.focus_outside_canvas = false;
|
||||
self.ui.dialog.is_loading = false;
|
||||
}
|
||||
|
||||
pub fn next_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let next_index = (self.ui.dialog.dialog_active_button_index + 1)
|
||||
% self.ui.dialog.dialog_buttons.len();
|
||||
self.ui.dialog.dialog_active_button_index = next_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_dialog_button(&mut self) {
|
||||
if !self.ui.dialog.dialog_buttons.is_empty() {
|
||||
let len = self.ui.dialog.dialog_buttons.len();
|
||||
let prev_index =
|
||||
(self.ui.dialog.dialog_active_button_index + len - 1) % len;
|
||||
self.ui.dialog.dialog_active_button_index = prev_index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_active_dialog_button_label(&self) -> Option<&str> {
|
||||
self.ui.dialog
|
||||
.dialog_buttons
|
||||
.get(self.ui.dialog.dialog_active_button_index)
|
||||
.map(|s| s.as_str())
|
||||
}
|
||||
}
|
||||
192
client/src/dialog/logic.rs
Normal file
192
client/src/dialog/logic.rs
Normal file
@@ -0,0 +1,192 @@
|
||||
// src/dialog/logic.rs
|
||||
|
||||
use crossterm::event::{Event, KeyCode};
|
||||
use crate::config::binds::config::Config;
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
use crate::state::app::state::AppState;
|
||||
use crate::buffer::AppView;
|
||||
use crate::buffer::state::BufferState;
|
||||
use crate::modes::handlers::event::EventOutcome;
|
||||
use crate::tui::functions::common::{login, register};
|
||||
use crate::tui::functions::common::add_table::handle_delete_selected_columns;
|
||||
use crate::pages::routing::{Router, Page};
|
||||
use anyhow::Result;
|
||||
|
||||
/// Handles key events specifically when a dialog is active.
|
||||
/// Returns Some(Result<EventOutcome, Error>) if the event was handled (consumed),
|
||||
/// otherwise returns None.
|
||||
pub async fn handle_dialog_event(
|
||||
event: &Event,
|
||||
config: &Config,
|
||||
app_state: &mut AppState,
|
||||
buffer_state: &mut BufferState,
|
||||
router: &mut Router,
|
||||
) -> Option<Result<EventOutcome>> {
|
||||
if let Event::Key(key) = event {
|
||||
// Always allow Esc to dismiss
|
||||
if key.code == KeyCode::Esc {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Dialog dismissed".to_string())));
|
||||
}
|
||||
|
||||
// Check general bindings for dialog actions
|
||||
if let Some(action) = config.get_general_action(key.code, key.modifiers) {
|
||||
match action {
|
||||
"move_down" | "next_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let num_buttons = app_state.ui.dialog.dialog_buttons.len();
|
||||
if num_buttons > 0 && current_index < num_buttons - 1 {
|
||||
app_state.ui.dialog.dialog_active_button_index += 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"move_up" | "previous_option" => {
|
||||
let current_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
if current_index > 0 {
|
||||
app_state.ui.dialog.dialog_active_button_index -= 1;
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(String::new())));
|
||||
}
|
||||
"select" => {
|
||||
let selected_index = app_state.ui.dialog.dialog_active_button_index;
|
||||
let purpose = match app_state.ui.dialog.purpose {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Internal Error: Dialog context lost".to_string(),
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Dialog Actions Directly Here
|
||||
match purpose {
|
||||
DialogPurpose::LoginSuccess => match selected_index {
|
||||
0 => {
|
||||
// "Menu" button selected
|
||||
app_state.hide_dialog();
|
||||
if let Page::Login(state) = &mut router.current {
|
||||
let message =
|
||||
login::back_to_main(state, app_state, buffer_state).await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Login state not active".to_string(),
|
||||
)));
|
||||
}
|
||||
1 => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Exiting dialog".to_string())));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::LoginFailed => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Login failed dialog dismissed".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::RegisterSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button for RegisterSuccess
|
||||
app_state.hide_dialog();
|
||||
if let Page::Register(state) = &mut router.current {
|
||||
let message =
|
||||
register::back_to_login(state, app_state, buffer_state)
|
||||
.await;
|
||||
return Some(Ok(EventOutcome::Ok(message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Register state not active".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::RegisterFailed => match selected_index {
|
||||
0 => {
|
||||
// "OK" button for RegisterFailed
|
||||
app_state.hide_dialog(); // Just dismiss
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Register failed dialog dismissed".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => {
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Unknown dialog button selected".to_string(),
|
||||
)));
|
||||
}
|
||||
},
|
||||
DialogPurpose::ConfirmDeleteColumns => match selected_index {
|
||||
0 => {
|
||||
// "Confirm" button selected
|
||||
if let Page::Admin(state) = &mut router.current {
|
||||
let outcome_message =
|
||||
handle_delete_selected_columns(&mut state.add_table_state);
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok(outcome_message)));
|
||||
}
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Admin state not active".to_string(),
|
||||
)));
|
||||
}
|
||||
1 => {
|
||||
// "Cancel" button selected
|
||||
app_state.hide_dialog();
|
||||
return Some(Ok(EventOutcome::Ok("Deletion cancelled.".to_string())));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
DialogPurpose::SaveTableSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin); // Navigate back
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Save success dialog dismissed.".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
DialogPurpose::SaveLogicSuccess => match selected_index {
|
||||
0 => {
|
||||
// "OK" button selected
|
||||
app_state.hide_dialog();
|
||||
buffer_state.update_history(AppView::Admin);
|
||||
return Some(Ok(EventOutcome::Ok(
|
||||
"Save success dialog dismissed.".to_string(),
|
||||
)));
|
||||
}
|
||||
_ => { /* Handle unexpected index */ }
|
||||
},
|
||||
}
|
||||
}
|
||||
_ => {} // Ignore other general actions when dialog is shown
|
||||
}
|
||||
}
|
||||
// If it was a key event but not handled above, consume it
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
} else {
|
||||
// If it wasn't a key event, consume it too while dialog is active
|
||||
Some(Ok(EventOutcome::Ok(String::new())))
|
||||
}
|
||||
}
|
||||
10
client/src/dialog/mod.rs
Normal file
10
client/src/dialog/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/dialog/mod.rs
|
||||
|
||||
pub mod ui;
|
||||
pub mod logic;
|
||||
pub mod state;
|
||||
pub mod functions;
|
||||
|
||||
pub use ui::render_dialog;
|
||||
pub use logic::handle_dialog_event;
|
||||
pub use state::DialogState;
|
||||
26
client/src/dialog/state.rs
Normal file
26
client/src/dialog/state.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
// src/dialog/state.rs
|
||||
use crate::ui::handlers::context::DialogPurpose;
|
||||
|
||||
pub struct DialogState {
|
||||
pub dialog_show: bool,
|
||||
pub dialog_title: String,
|
||||
pub dialog_message: String,
|
||||
pub dialog_buttons: Vec<String>,
|
||||
pub dialog_active_button_index: usize,
|
||||
pub purpose: Option<DialogPurpose>,
|
||||
pub is_loading: bool,
|
||||
}
|
||||
|
||||
impl Default for DialogState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dialog_show: false,
|
||||
dialog_title: String::new(),
|
||||
dialog_message: String::new(),
|
||||
dialog_buttons: Vec::new(),
|
||||
dialog_active_button_index: 0,
|
||||
purpose: None,
|
||||
is_loading: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
185
client/src/dialog/ui.rs
Normal file
185
client/src/dialog/ui.rs
Normal file
@@ -0,0 +1,185 @@
|
||||
// src/dialog/ui.rs
|
||||
|
||||
use crate::config::colors::themes::Theme;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
prelude::Alignment,
|
||||
style::{Modifier, Style},
|
||||
text::{Line, Span, Text},
|
||||
widgets::{Block, BorderType, Borders, Paragraph, Clear},
|
||||
Frame,
|
||||
};
|
||||
use unicode_segmentation::UnicodeSegmentation; // For grapheme clusters
|
||||
use unicode_width::UnicodeWidthStr; // For accurate width calculation
|
||||
|
||||
pub fn render_dialog(
|
||||
f: &mut Frame,
|
||||
area: Rect,
|
||||
theme: &Theme,
|
||||
dialog_title: &str,
|
||||
dialog_message: &str,
|
||||
dialog_buttons: &[String],
|
||||
dialog_active_button_index: usize,
|
||||
is_loading: bool,
|
||||
) {
|
||||
// Calculate required height based on the actual number of lines in the message
|
||||
let message_lines: Vec<_> = dialog_message.lines().collect();
|
||||
let message_height = message_lines.len() as u16;
|
||||
let button_row_height = if dialog_buttons.is_empty() { 0 } else { 3 };
|
||||
let vertical_padding = 2; // Block borders (top/bottom)
|
||||
let inner_vertical_margin = 2; // Margin inside block (top/bottom)
|
||||
|
||||
// Calculate required height based on actual message lines
|
||||
let required_inner_height =
|
||||
message_height + button_row_height + inner_vertical_margin;
|
||||
let required_total_height = required_inner_height + vertical_padding;
|
||||
|
||||
// Use a fixed percentage width, clamped to min/max
|
||||
let width_percentage: u16 = 60;
|
||||
let dialog_width = (area.width * width_percentage / 100)
|
||||
.max(20) // Minimum width
|
||||
.min(area.width); // Maximum width
|
||||
|
||||
// Ensure height doesn't exceed available area
|
||||
let dialog_height = required_total_height.min(area.height);
|
||||
|
||||
// Calculate centered area manually
|
||||
let dialog_x = area.x + (area.width.saturating_sub(dialog_width)) / 2;
|
||||
let dialog_y = area.y + (area.height.saturating_sub(dialog_height)) / 2;
|
||||
let dialog_area = Rect::new(dialog_x, dialog_y, dialog_width, dialog_height);
|
||||
|
||||
// Clear the area first before drawing the dialog
|
||||
f.render_widget(Clear, dialog_area);
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Rounded)
|
||||
.border_style(Style::default().fg(theme.accent))
|
||||
.title(format!(" {} ", dialog_title)) // Add padding to title
|
||||
.style(Style::default().bg(theme.bg));
|
||||
|
||||
f.render_widget(block, dialog_area);
|
||||
|
||||
// Calculate inner area *after* defining the block
|
||||
let inner_area = dialog_area.inner(Margin {
|
||||
horizontal: 2, // Left/Right padding inside border
|
||||
vertical: 1, // Top/Bottom padding inside border
|
||||
});
|
||||
|
||||
if is_loading {
|
||||
// --- Loading State ---
|
||||
let loading_text = Paragraph::new(dialog_message) // Use the message passed for loading
|
||||
.style(Style::default().fg(theme.fg).add_modifier(Modifier::ITALIC))
|
||||
.alignment(Alignment::Center);
|
||||
// Render loading message centered in the inner area
|
||||
f.render_widget(loading_text, inner_area);
|
||||
} else {
|
||||
// --- Normal State (Message + Buttons) ---
|
||||
|
||||
// Layout for Message and Buttons based on actual message height
|
||||
let mut constraints = vec![
|
||||
// Allocate space for message, ensuring at least 1 line height
|
||||
Constraint::Length(message_height.max(1)), // Use actual calculated height
|
||||
];
|
||||
if button_row_height > 0 {
|
||||
constraints.push(Constraint::Length(button_row_height));
|
||||
}
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(constraints)
|
||||
.split(inner_area);
|
||||
|
||||
// Render Message
|
||||
let available_width = inner_area.width as usize;
|
||||
let ellipsis = "...";
|
||||
let ellipsis_width = UnicodeWidthStr::width(ellipsis);
|
||||
|
||||
let processed_lines: Vec<Line> = message_lines
|
||||
.into_iter()
|
||||
.map(|line| {
|
||||
let line_width = UnicodeWidthStr::width(line);
|
||||
if line_width > available_width {
|
||||
// Truncate with ellipsis
|
||||
let mut truncated_len = 0;
|
||||
let mut current_width = 0;
|
||||
for (idx, grapheme) in line.grapheme_indices(true) {
|
||||
let grapheme_width = UnicodeWidthStr::width(grapheme);
|
||||
if current_width + grapheme_width
|
||||
> available_width.saturating_sub(ellipsis_width)
|
||||
{
|
||||
break;
|
||||
}
|
||||
current_width += grapheme_width;
|
||||
truncated_len = idx + grapheme.len();
|
||||
}
|
||||
let truncated_line =
|
||||
format!("{}{}", &line[..truncated_len], ellipsis);
|
||||
Line::from(Span::styled(
|
||||
truncated_line,
|
||||
Style::default().fg(theme.fg),
|
||||
))
|
||||
} else {
|
||||
Line::from(Span::styled(line, Style::default().fg(theme.fg)))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let message_paragraph =
|
||||
Paragraph::new(Text::from(processed_lines)).alignment(Alignment::Center);
|
||||
f.render_widget(message_paragraph, chunks[0]); // Render message in the first chunk
|
||||
|
||||
// Render Buttons if they exist and there's a chunk for them
|
||||
if !dialog_buttons.is_empty() && chunks.len() > 1 {
|
||||
let button_area = chunks[1];
|
||||
let button_count = dialog_buttons.len();
|
||||
|
||||
let button_constraints = std::iter::repeat(Constraint::Ratio(
|
||||
1,
|
||||
button_count as u32,
|
||||
))
|
||||
.take(button_count)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let button_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints(button_constraints)
|
||||
.horizontal_margin(1) // Add space between buttons
|
||||
.split(button_area);
|
||||
|
||||
for (i, button_label) in dialog_buttons.iter().enumerate() {
|
||||
if i >= button_chunks.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let is_active = i == dialog_active_button_index;
|
||||
let (button_style, border_style) = if is_active {
|
||||
(
|
||||
Style::default()
|
||||
.fg(theme.highlight)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
Style::default().fg(theme.accent),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
Style::default().fg(theme.fg),
|
||||
Style::default().fg(theme.border),
|
||||
)
|
||||
};
|
||||
|
||||
let button_block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_type(BorderType::Plain)
|
||||
.border_style(border_style);
|
||||
|
||||
f.render_widget(
|
||||
Paragraph::new(button_label.as_str())
|
||||
.block(button_block)
|
||||
.style(button_style)
|
||||
.alignment(Alignment::Center),
|
||||
button_chunks[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user