* work * moar work * notes * work * separate to terminal and control channels * stdin working * serve html web client initial * serve static assets loaded with include_dir * merge * enable_web_server config parameter * compile time flag to disable web server capability * rustfmt * add license to all xterm.js assets * mouse working except copy/paste * helpful comment * web client improvements - move script to js file - add favicon - add nerd font - change title TODO: investigate if font license embedded in otf is sufficient * get mouse to work properly * kitty keyboard support initial * fix wrong type in preload link * wip axum websocket handlers - upgrade axum to v0.8.1, enable ws feature - begin setup of websocket handlers - tidy up imports * replace control listener * handle terminal websocket with axum * cleanup Cargo.toml * kitty fixes and bracketed paste * fix(mouse): pane not found crash * initial session switching infra * add `web_client_font` option * session switching, creation and resurrection working through the session manager * move session module to zellij-utils and share logic with web-client * some cleanups * require restart for enable-web-server * use session name from router * write config to disk and watch for config changes * rename session name to ipc path * add basic panic handler, make render_to_client exit on channel close * use while let instead of loop * handle websocket close * add mouse motions * make clipboard work * add weblink handling and webgl rendering * add todo * fix: use session name instead of patch on session switch * use "default" layout for new sessions * ui indication for session being shared * share this session ui * plugin assets * Fix process crash on mac with notify watcher. Use poll watcher instead of recommended as a workaround. * make url session switching and creation work * start welcome screen on root url * scaffold control messages, set font from config * set dimensions on session start * bring back session name from url * send bytes on terminal websocket instead of json - create web client os input and id before websocket connection * draft ui * work * refactor ui * remove otf font, remove margins to avoid scrollbar * version query endpoint for server status * web session info query endpoint * refactor: move stuff around * add web client info to session metadata * make tests pass * populate real data in session list * remove unnecessary endpoint * add web_client node to config, add font option * remove web_client_font * allow disabling the web session through the config - WIP * formalize sharing/not-sharing configuration * fix tests * allow shutting down web server * display error when web clients are forbidden to attach * only show sessions that allow web clients if this is a web client * style(fmt): rustfmt * fix: query web server from Zellij rather than from each plugin * remove log spam * handle some error paths better in the web client * allow controlling the web server through the cli * allow configuring the web server's ip/port * fix tests and format code * use direct WebServerStatus event instead of piggy-backing on SessionInfo * plugin revamp initial * make plugin responsive * adjust plugin title * refactor: share plugin * refactor: share plugin * add cors middleware * some fixes for running without a compiled web server capability * display error when starting the share plugin without web server support * clarify config * add pipelines to compile zellij without web support * display error when unable to start web server * only query web server when share plugin is running * refactor(web-client): connection table * give zellij_server_listener access to the control channel * fixes and clarifications * refactor: consolidate generate_unique_session_name * give proper error when trying to attach to a forbidden session * change browser URL when switching sessions * add keyboard shortcut * enforce https when bound to non-loopback ip * initial authentication token implementation * background color from theme * initial web client theme config * basic token generation ui * refactor set config message creation * also set body background * allow editing scrollback for plugins too * set scrollback to 0 * properly parse colors in config * generate token from plugin * nice login modals * initial token management screen * implement token authentication * refactor(share): token management screen * style(fmt): rustfmt * fix(plugin): some minor bugs * refactor(share): main screen * refactor(share): token screen * refactor(share): main * refactor(share): ui components * fix(responsiveness): properly send usage_width to the render function * fix cli commands and add some verbosity * add support for settings ansi and selection colors * add cursor and cursor accent * basic web client tests * fix tests * refactor: web client * use session tokens for authentication * improve modals * move shutdown to ipc * refactor: ipc logic * serialize theme config for web client * update tests * refactor: move some stuff around to prepare for config hot reload * config live reloading for the web clients * change remember-me UI wording * improve xterm.js link handling * make sure terminal is focused on mousemove * remove deprecated sharing indication from compact-bar * gate deps and functionality behind the web_server_compatibility feature * feat(build): add --no-web flag in all the places * fix some other build flows * add new assets * update CI for no-web (untested) * make more dependencies optional * update axum-extra * add web client configuration options * gracefully close connections on server exit * tests for graceful connection closing * handle client-side reconnect when server is down * fix: make sure ipc bus folder exists before starting * add commands to manage login tokens from the cli * style(fmt): rustfmt * some cleanups * fix(ux): allow alt-right-click on the web client without opening the context menu * fix: prevent attaching to welcome screen * fix: reload config issues * fix long socket path on macos * normalize config conversion and fix color gap in browser * revoke session_token cookie if it is not valid * fix: visual bug with multiple clients in extremely small screen sizes * fix: only include rusqlite for the web server capability builds * update e2e snapshots * refactor(web): client side js * some cleanups * moar cleanups * fix(tests): wait for server instead of using a fixed timeout * debug CI * fix(tests): use spawn_blocking for running the test web server * fix(tests): wait for http rather than tcp port * fix(tests): properly pass config path - hopefully this is the issue... * success! bring back the rest of the tests * attempt to fix the macos CI issue * docs(changelog): add PR --------- Co-authored-by: Thomas Linford <linford.t@gmail.com>
607 lines
21 KiB
Rust
607 lines
21 KiB
Rust
use zellij_tile::prelude::*;
|
|
|
|
struct ScreenContent {
|
|
title: (String, Text),
|
|
items: Vec<Vec<Text>>,
|
|
help: (String, Text),
|
|
status_message: Option<(String, Text)>,
|
|
max_width: usize,
|
|
new_token_item: Option<Vec<Text>>,
|
|
}
|
|
|
|
struct Layout {
|
|
base_x: usize,
|
|
base_y: usize,
|
|
title_x: usize,
|
|
help_y: usize,
|
|
status_y: usize,
|
|
}
|
|
|
|
struct ScrollInfo {
|
|
start_index: usize,
|
|
end_index: usize,
|
|
truncated_top: usize,
|
|
truncated_bottom: usize,
|
|
}
|
|
|
|
struct ColumnWidths {
|
|
token: usize,
|
|
date: usize,
|
|
controls: usize,
|
|
}
|
|
|
|
pub struct TokenManagementScreen<'a> {
|
|
token_list: &'a Vec<(String, String)>,
|
|
selected_list_index: Option<usize>,
|
|
renaming_token: &'a Option<String>,
|
|
entering_new_token_name: &'a Option<String>,
|
|
error: &'a Option<String>,
|
|
info: &'a Option<String>,
|
|
rows: usize,
|
|
cols: usize,
|
|
}
|
|
|
|
impl<'a> TokenManagementScreen<'a> {
|
|
pub fn new(
|
|
token_list: &'a Vec<(String, String)>,
|
|
selected_list_index: Option<usize>,
|
|
renaming_token: &'a Option<String>,
|
|
entering_new_token_name: &'a Option<String>,
|
|
error: &'a Option<String>,
|
|
info: &'a Option<String>,
|
|
rows: usize,
|
|
cols: usize,
|
|
) -> Self {
|
|
Self {
|
|
token_list,
|
|
selected_list_index,
|
|
renaming_token,
|
|
entering_new_token_name,
|
|
error,
|
|
info,
|
|
rows,
|
|
cols,
|
|
}
|
|
}
|
|
pub fn render(&self) {
|
|
let content = self.build_screen_content();
|
|
let max_height = self.calculate_max_item_height();
|
|
let scrolled_content = self.apply_scroll_truncation(content, max_height);
|
|
let layout = self.calculate_layout(&scrolled_content);
|
|
self.print_items_to_screen(scrolled_content, layout);
|
|
}
|
|
|
|
fn calculate_column_widths(&self) -> ColumnWidths {
|
|
let max_table_width = self.cols;
|
|
|
|
const MIN_TOKEN_WIDTH: usize = 10;
|
|
const MIN_DATE_WIDTH: usize = 10; // Minimum for just date "YYYY-MM-DD"
|
|
const MIN_CONTROLS_WIDTH: usize = 6; // Minimum for "(<x>, <r>)"
|
|
const COLUMN_SPACING: usize = 2; // Space between columns
|
|
|
|
let min_total_width =
|
|
MIN_TOKEN_WIDTH + MIN_DATE_WIDTH + MIN_CONTROLS_WIDTH + COLUMN_SPACING;
|
|
|
|
if max_table_width <= min_total_width {
|
|
return ColumnWidths {
|
|
token: MIN_TOKEN_WIDTH,
|
|
date: MIN_DATE_WIDTH,
|
|
controls: MIN_CONTROLS_WIDTH,
|
|
};
|
|
}
|
|
|
|
const PREFERRED_DATE_WIDTH: usize = 29; // "issued on YYYY-MM-DD HH:MM:SS"
|
|
const PREFERRED_CONTROLS_WIDTH: usize = 24; // "(<x> revoke, <r> rename)"
|
|
|
|
let available_width = max_table_width.saturating_sub(COLUMN_SPACING);
|
|
let preferred_fixed_width = PREFERRED_DATE_WIDTH + PREFERRED_CONTROLS_WIDTH;
|
|
|
|
if available_width >= preferred_fixed_width + MIN_TOKEN_WIDTH {
|
|
// We can use preferred widths for date and controls
|
|
ColumnWidths {
|
|
token: available_width.saturating_sub(preferred_fixed_width),
|
|
date: PREFERRED_DATE_WIDTH,
|
|
controls: PREFERRED_CONTROLS_WIDTH,
|
|
}
|
|
} else {
|
|
// Need to balance truncation across all columns
|
|
let remaining_width = available_width
|
|
.saturating_sub(MIN_TOKEN_WIDTH)
|
|
.saturating_sub(MIN_DATE_WIDTH)
|
|
.saturating_sub(MIN_CONTROLS_WIDTH);
|
|
let extra_per_column = remaining_width / 3;
|
|
|
|
ColumnWidths {
|
|
token: MIN_TOKEN_WIDTH + extra_per_column,
|
|
date: MIN_DATE_WIDTH + extra_per_column,
|
|
controls: MIN_CONTROLS_WIDTH + extra_per_column,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn truncate_token_name(&self, token: &str, max_width: usize) -> String {
|
|
if token.chars().count() <= max_width {
|
|
return token.to_string();
|
|
}
|
|
|
|
if max_width <= 6 {
|
|
// Too small to show anything meaningful
|
|
return "[...]".to_string();
|
|
}
|
|
|
|
let truncator = if max_width <= 10 { "[..]" } else { "[...]" };
|
|
let truncator_len = truncator.chars().count();
|
|
let remaining_chars = max_width.saturating_sub(truncator_len);
|
|
let start_chars = remaining_chars / 2;
|
|
let end_chars = remaining_chars.saturating_sub(start_chars);
|
|
|
|
let token_chars: Vec<char> = token.chars().collect();
|
|
let start_part: String = token_chars.iter().take(start_chars).collect();
|
|
let end_part: String = token_chars
|
|
.iter()
|
|
.rev()
|
|
.take(end_chars)
|
|
.collect::<String>()
|
|
.chars()
|
|
.rev()
|
|
.collect();
|
|
|
|
format!("{}{}{}", start_part, truncator, end_part)
|
|
}
|
|
|
|
fn format_date(
|
|
&self,
|
|
created_at: &str,
|
|
max_width: usize,
|
|
include_issued_prefix: bool,
|
|
) -> String {
|
|
let full_text = if include_issued_prefix {
|
|
format!("issued on {}", created_at)
|
|
} else {
|
|
created_at.to_string()
|
|
};
|
|
|
|
if full_text.chars().count() <= max_width {
|
|
return full_text;
|
|
}
|
|
|
|
// If we can't fit "issued on", use the date
|
|
if !include_issued_prefix || created_at.chars().count() <= max_width {
|
|
if created_at.chars().count() <= max_width {
|
|
return created_at.to_string();
|
|
}
|
|
|
|
// Truncate the date itself if needed
|
|
let chars: Vec<char> = created_at.chars().collect();
|
|
if max_width <= 3 {
|
|
return "...".to_string();
|
|
}
|
|
let truncated: String = chars.iter().take(max_width - 3).collect();
|
|
format!("{}...", truncated)
|
|
} else {
|
|
// Try without "issued on" prefix
|
|
self.format_date(created_at, max_width, false)
|
|
}
|
|
}
|
|
|
|
fn format_controls(&self, max_width: usize, is_selected: bool) -> String {
|
|
if !is_selected {
|
|
return " ".repeat(max_width);
|
|
}
|
|
|
|
let full_controls = "(<x> revoke, <r> rename)";
|
|
let short_controls = "(<x>, <r>)";
|
|
|
|
if full_controls.chars().count() <= max_width {
|
|
full_controls.to_string()
|
|
} else if short_controls.chars().count() <= max_width {
|
|
// Pad the short controls to fill the available width
|
|
let padding = max_width - short_controls.chars().count();
|
|
format!("{}{}", short_controls, " ".repeat(padding))
|
|
} else {
|
|
// Very constrained space
|
|
" ".repeat(max_width)
|
|
}
|
|
}
|
|
|
|
fn calculate_max_item_height(&self) -> usize {
|
|
// Calculate fixed UI elements that are always present:
|
|
// - 1 row for title
|
|
// - 1 row for spacing after title (always preserved)
|
|
// - 1 row for the "create new token" line (always visible)
|
|
// - 1 row for spacing before help (always preserved)
|
|
// - 1 row for help text (or status message - they're mutually exclusive)
|
|
|
|
let fixed_rows = 4; // title + spacing + help/status + spacing before help
|
|
let create_new_token_rows = 1; // "create new token" line
|
|
|
|
let total_fixed_rows = fixed_rows + create_new_token_rows;
|
|
|
|
// Calculate available rows for token items
|
|
let available_for_items = self.rows.saturating_sub(total_fixed_rows);
|
|
|
|
// Return at least 1 to avoid issues, but this will be the maximum height for token items only
|
|
available_for_items.max(1)
|
|
}
|
|
|
|
fn build_screen_content(&self) -> ScreenContent {
|
|
let mut max_width = 0;
|
|
let max_table_width = self.cols;
|
|
let column_widths = self.calculate_column_widths();
|
|
|
|
let title_text = "List of Login Tokens";
|
|
let title = Text::new(title_text).color_range(2, ..);
|
|
max_width = std::cmp::max(max_width, title_text.len());
|
|
|
|
let mut items = vec![];
|
|
for (i, (token, created_at)) in self.token_list.iter().enumerate() {
|
|
let is_selected = Some(i) == self.selected_list_index;
|
|
let (row_text, row_items) =
|
|
self.create_token_item(token, created_at, is_selected, &column_widths);
|
|
max_width = std::cmp::max(max_width, row_text.chars().count());
|
|
items.push(row_items);
|
|
}
|
|
|
|
let (new_token_text, new_token_row) = self.create_new_token_item(&column_widths);
|
|
max_width = std::cmp::max(max_width, new_token_text.chars().count());
|
|
|
|
let (help_text, help_line) = self.create_help_line();
|
|
max_width = std::cmp::max(max_width, help_text.chars().count());
|
|
|
|
let status_message = self.create_status_message();
|
|
if let Some((ref text, _)) = status_message {
|
|
max_width = std::cmp::max(max_width, text.chars().count());
|
|
}
|
|
|
|
max_width = std::cmp::min(max_width, max_table_width);
|
|
|
|
ScreenContent {
|
|
title: (title_text.to_string(), title),
|
|
items,
|
|
help: (help_text, help_line),
|
|
status_message,
|
|
max_width,
|
|
new_token_item: Some(new_token_row),
|
|
}
|
|
}
|
|
|
|
fn apply_scroll_truncation(
|
|
&self,
|
|
mut content: ScreenContent,
|
|
max_height: usize,
|
|
) -> ScreenContent {
|
|
let total_token_items = content.items.len(); // Only token items, not including "create new token"
|
|
|
|
// If all token items fit, no need to truncate
|
|
if total_token_items <= max_height {
|
|
return content;
|
|
}
|
|
|
|
let scroll_info = self.calculate_scroll_info(total_token_items, max_height);
|
|
|
|
// Extract the visible range
|
|
let mut visible_items: Vec<Vec<Text>> = content
|
|
.items
|
|
.into_iter()
|
|
.skip(scroll_info.start_index)
|
|
.take(
|
|
scroll_info
|
|
.end_index
|
|
.saturating_sub(scroll_info.start_index),
|
|
)
|
|
.collect();
|
|
|
|
// Add truncation indicators
|
|
if scroll_info.truncated_top > 0 {
|
|
self.add_truncation_indicator(&mut visible_items[0], scroll_info.truncated_top);
|
|
}
|
|
|
|
if scroll_info.truncated_bottom > 0 {
|
|
let last_idx = visible_items.len().saturating_sub(1);
|
|
self.add_truncation_indicator(
|
|
&mut visible_items[last_idx],
|
|
scroll_info.truncated_bottom,
|
|
);
|
|
}
|
|
|
|
content.items = visible_items;
|
|
content
|
|
}
|
|
|
|
fn calculate_scroll_info(&self, total_token_items: usize, max_height: usize) -> ScrollInfo {
|
|
// Only consider token items for scrolling (not the "create new token" line)
|
|
// The "create new token" line is always visible and handled separately
|
|
|
|
// Find the selected index within the token list only
|
|
let selected_index = if let Some(idx) = self.selected_list_index {
|
|
idx
|
|
} else {
|
|
// If "create new token" is selected or no selection,
|
|
// we don't need to center anything in the token list
|
|
0
|
|
};
|
|
|
|
// Calculate how many items to show above and below the selected item
|
|
let items_above = max_height / 2;
|
|
let items_below = max_height.saturating_sub(items_above).saturating_sub(1); // -1 for the selected item itself
|
|
|
|
// Calculate the start and end indices
|
|
let start_index = if selected_index < items_above {
|
|
0
|
|
} else if selected_index + items_below >= total_token_items {
|
|
total_token_items.saturating_sub(max_height)
|
|
} else {
|
|
selected_index.saturating_sub(items_above)
|
|
};
|
|
|
|
let end_index = std::cmp::min(start_index + max_height, total_token_items);
|
|
|
|
ScrollInfo {
|
|
start_index,
|
|
end_index,
|
|
truncated_top: start_index,
|
|
truncated_bottom: total_token_items.saturating_sub(end_index),
|
|
}
|
|
}
|
|
|
|
fn add_truncation_indicator(&self, row: &mut Vec<Text>, count: usize) {
|
|
let indicator = format!("+[{}]", count);
|
|
|
|
// Replace the last cell (controls column) with the truncation indicator
|
|
if let Some(last_cell) = row.last_mut() {
|
|
*last_cell = Text::new(&indicator).color_range(1, ..);
|
|
}
|
|
}
|
|
|
|
fn create_token_item(
|
|
&self,
|
|
token: &str,
|
|
created_at: &str,
|
|
is_selected: bool,
|
|
column_widths: &ColumnWidths,
|
|
) -> (String, Vec<Text>) {
|
|
if is_selected {
|
|
if let Some(new_name) = &self.renaming_token {
|
|
self.create_renaming_item(new_name, created_at, column_widths)
|
|
} else {
|
|
self.create_selected_item(token, created_at, column_widths)
|
|
}
|
|
} else {
|
|
self.create_regular_item(token, created_at, column_widths)
|
|
}
|
|
}
|
|
|
|
fn create_renaming_item(
|
|
&self,
|
|
new_name: &str,
|
|
created_at: &str,
|
|
column_widths: &ColumnWidths,
|
|
) -> (String, Vec<Text>) {
|
|
let truncated_name =
|
|
self.truncate_token_name(new_name, column_widths.token.saturating_sub(1)); // -1 for cursor
|
|
let item_text = format!("{}_", truncated_name);
|
|
let date_text = self.format_date(created_at, column_widths.date, true);
|
|
let controls_text = " ".repeat(column_widths.controls);
|
|
|
|
let token_end = truncated_name.chars().count();
|
|
let items = vec![
|
|
Text::new(&item_text)
|
|
.color_range(0, ..token_end + 1)
|
|
.selected(),
|
|
Text::new(&date_text),
|
|
Text::new(&controls_text),
|
|
];
|
|
(
|
|
format!("{} {} {}", item_text, date_text, controls_text),
|
|
items,
|
|
)
|
|
}
|
|
|
|
fn create_selected_item(
|
|
&self,
|
|
token: &str,
|
|
created_at: &str,
|
|
column_widths: &ColumnWidths,
|
|
) -> (String, Vec<Text>) {
|
|
let mut item_text = self.truncate_token_name(token, column_widths.token);
|
|
if item_text.is_empty() {
|
|
// otherwise the table gets messed up
|
|
item_text.push(' ');
|
|
};
|
|
let date_text = self.format_date(created_at, column_widths.date, true);
|
|
let controls_text = self.format_controls(column_widths.controls, true);
|
|
|
|
// Determine highlight ranges for controls based on the actual content
|
|
let (x_range, r_range) = if controls_text.contains("revoke") {
|
|
// Full controls: "(<x> revoke, <r> rename)"
|
|
(1..=3, 13..=15)
|
|
} else {
|
|
// Short controls: "(<x>, <r>)"
|
|
(1..=3, 6..=8)
|
|
};
|
|
|
|
let controls_colored = if controls_text.trim().is_empty() {
|
|
Text::new(&controls_text).selected()
|
|
} else {
|
|
Text::new(&controls_text)
|
|
.color_range(3, x_range)
|
|
.color_range(3, r_range)
|
|
.selected()
|
|
};
|
|
|
|
let items = vec![
|
|
Text::new(&item_text).color_range(0, ..).selected(),
|
|
Text::new(&date_text).selected(),
|
|
controls_colored,
|
|
];
|
|
|
|
(
|
|
format!("{} {} {}", item_text, date_text, controls_text),
|
|
items,
|
|
)
|
|
}
|
|
|
|
fn create_regular_item(
|
|
&self,
|
|
token: &str,
|
|
created_at: &str,
|
|
column_widths: &ColumnWidths,
|
|
) -> (String, Vec<Text>) {
|
|
let mut item_text = self.truncate_token_name(token, column_widths.token);
|
|
if item_text.is_empty() {
|
|
// otherwise the table gets messed up
|
|
item_text.push(' ');
|
|
};
|
|
let date_text = self.format_date(created_at, column_widths.date, true);
|
|
let controls_text = " ".repeat(column_widths.controls);
|
|
|
|
let items = vec![
|
|
Text::new(&item_text).color_range(0, ..),
|
|
Text::new(&date_text),
|
|
Text::new(&controls_text),
|
|
];
|
|
(
|
|
format!("{} {} {}", item_text, date_text, controls_text),
|
|
items,
|
|
)
|
|
}
|
|
|
|
fn create_new_token_item(&self, column_widths: &ColumnWidths) -> (String, Vec<Text>) {
|
|
let create_new_token_text = "<n> - create new token".to_string();
|
|
let short_create_text = "<n> - new".to_string();
|
|
let date_placeholder = " ".repeat(column_widths.date);
|
|
let controls_placeholder = " ".repeat(column_widths.controls);
|
|
|
|
if let Some(name) = &self.entering_new_token_name {
|
|
let truncated_name =
|
|
self.truncate_token_name(name, column_widths.token.saturating_sub(1)); // -1 for cursor
|
|
let text = format!("{}_", truncated_name);
|
|
let item = vec![
|
|
Text::new(&text).color_range(3, ..),
|
|
Text::new(&date_placeholder),
|
|
Text::new(&controls_placeholder),
|
|
];
|
|
(
|
|
format!("{} {} {}", text, date_placeholder, controls_placeholder),
|
|
item,
|
|
)
|
|
} else {
|
|
// Check if the full text fits, otherwise use the short version
|
|
let text_to_use = if create_new_token_text.chars().count() <= column_widths.token {
|
|
&create_new_token_text
|
|
} else {
|
|
&short_create_text
|
|
};
|
|
|
|
let item = vec![
|
|
Text::new(text_to_use).color_range(3, 0..=2),
|
|
Text::new(&date_placeholder),
|
|
Text::new(&controls_placeholder),
|
|
];
|
|
(
|
|
format!(
|
|
"{} {} {}",
|
|
text_to_use, date_placeholder, controls_placeholder
|
|
),
|
|
item,
|
|
)
|
|
}
|
|
}
|
|
|
|
fn create_help_line(&self) -> (String, Text) {
|
|
let (text, highlight_range) = if self.entering_new_token_name.is_some() {
|
|
(
|
|
"Help: Enter optional name for new token, <Enter> to submit",
|
|
41..=47,
|
|
)
|
|
} else if self.renaming_token.is_some() {
|
|
(
|
|
"Help: Enter new name for this token, <Enter> to submit",
|
|
39..=45,
|
|
)
|
|
} else {
|
|
(
|
|
"Help: <Ctrl x> - revoke all tokens, <Esc> - go back",
|
|
6..=13,
|
|
)
|
|
};
|
|
|
|
let mut help_line = Text::new(text).color_range(3, highlight_range);
|
|
|
|
// Add second highlight for the back option
|
|
if self.entering_new_token_name.is_none() && self.renaming_token.is_none() {
|
|
help_line = help_line.color_range(3, 36..=40);
|
|
}
|
|
|
|
(text.to_string(), help_line)
|
|
}
|
|
|
|
fn create_status_message(&self) -> Option<(String, Text)> {
|
|
if let Some(error) = &self.error {
|
|
Some((error.clone(), Text::new(error).color_range(3, ..)))
|
|
} else if let Some(info) = &self.info {
|
|
Some((info.clone(), Text::new(info).color_range(1, ..)))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn calculate_layout(&self, content: &ScreenContent) -> Layout {
|
|
// Calculate fixed UI elements that must always be present:
|
|
// - 1 row for title
|
|
// - 1 row for spacing after title (always preserved)
|
|
// - token items (variable, potentially truncated)
|
|
// - 1 row for "create new token" line
|
|
// - 1 row for spacing before help (always preserved)
|
|
// - 1 row for help text OR status message (mutually exclusive now)
|
|
|
|
let fixed_ui_rows = 4; // title + spacing after title + spacing before help + help/status
|
|
let create_new_token_rows = 1;
|
|
let token_item_rows = content.items.len();
|
|
|
|
let total_content_rows = fixed_ui_rows + create_new_token_rows + token_item_rows;
|
|
|
|
// Only add top/bottom padding if we have extra space
|
|
let base_y = if total_content_rows < self.rows {
|
|
// We have room for padding - center the content
|
|
(self.rows.saturating_sub(total_content_rows)) / 2
|
|
} else {
|
|
// No room for padding - start at the top
|
|
0
|
|
};
|
|
|
|
// Calculate positions relative to base_y
|
|
let item_start_y = base_y + 2; // title + spacing after title
|
|
let new_token_y = item_start_y + token_item_rows;
|
|
let help_y = new_token_y + 1 + 1; // new token line + spacing before help
|
|
|
|
Layout {
|
|
base_x: (self.cols.saturating_sub(content.max_width) as f64 / 2.0).floor() as usize,
|
|
base_y,
|
|
title_x: self.cols.saturating_sub(content.title.0.len()) / 2,
|
|
help_y,
|
|
status_y: help_y, // Status message uses the same position as help
|
|
}
|
|
}
|
|
|
|
fn print_items_to_screen(&self, content: ScreenContent, layout: Layout) {
|
|
print_text_with_coordinates(content.title.1, layout.title_x, layout.base_y, None, None);
|
|
|
|
let mut table = Table::new().add_row(vec![" ", " ", " "]);
|
|
for item in content.items.into_iter() {
|
|
table = table.add_styled_row(item);
|
|
}
|
|
|
|
if let Some(new_token_item) = content.new_token_item {
|
|
table = table.add_styled_row(new_token_item);
|
|
}
|
|
|
|
print_table_with_coordinates(table, layout.base_x, layout.base_y + 1, None, None);
|
|
|
|
if let Some((_, status_text)) = content.status_message {
|
|
print_text_with_coordinates(status_text, layout.base_x, layout.status_y, None, None);
|
|
} else {
|
|
print_text_with_coordinates(content.help.1, layout.base_x, layout.help_y, None, None);
|
|
}
|
|
}
|
|
}
|