diff options
| author | Aram Drevekenin <aram@poor.dev> | 2025-06-23 19:19:37 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2025-06-23 19:19:37 +0200 |
| commit | c5ac7968809ddb1033b625562eae22019cccc2f4 (patch) | |
| tree | 932cf07d898dfbcc6ec6a9d4cf5353c06a47accc /default-plugins/share | |
| parent | feat(ui): add optional tooltip with key hints to the compact bar (#4225) (diff) | |
| download | sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.gz sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.tar.xz sl0ppij-c5ac7968809ddb1033b625562eae22019cccc2f4.zip | |
Feature: web-client/server to share your sessions in the browser (#4242)
* 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>
Diffstat (limited to 'default-plugins/share')
| -rw-r--r-- | default-plugins/share/.cargo/config.toml | 2 | ||||
| -rw-r--r-- | default-plugins/share/.gitignore | 1 | ||||
| -rw-r--r-- | default-plugins/share/Cargo.toml | 11 | ||||
| l--------- | default-plugins/share/LICENSE.md | 1 | ||||
| -rw-r--r-- | default-plugins/share/src/main.rs | 647 | ||||
| -rw-r--r-- | default-plugins/share/src/main_screen.rs | 301 | ||||
| -rw-r--r-- | default-plugins/share/src/token_management_screen.rs | 607 | ||||
| -rw-r--r-- | default-plugins/share/src/token_screen.rs | 192 | ||||
| -rw-r--r-- | default-plugins/share/src/ui_components.rs | 637 |
9 files changed, 2399 insertions, 0 deletions
diff --git a/default-plugins/share/.cargo/config.toml b/default-plugins/share/.cargo/config.toml new file mode 100644 index 00000000..6b509f5b --- /dev/null +++ b/default-plugins/share/.cargo/config.toml @@ -0,0 +1,2 @@ +[build] +target = "wasm32-wasip1" diff --git a/default-plugins/share/.gitignore b/default-plugins/share/.gitignore new file mode 100644 index 00000000..ea8c4bf7 --- /dev/null +++ b/default-plugins/share/.gitignore @@ -0,0 +1 @@ +/target diff --git a/default-plugins/share/Cargo.toml b/default-plugins/share/Cargo.toml new file mode 100644 index 00000000..e89b623c --- /dev/null +++ b/default-plugins/share/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "share" +version = "0.1.0" +authors = ["Aram Drevekenin <aram@poor.dev>"] +edition = "2021" +license = "MIT" + +[dependencies] +zellij-tile = { path = "../../zellij-tile" } +url = "2.0" +rand = "0.9.0" diff --git a/default-plugins/share/LICENSE.md b/default-plugins/share/LICENSE.md new file mode 120000 index 00000000..f0608a63 --- /dev/null +++ b/default-plugins/share/LICENSE.md @@ -0,0 +1 @@ +../../LICENSE.md
\ No newline at end of file diff --git a/default-plugins/share/src/main.rs b/default-plugins/share/src/main.rs new file mode 100644 index 00000000..1708e495 --- /dev/null +++ b/default-plugins/share/src/main.rs @@ -0,0 +1,647 @@ +mod main_screen; +mod token_management_screen; +mod token_screen; +mod ui_components; + +use std::net::IpAddr; +use zellij_tile::prelude::*; + +use std::collections::{BTreeMap, HashMap}; + +use main_screen::MainScreen; +use token_management_screen::TokenManagementScreen; +use token_screen::TokenScreen; + +static WEB_SERVER_QUERY_DURATION: f64 = 0.4; // Doherty threshold + +#[derive(Debug, Default)] +struct App { + web_server: WebServerState, + ui: UIState, + tokens: TokenManager, + state: AppState, +} + +register_plugin!(App); + +impl ZellijPlugin for App { + fn load(&mut self, _configuration: BTreeMap<String, String>) { + self.initialize(); + } + + fn update(&mut self, event: Event) -> bool { + if !self.web_server.capability && !matches!(event, Event::ModeUpdate(_)) { + return false; + } + + match event { + Event::Timer(_) => self.handle_timer(), + Event::ModeUpdate(mode_info) => self.handle_mode_update(mode_info), + Event::WebServerStatus(status) => self.handle_web_server_status(status), + Event::Key(key) => self.handle_key_input(key), + Event::Mouse(mouse_event) => self.handle_mouse_event(mouse_event), + Event::RunCommandResult(exit_code, _stdout, _stderr, context) => { + self.handle_command_result(exit_code, context) + }, + Event::FailedToStartWebServer(error) => self.handle_web_server_error(error), + _ => false, + } + } + + fn render(&mut self, rows: usize, cols: usize) { + if !self.web_server.capability { + self.render_no_capability_message(rows, cols); + return; + } + + self.ui.reset_render_state(); + match &self.state.current_screen { + Screen::Main => self.render_main_screen(rows, cols), + Screen::Token(token) => self.render_token_screen(rows, cols, token), + Screen::ManageTokens => self.render_manage_tokens_screen(rows, cols), + } + } +} + +impl App { + fn initialize(&mut self) { + self.subscribe_to_events(); + self.state.own_plugin_id = Some(get_plugin_ids().plugin_id); + self.retrieve_token_list(); + self.query_link_executable(); + self.set_plugin_title(); + } + + fn subscribe_to_events(&self) { + subscribe(&[ + EventType::Key, + EventType::ModeUpdate, + EventType::WebServerStatus, + EventType::Mouse, + EventType::RunCommandResult, + EventType::FailedToStartWebServer, + EventType::Timer, + ]); + } + + fn set_plugin_title(&self) { + if let Some(plugin_id) = self.state.own_plugin_id { + rename_plugin_pane(plugin_id, "Share Session"); + } + } + + fn handle_timer(&mut self) -> bool { + query_web_server_status(); + self.retrieve_token_list(); + set_timeout(WEB_SERVER_QUERY_DURATION); + false + } + + fn handle_mode_update(&mut self, mode_info: ModeInfo) -> bool { + let mut should_render = false; + + self.state.session_name = mode_info.session_name; + + if let Some(web_clients_allowed) = mode_info.web_clients_allowed { + self.web_server.clients_allowed = web_clients_allowed; + should_render = true; + } + + if let Some(web_sharing) = mode_info.web_sharing { + self.web_server.sharing = web_sharing; + should_render = true; + } + + if let Some(web_server_ip) = mode_info.web_server_ip { + self.web_server.ip = Some(web_server_ip); + should_render = true; + } + + if let Some(web_server_port) = mode_info.web_server_port { + self.web_server.port = Some(web_server_port); + should_render = true; + } + + if let Some(web_server_capability) = mode_info.web_server_capability { + self.web_server.capability = web_server_capability; + if self.web_server.capability && !self.state.timer_running { + self.state.timer_running = true; + set_timeout(WEB_SERVER_QUERY_DURATION); + } + should_render = true; + } + + should_render + } + + fn handle_web_server_status(&mut self, status: WebServerStatus) -> bool { + match status { + WebServerStatus::Online(base_url) => { + self.web_server.base_url = base_url; + self.web_server.started = true; + self.web_server.different_version_error = None; + }, + WebServerStatus::Offline => { + self.web_server.started = false; + self.web_server.different_version_error = None; + }, + WebServerStatus::DifferentVersion(version) => { + self.web_server.started = false; + self.web_server.different_version_error = Some(version); + }, + } + true + } + + fn handle_key_input(&mut self, key: KeyWithModifier) -> bool { + if self.clear_error_or_info() { + return true; + } + + match self.state.current_screen { + Screen::Main => self.handle_main_screen_keys(key), + Screen::Token(_) => self.handle_token_screen_keys(key), + Screen::ManageTokens => self.handle_manage_tokens_keys(key), + } + } + + fn clear_error_or_info(&mut self) -> bool { + self.web_server.error.take().is_some() || self.state.info.take().is_some() + } + + fn handle_main_screen_keys(&mut self, key: KeyWithModifier) -> bool { + match key.bare_key { + BareKey::Enter if key.has_no_modifiers() && !self.web_server.started => { + start_web_server(); + false + }, + BareKey::Char('c') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + stop_web_server(); + false + }, + BareKey::Char(' ') if key.has_no_modifiers() => { + self.toggle_session_sharing(); + false + }, + BareKey::Char('t') if key.has_no_modifiers() => { + self.handle_token_action(); + true + }, + BareKey::Esc if key.has_no_modifiers() => { + close_self(); + false + }, + _ => false, + } + } + + fn toggle_session_sharing(&self) { + match self.web_server.sharing { + WebSharing::Disabled => {}, + WebSharing::On => stop_sharing_current_session(), + WebSharing::Off => share_current_session(), + } + } + + fn handle_token_action(&mut self) { + if self.tokens.list.is_empty() { + self.generate_new_token(None); + } else { + self.change_to_manage_tokens_screen(); + } + } + + fn handle_token_screen_keys(&mut self, key: KeyWithModifier) -> bool { + match key.bare_key { + BareKey::Esc if key.has_no_modifiers() => { + self.change_to_previous_screen(); + true + }, + _ => false, + } + } + + fn handle_manage_tokens_keys(&mut self, key: KeyWithModifier) -> bool { + if self.tokens.handle_text_input(&key) { + return true; + } + + match key.bare_key { + BareKey::Esc if key.has_no_modifiers() => self.handle_escape_key(), + BareKey::Down if key.has_no_modifiers() => self.tokens.navigate_down(), + BareKey::Up if key.has_no_modifiers() => self.tokens.navigate_up(), + BareKey::Char('n') if key.has_no_modifiers() => { + self.tokens.start_new_token_input(); + true + }, + BareKey::Enter if key.has_no_modifiers() => self.handle_enter_key(), + BareKey::Char('r') if key.has_no_modifiers() => { + self.tokens.start_rename_input(); + true + }, + BareKey::Char('x') if key.has_no_modifiers() => self.revoke_selected_token(), + BareKey::Char('x') if key.has_modifiers(&[KeyModifier::Ctrl]) => { + self.revoke_all_tokens(); + true + }, + _ => false, + } + } + + fn handle_escape_key(&mut self) -> bool { + let was_editing = self.tokens.cancel_input(); + + if !was_editing { + self.change_to_main_screen(); + } + true + } + + fn handle_enter_key(&mut self) -> bool { + if let Some(token_name) = self.tokens.finish_new_token_input() { + self.generate_new_token(token_name); + return true; + } + + if let Some(new_name) = self.tokens.finish_rename_input() { + self.rename_current_token(new_name); + return true; + } + + false + } + + fn generate_new_token(&mut self, name: Option<String>) { + match generate_web_login_token(name) { + Ok(token) => self.change_to_token_screen(token), + Err(e) => self.web_server.error = Some(e), + } + } + + fn rename_current_token(&mut self, new_name: String) { + if let Some(current_token) = self.tokens.get_selected_token() { + match rename_web_token(¤t_token.0, &new_name) { + Ok(_) => { + self.retrieve_token_list(); + if self.tokens.adjust_selection_after_list_change() { + self.change_to_main_screen(); + } + }, + Err(e) => self.web_server.error = Some(e), + } + } + } + + fn revoke_selected_token(&mut self) -> bool { + if let Some(token) = self.tokens.get_selected_token() { + match revoke_web_login_token(&token.0) { + Ok(_) => { + self.retrieve_token_list(); + if self.tokens.adjust_selection_after_list_change() { + self.change_to_main_screen(); + } + self.state.info = Some("Revoked. Connected clients not affected.".to_owned()); + }, + Err(e) => self.web_server.error = Some(e), + } + return true; + } + false + } + + fn revoke_all_tokens(&mut self) { + match revoke_all_web_tokens() { + Ok(_) => { + self.retrieve_token_list(); + if self.tokens.adjust_selection_after_list_change() { + self.change_to_main_screen(); + } + self.state.info = Some("Revoked. Connected clients not affected.".to_owned()); + }, + Err(e) => self.web_server.error = Some(e), + } + } + + fn handle_mouse_event(&mut self, event: Mouse) -> bool { + match event { + Mouse::LeftClick(line, column) => self.handle_link_click(line, column), + Mouse::Hover(line, column) => { + self.ui.hover_coordinates = Some((column, line as usize)); + true + }, + _ => false, + } + } + + fn handle_link_click(&mut self, line: isize, column: usize) -> bool { + for (coordinates, url) in &self.ui.clickable_urls { + if coordinates.contains(column, line as usize) { + if let Some(executable) = self.ui.link_executable { + run_command(&[executable, url], Default::default()); + } + return true; + } + } + false + } + + fn handle_command_result( + &mut self, + exit_code: Option<i32>, + context: BTreeMap<String, String>, + ) -> bool { + if context.contains_key("xdg_open_cli") && exit_code == Some(0) { + self.ui.link_executable = Some("xdg-open"); + } else if context.contains_key("open_cli") && exit_code == Some(0) { + self.ui.link_executable = Some("open"); + } + false + } + + fn handle_web_server_error(&mut self, error: String) -> bool { + self.web_server.error = Some(error); + true + } + + fn query_link_executable(&self) { + let mut xdg_context = BTreeMap::new(); + xdg_context.insert("xdg_open_cli".to_owned(), String::new()); + run_command(&["xdg-open", "--help"], xdg_context); + + let mut open_context = BTreeMap::new(); + open_context.insert("open_cli".to_owned(), String::new()); + run_command(&["open", "--help"], open_context); + } + + fn render_no_capability_message(&self, rows: usize, cols: usize) { + let full_text = "This version of Zellij was compiled without web sharing capabilities"; + let short_text = "No web server capabilities"; + let text = if cols >= full_text.chars().count() { + full_text + } else { + short_text + }; + + let text_element = Text::new(text).color_range(3, ..); + let text_x = cols.saturating_sub(text.chars().count()) / 2; + let text_y = rows / 2; + print_text_with_coordinates(text_element, text_x, text_y, None, None); + } + + fn change_to_token_screen(&mut self, token: String) { + self.retrieve_token_list(); + set_self_mouse_selection_support(true); + self.state.previous_screen = Some(self.state.current_screen.clone()); + self.state.current_screen = Screen::Token(token); + } + + fn change_to_manage_tokens_screen(&mut self) { + self.retrieve_token_list(); + set_self_mouse_selection_support(false); + self.tokens.selected_index = Some(0); + self.state.previous_screen = None; + self.state.current_screen = Screen::ManageTokens; + } + + fn change_to_main_screen(&mut self) { + self.retrieve_token_list(); + set_self_mouse_selection_support(false); + self.state.previous_screen = None; + self.state.current_screen = Screen::Main; + } + + fn change_to_previous_screen(&mut self) { + self.retrieve_token_list(); + match self.state.previous_screen.take() { + Some(Screen::ManageTokens) => self.change_to_manage_tokens_screen(), + _ => self.change_to_main_screen(), + } + } + + fn render_main_screen(&mut self, rows: usize, cols: usize) { + let state_changes = MainScreen::new( + self.tokens.list.is_empty(), + self.web_server.started, + &self.web_server.error, + &self.web_server.different_version_error, + &self.web_server.base_url, + self.web_server.ip, + self.web_server.port, + &self.state.session_name, + self.web_server.sharing, + self.ui.hover_coordinates, + &self.state.info, + &self.ui.link_executable, + ) + .render(rows, cols); + + self.ui.currently_hovering_over_link = state_changes.currently_hovering_over_link; + self.ui.currently_hovering_over_unencrypted = + state_changes.currently_hovering_over_unencrypted; + self.ui.clickable_urls = state_changes.clickable_urls; + } + + fn render_token_screen(&self, rows: usize, cols: usize, token: &str) { + let token_screen = + TokenScreen::new(token.to_string(), self.web_server.error.clone(), rows, cols); + token_screen.render(); + } + + fn render_manage_tokens_screen(&self, rows: usize, cols: usize) { + TokenManagementScreen::new( + &self.tokens.list, + self.tokens.selected_index, + &self.tokens.renaming_token, + &self.tokens.entering_new_name, + &self.web_server.error, + &self.state.info, + rows, + cols, + ) + .render(); + } + + fn retrieve_token_list(&mut self) { + if let Err(e) = self.tokens.retrieve_list() { + self.web_server.error = Some(e); + } + } +} + +#[derive(Debug, Default)] +struct WebServerState { + started: bool, + sharing: WebSharing, + clients_allowed: bool, + error: Option<String>, + different_version_error: Option<String>, + ip: Option<IpAddr>, + port: Option<u16>, + base_url: String, + capability: bool, +} + +#[derive(Debug, Default)] +struct UIState { + hover_coordinates: Option<(usize, usize)>, + clickable_urls: HashMap<CoordinatesInLine, String>, + link_executable: Option<&'static str>, + currently_hovering_over_link: bool, + currently_hovering_over_unencrypted: bool, +} + +impl UIState { + fn reset_render_state(&mut self) { + self.currently_hovering_over_link = false; + self.clickable_urls.clear(); + } +} + +#[derive(Debug, Default)] +struct TokenManager { + list: Vec<(String, String)>, + selected_index: Option<usize>, + entering_new_name: Option<String>, + renaming_token: Option<String>, +} + +impl TokenManager { + fn retrieve_list(&mut self) -> Result<(), String> { + match list_web_login_tokens() { + Ok(tokens) => { + self.list = tokens; + Ok(()) + }, + Err(e) => Err(format!("Failed to retrieve login tokens: {}", e)), + } + } + + fn get_selected_token(&self) -> Option<&(String, String)> { + self.selected_index.and_then(|i| self.list.get(i)) + } + + fn adjust_selection_after_list_change(&mut self) -> bool { + if self.list.is_empty() { + self.selected_index = None; + true // indicates should change to main screen + } else if self.selected_index >= Some(self.list.len()) { + self.selected_index = Some(self.list.len().saturating_sub(1)); + false + } else { + false + } + } + + fn navigate_down(&mut self) -> bool { + if let Some(ref mut index) = self.selected_index { + *index = if *index < self.list.len().saturating_sub(1) { + *index + 1 + } else { + 0 + }; + return true; + } + false + } + + fn navigate_up(&mut self) -> bool { + if let Some(ref mut index) = self.selected_index { + *index = if *index == 0 { + self.list.len().saturating_sub(1) + } else { + *index - 1 + }; + return true; + } + false + } + + fn start_new_token_input(&mut self) { + self.entering_new_name = Some(String::new()); + } + + fn start_rename_input(&mut self) { + self.renaming_token = Some(String::new()); + } + + fn handle_text_input(&mut self, key: &KeyWithModifier) -> bool { + match key.bare_key { + BareKey::Char(c) if key.has_no_modifiers() => { + if let Some(ref mut name) = self.entering_new_name { + name.push(c); + return true; + } + if let Some(ref mut name) = self.renaming_token { + name.push(c); + return true; + } + }, + BareKey::Backspace if key.has_no_modifiers() => { + if let Some(ref mut name) = self.entering_new_name { + name.pop(); + return true; + } + if let Some(ref mut name) = self.renaming_token { + name.pop(); + return true; + } + }, + _ => {}, + } + false + } + + fn finish_new_token_input(&mut self) -> Option<Option<String>> { + self.entering_new_name + .take() + .map(|name| if name.is_empty() { None } else { Some(name) }) + } + + fn finish_rename_input(&mut self) -> Option<String> { + self.renaming_token.take() + } + + fn cancel_input(&mut self) -> bool { + self.entering_new_name.take().is_some() || self.renaming_token.take().is_some() + } +} + +#[derive(Debug, Default)] +struct AppState { + session_name: Option<String>, + own_plugin_id: Option<u32>, + timer_running: bool, + current_screen: Screen, + previous_screen: Option<Screen>, + info: Option<String>, +} + +#[derive(Debug, Clone)] +enum Screen { + Main, + Token(String), + ManageTokens, +} + +impl Default for Screen { + fn default() -> Self { + Screen::Main + } +} + +#[derive(Debug, PartialEq, Eq, Hash)] +pub struct CoordinatesInLine { + x: usize, + y: usize, + width: usize, +} + +impl CoordinatesInLine { + pub fn new(x: usize, y: usize, width: usize) -> Self { + CoordinatesInLine { x, y, width } + } + + pub fn contains(&self, x: usize, y: usize) -> bool { + x >= self.x && x <= self.x + self.width && self.y == y + } +} diff --git a/default-plugins/share/src/main_screen.rs b/default-plugins/share/src/main_screen.rs new file mode 100644 index 00000000..b1222592 --- /dev/null +++ b/default-plugins/share/src/main_screen.rs @@ -0,0 +1,301 @@ +use super::CoordinatesInLine; +use crate::ui_components::{ + hovering_on_line, render_text_with_underline, CurrentSessionSection, Usage, + WebServerStatusSection, +}; +use zellij_tile::prelude::*; + +use std::collections::HashMap; +use url::Url; + +pub struct MainScreenState { + pub currently_hovering_over_link: bool, + pub currently_hovering_over_unencrypted: bool, + pub clickable_urls: HashMap<CoordinatesInLine, String>, +} + +pub struct MainScreen<'a> { + token_list_is_empty: bool, + web_server_started: bool, + web_server_error: &'a Option<String>, + web_server_different_version_error: &'a Option<String>, + web_server_base_url: &'a String, + web_server_ip: Option<std::net::IpAddr>, + web_server_port: Option<u16>, + session_name: &'a Option<String>, + web_sharing: WebSharing, + hover_coordinates: Option<(usize, usize)>, + info: &'a Option<String>, + link_executable: &'a Option<&'a str>, +} + +impl<'a> MainScreen<'a> { + const TITLE_TEXT: &'static str = "Share Session Locally in the Browser"; + const WARNING_TEXT: &'static str = + "[*] Connection unencrypted. Consider using an SSL certificate."; + const MORE_INFO_TEXT: &'static str = "More info: "; + const SSL_URL: &'static str = "https://zellij.dev/documentation/web-server-ssl"; + const HELP_TEXT_WITH_CLICK: &'static str = "Help: Click or Shift-Click to open in browser"; + const HELP_TEXT_SHIFT_ONLY: &'static str = "Help: Shift-Click to open in browser"; + pub fn new( + token_list_is_empty: bool, + web_server_started: bool, + web_server_error: &'a Option<String>, + web_server_different_version_error: &'a Option<String>, + web_server_base_url: &'a String, + web_server_ip: Option<std::net::IpAddr>, + web_server_port: Option<u16>, + session_name: &'a Option<String>, + web_sharing: WebSharing, + hover_coordinates: Option<(usize, usize)>, + info: &'a Option<String>, + link_executable: &'a Option<&'a str>, + ) -> Self { + Self { + token_list_is_empty, + web_server_started, + web_server_error, + web_server_different_version_error, + web_server_base_url, + web_server_ip, + web_server_port, + session_name, + web_sharing, + hover_coordinates, + info, + link_executable, + } + } + + pub fn render(self, rows: usize, cols: usize) -> MainScreenState { + let mut state = MainScreenState { + currently_hovering_over_link: false, + currently_hovering_over_unencrypted: false, + clickable_urls: HashMap::new(), + }; + + let layout = self.calculate_layout(rows, cols); + self.render_content(&layout, &mut state); + + state + } + + fn calculate_layout(&self, rows: usize, cols: usize) -> Layout { + let usage = Usage::new(!self.token_list_is_empty); + let web_server_status_section = WebServerStatusSection::new( + self.web_server_started, + self.web_server_error.clone(), + self.web_server_different_version_error.clone(), + self.web_server_base_url.clone(), + self.connection_is_unencrypted(), + ); + let current_session_section = CurrentSessionSection::new( + self.web_server_started, + self.web_server_ip, + self.web_server_port, + self.session_name.clone(), + self.web_sharing, + self.connection_is_unencrypted(), + ); + + let title_width = Self::TITLE_TEXT.chars().count(); + + let (web_server_width, web_server_height) = + web_server_status_section.web_server_status_width_and_height(); + let (current_session_width, current_session_height) = + current_session_section.current_session_status_width_and_height(); + let (usage_width, usage_height) = usage.usage_width_and_height(cols); + + let mut max_width = title_width + .max(web_server_width) + .max(current_session_width) + .max(usage_width); + + let mut total_height = + 2 + web_server_height + 1 + current_session_height + 1 + usage_height; + + if self.connection_is_unencrypted() { + let warning_width = self.unencrypted_warning_width(); + max_width = max_width.max(warning_width); + total_height += 3; + } + + Layout { + base_x: cols.saturating_sub(max_width) / 2, + base_y: rows.saturating_sub(total_height) / 2, + title_text: Self::TITLE_TEXT, + web_server_height, + usage_height, + usage_width, + } + } + + fn render_content(&self, layout: &Layout, state: &mut MainScreenState) { + let mut current_y = layout.base_y; + + self.render_title(layout, current_y); + current_y += 2; + + current_y = self.render_web_server_section(layout, current_y, state); + current_y = self.render_current_session_section(layout, current_y, state); + current_y = self.render_usage_section(layout, current_y); + current_y = self.render_warnings_and_help(layout, current_y, state); + + self.render_info(layout, current_y); + } + + fn render_title(&self, layout: &Layout, y: usize) { + let title = Text::new(layout.title_text).color_range(2, ..); + print_text_with_coordinates(title, layout.base_x, y, None, None); + } + + fn render_web_server_section( + &self, + layout: &Layout, + y: usize, + state: &mut MainScreenState, + ) -> usize { + let mut web_server_status_section = WebServerStatusSection::new( + self.web_server_started, + self.web_server_error.clone(), + self.web_server_different_version_error.clone(), + self.web_server_base_url.clone(), + self.connection_is_unencrypted(), + ); + + web_server_status_section.render_web_server_status( + layout.base_x, + y, + self.hover_coordinates, + ); + + state.currently_hovering_over_link |= + web_server_status_section.currently_hovering_over_link; + state.currently_hovering_over_unencrypted |= + web_server_status_section.currently_hovering_over_unencrypted; + + for (coordinates, url) in web_server_status_section.clickable_urls { + state.clickable_urls.insert(coordinates, url); + } + + y + layout.web_server_height + 1 + } + + fn render_current_session_section( + &self, + layout: &Layout, + y: usize, + state: &mut MainScreenState, + ) -> usize { + let mut current_session_section = CurrentSessionSection::new( + self.web_server_started, + self.web_server_ip, + self.web_server_port, + self.session_name.clone(), + self.web_sharing, + self.connection_is_unencrypted(), + ); + + current_session_section.render_current_session_status( + layout.base_x, + y, + self.hover_coordinates, + ); + + state.currently_hovering_over_link |= current_session_section.currently_hovering_over_link; + + for (coordinates, url) in current_session_section.clickable_urls { + state.clickable_urls.insert(coordinates, url); + } + + y + layout.web_server_height + 1 + } + + fn render_usage_section(&self, layout: &Layout, y: usize) -> usize { + let usage = Usage::new(!self.token_list_is_empty); + usage.render_usage(layout.base_x, y, layout.usage_width); + y + layout.usage_height + 1 + } + + fn render_warnings_and_help( + &self, + layout: &Layout, + mut y: usize, + state: &mut MainScreenState, + ) -> usize { + if self.connection_is_unencrypted() && self.web_server_started { + self.render_unencrypted_warning(layout.base_x, y, state); + y += 3; + } + + if state.currently_hovering_over_link { + self.render_link_help(layout.base_x, y); + y += 3; + } + + y + } + + fn render_info(&self, layout: &Layout, y: usize) { + if let Some(info) = self.info { + let info_text = Text::new(info).color_range(1, ..); + print_text_with_coordinates(info_text, layout.base_x, y, None, None); + } + } + + fn unencrypted_warning_width(&self) -> usize { + let more_info_line = format!("{}{}", Self::MORE_INFO_TEXT, Self::SSL_URL); + std::cmp::max( + Self::WARNING_TEXT.chars().count(), + more_info_line.chars().count(), + ) + } + + fn render_unencrypted_warning(&self, x: usize, y: usize, state: &mut MainScreenState) { + let warning_text = Text::new(Self::WARNING_TEXT).color_range(1, ..3); + let more_info_line = Text::new(format!("{}{}", Self::MORE_INFO_TEXT, Self::SSL_URL)); + + let url_x = x + Self::MORE_INFO_TEXT.chars().count(); + let url_y = y + 1; + let url_width = Self::SSL_URL.chars().count(); + + state.clickable_urls.insert( + CoordinatesInLine::new(url_x, url_y, url_width), + Self::SSL_URL.to_owned(), + ); + + print_text_with_coordinates(warning_text, x, y, None, None); + print_text_with_coordinates(more_info_line, x, y + 1, None, None); + + if hovering_on_line(url_x, url_y, url_width, self.hover_coordinates) { + state.currently_hovering_over_link = true; + render_text_with_underline(url_x, url_y, Self::SSL_URL); + } + } + + fn render_link_help(&self, x: usize, y: usize) { + let help_text = if self.link_executable.is_some() { + Text::new(Self::HELP_TEXT_WITH_CLICK) + .color_range(3, 6..=10) + .color_range(3, 15..=25) + } else { + Text::new(Self::HELP_TEXT_SHIFT_ONLY).color_range(3, 6..=16) + }; + print_text_with_coordinates(help_text, x, y, None, None); + } + pub fn connection_is_unencrypted(&self) -> bool { + Url::parse(&self.web_server_base_url) + .ok() + .map(|b| b.scheme() == "http") + .unwrap_or(false) + } +} + +struct Layout<'a> { + base_x: usize, + base_y: usize, + title_text: &'a str, + web_server_height: usize, + usage_height: usize, + usage_width: usize, +} diff --git a/default-plugins/share/src/token_management_screen.rs b/default-plugins/share/src/token_management_screen.rs new file mode 100644 index 00000000..0eb3a873 --- /dev/null +++ b/default-plugins/share/src/token_management_screen.rs @@ -0,0 +1,607 @@ +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); + } + } +} diff --git a/default-plugins/share/src/token_screen.rs b/default-plugins/share/src/token_screen.rs new file mode 100644 index 00000000..461b199d --- /dev/null +++ b/default-plugins/share/src/token_screen.rs @@ -0,0 +1,192 @@ +use zellij_tile::prelude::*; + +// Constants for text content +const TOKEN_LABEL_LONG: &str = "New log-in token: "; +const TOKEN_LABEL_SHORT: &str = "Token: "; +const EXPLANATION_1_LONG: &str = "Use this token to log-in from the browser."; +const EXPLANATION_1_SHORT: &str = "Use to log-in from the browser."; +const EXPLANATION_2_LONG: &str = + "Copy this token, because it will not be saved and can't be retrieved."; +const EXPLANATION_2_SHORT: &str = "It will not be saved and can't be retrieved."; +const EXPLANATION_3_LONG: &str = "If lost, it can always be revoked and a new one generated."; +const EXPLANATION_3_SHORT: &str = "It can always be revoked and a regenerated."; +const ESC_INSTRUCTION: &str = "<Esc> - go back"; + +// Screen layout constants +const SCREEN_HEIGHT: usize = 7; +const TOKEN_Y_OFFSET: usize = 0; +const EXPLANATION_1_Y_OFFSET: usize = 2; +const EXPLANATION_2_Y_OFFSET: usize = 4; +const EXPLANATION_3_Y_OFFSET: usize = 5; +const ESC_Y_OFFSET: usize = 7; +const ERROR_Y_OFFSET: usize = 8; + +struct TextVariant { + long: &'static str, + short: &'static str, +} + +impl TextVariant { + fn select(&self, cols: usize) -> &'static str { + if cols >= self.long.chars().count() { + self.long + } else { + self.short + } + } +} + +pub struct TokenScreen { + token: String, + web_server_error: Option<String>, + rows: usize, + cols: usize, +} + +impl TokenScreen { + pub fn new(token: String, web_server_error: Option<String>, rows: usize, cols: usize) -> Self { + Self { + token, + web_server_error, + rows, + cols, + } + } + + pub fn render(&self) { + let elements = self.prepare_screen_elements(); + let width = self.calculate_max_width(&elements); + let (base_x, base_y) = self.calculate_base_position(width); + + self.render_elements(&elements, base_x, base_y); + self.render_error_if_present(base_x, base_y); + } + + fn prepare_screen_elements(&self) -> ScreenElements { + let token_variant = TextVariant { + long: TOKEN_LABEL_LONG, + short: TOKEN_LABEL_SHORT, + }; + + let explanation_variants = [ + TextVariant { + long: EXPLANATION_1_LONG, + short: EXPLANATION_1_SHORT, + }, + TextVariant { + long: EXPLANATION_2_LONG, + short: EXPLANATION_2_SHORT, + }, + TextVariant { + long: EXPLANATION_3_LONG, + short: EXPLANATION_3_SHORT, + }, + ]; + + let token_label = token_variant.select( + self.cols + .saturating_sub(self.token.chars().count().saturating_sub(1)), + ); + let token_text = format!("{}{}", token_label, self.token); + let token_element = self.create_token_text_element(&token_text, token_label); + + let explanation_texts: Vec<&str> = explanation_variants + .iter() + .map(|variant| variant.select(self.cols)) + .collect(); + + let explanation_elements: Vec<Text> = explanation_texts + .iter() + .enumerate() + .map(|(i, &text)| { + if i == 0 { + Text::new(text).color_range(0, ..) + } else { + Text::new(text) + } + }) + .collect(); + + let esc_element = Text::new(ESC_INSTRUCTION).color_range(3, ..=4); + + ScreenElements { + token: token_element, + token_text, + explanation_texts, + explanations: explanation_elements, + esc: esc_element, + } + } + + fn create_token_text_element(&self, token_text: &str, token_label: &str) -> Text { + Text::new(token_text).color_range(2, ..token_label.chars().count()) + } + + fn calculate_max_width(&self, elements: &ScreenElements) -> usize { + let token_width = elements.token_text.chars().count(); + let explanation_widths = elements + .explanation_texts + .iter() + .map(|text| text.chars().count()); + let esc_width = ESC_INSTRUCTION.chars().count(); + + [token_width, esc_width] + .into_iter() + .chain(explanation_widths) + .max() + .unwrap_or(0) + } + + fn calculate_base_position(&self, width: usize) -> (usize, usize) { + let base_x = self.cols.saturating_sub(width) / 2; + let base_y = self.rows.saturating_sub(SCREEN_HEIGHT) / 2; + (base_x, base_y) + } + + fn render_elements(&self, elements: &ScreenElements, base_x: usize, base_y: usize) { + print_text_with_coordinates( + elements.token.clone(), + base_x, + base_y + TOKEN_Y_OFFSET, + None, + None, + ); + + let y_offsets = [ + EXPLANATION_1_Y_OFFSET, + EXPLANATION_2_Y_OFFSET, + EXPLANATION_3_Y_OFFSET, + ]; + for (explanation, &y_offset) in elements.explanations.iter().zip(y_offsets.iter()) { + print_text_with_coordinates(explanation.clone(), base_x, base_y + y_offset, None, None); + } + + print_text_with_coordinates( + elements.esc.clone(), + base_x, + base_y + ESC_Y_OFFSET, + None, + None, + ); + } + + fn render_error_if_present(&self, base_x: usize, base_y: usize) { + if let Some(error) = &self.web_server_error { + print_text_with_coordinates( + Text::new(error).color_range(3, ..), + base_x, + base_y + ERROR_Y_OFFSET, + None, + None, + ); + } + } +} + +struct ScreenElements { + token: Text, + token_text: String, + explanation_texts: Vec<&'static str>, + explanations: Vec<Text>, + esc: Text, +} diff --git a/default-plugins/share/src/ui_components.rs b/default-plugins/share/src/ui_components.rs new file mode 100644 index 00000000..00b79dd5 --- /dev/null +++ b/default-plugins/share/src/ui_components.rs @@ -0,0 +1,637 @@ +use std::net::IpAddr; +use zellij_tile::prelude::*; + +use crate::CoordinatesInLine; +use std::collections::HashMap; + +pub const USAGE_TITLE: &str = "How it works:"; +pub const FIRST_TIME_USAGE_TITLE: &str = "Before logging in for the first time:"; +pub const FIRST_TIME_BULLETIN_1: &str = "- Press <t> to generate a login token"; +pub const BULLETIN_1_FULL: &str = "- Visit base URL to start a new session"; +pub const BULLETIN_1_SHORT: &str = "- Base URL: new session"; +pub const BULLETIN_2_FULL: &str = "- Follow base URL with a session name to attach to or create it"; +pub const BULLETIN_2_SHORT: &str = "- Base URL + session name: attach or create"; +pub const BULLETIN_3_FULL: &str = + "- By default sessions not started from the web must be explicitly shared"; +pub const BULLETIN_3_SHORT: &str = "- Sessions not started from the web must be explicitly shared"; +pub const BULLETIN_4: &str = "- <t> manage login tokens"; + +pub const WEB_SERVER_TITLE: &str = "Web server: "; +pub const WEB_SERVER_RUNNING: &str = "RUNNING "; +pub const WEB_SERVER_NOT_RUNNING: &str = "NOT RUNNING"; +pub const WEB_SERVER_INCOMPATIBLE_PREFIX: &str = "RUNNING INCOMPATIBLE VERSION "; +pub const CTRL_C_STOP: &str = "(<Ctrl c> - Stop)"; +pub const CTRL_C_STOP_OTHER: &str = "<Ctrl c> - Stop other server"; +pub const PRESS_ENTER_START: &str = "Press <ENTER> to start"; +pub const ERROR_PREFIX: &str = "ERROR: "; +pub const URL_TITLE: &str = "URL: "; +pub const UNENCRYPTED_MARKER: &str = " [*]"; + +pub const CURRENT_SESSION_TITLE: &str = "Current session: "; +pub const SESSION_URL_TITLE: &str = "Session URL: "; +pub const SHARING_STATUS: &str = "SHARING (<SPACE> - Stop Sharing)"; +pub const SHARING_DISABLED: &str = "SHARING IS DISABLED"; +pub const NOT_SHARING: &str = "NOT SHARING"; +pub const PRESS_SPACE_SHARE: &str = "Press <SPACE> to share"; +pub const WEB_SERVER_OFFLINE: &str = "...but web server is offline"; + +pub const COLOR_INDEX_0: usize = 0; +pub const COLOR_INDEX_1: usize = 1; +pub const COLOR_INDEX_2: usize = 2; +pub const COLOR_HIGHLIGHT: usize = 3; + +#[derive(Debug, Clone)] +pub struct ColorRange { + pub start: usize, + pub end: usize, + pub color: usize, +} + +// TODO: move this API to zellij-tile +#[derive(Debug)] +pub struct ColoredTextBuilder { + text: String, + ranges: Vec<ColorRange>, +} + +impl ColoredTextBuilder { + pub fn new(text: String) -> Self { + Self { + text, + ranges: Vec::new(), + } + } + + pub fn highlight_substring(mut self, substring: &str, color: usize) -> Self { + if let Some(start) = self.text.find(substring) { + let end = start + substring.chars().count(); + self.ranges.push(ColorRange { start, end, color }); + } + self + } + + pub fn highlight_range(mut self, start: usize, end: usize, color: usize) -> Self { + self.ranges.push(ColorRange { start, end, color }); + self + } + + pub fn highlight_from_start(mut self, start: usize, color: usize) -> Self { + let end = self.text.chars().count(); + self.ranges.push(ColorRange { start, end, color }); + self + } + + pub fn highlight_all(mut self, color: usize) -> Self { + let end = self.text.chars().count(); + self.ranges.push(ColorRange { + start: 0, + end, + color, + }); + self + } + + pub fn build(self) -> (Text, usize) { + let length = self.text.chars().count(); + let mut text_component = Text::new(self.text); + + for range in self.ranges { + text_component = text_component.color_range(range.color, range.start..range.end); + } + + (text_component, length) + } +} + +// create titled text with different colors for title and value +fn create_titled_text( + title: &str, + value: &str, + title_color: usize, + value_color: usize, +) -> (Text, usize) { + let full_text = format!("{}{}", title, value); + ColoredTextBuilder::new(full_text) + .highlight_range(0, title.chars().count(), title_color) + .highlight_from_start(title.chars().count(), value_color) + .build() +} + +// to create text with a highlighted shortcut key +fn create_highlighted_shortcut(text: &str, shortcut: &str, color: usize) -> (Text, usize) { + ColoredTextBuilder::new(text.to_string()) + .highlight_substring(shortcut, color) + .build() +} + +fn get_text_with_fallback( + full_text: &'static str, + short_text: &'static str, + max_width: usize, +) -> &'static str { + if full_text.chars().count() <= max_width { + full_text + } else { + short_text + } +} + +fn calculate_max_length(texts: &[&str]) -> usize { + texts + .iter() + .map(|text| text.chars().count()) + .max() + .unwrap_or(0) +} + +fn format_url_with_encryption_marker(base_url: &str, is_unencrypted: bool) -> String { + if is_unencrypted { + format!("{}{}", base_url, UNENCRYPTED_MARKER) + } else { + base_url.to_string() + } +} + +#[derive(Debug)] +pub struct Usage { + has_login_tokens: bool, + first_time_usage_title: &'static str, + first_time_bulletin_1: &'static str, + usage_title: &'static str, + bulletin_1_full: &'static str, + bulletin_1_short: &'static str, + bulletin_2_full: &'static str, + bulletin_2_short: &'static str, + bulletin_3_full: &'static str, + bulletin_3_short: &'static str, + bulletin_4: &'static str, +} + +impl Usage { + pub fn new(has_login_tokens: bool) -> Self { + Usage { + has_login_tokens, + usage_title: USAGE_TITLE, + bulletin_1_full: BULLETIN_1_FULL, + bulletin_1_short: BULLETIN_1_SHORT, + bulletin_2_full: BULLETIN_2_FULL, + bulletin_2_short: BULLETIN_2_SHORT, + bulletin_3_full: BULLETIN_3_FULL, + bulletin_3_short: BULLETIN_3_SHORT, + bulletin_4: BULLETIN_4, + first_time_usage_title: FIRST_TIME_USAGE_TITLE, + first_time_bulletin_1: FIRST_TIME_BULLETIN_1, + } + } + + pub fn usage_width_and_height(&self, max_width: usize) -> (usize, usize) { + if self.has_login_tokens { + self.full_usage_width_and_height(max_width) + } else { + self.first_time_usage_width_and_height(max_width) + } + } + + pub fn full_usage_width_and_height(&self, max_width: usize) -> (usize, usize) { + let bulletin_1 = + get_text_with_fallback(self.bulletin_1_full, self.bulletin_1_short, max_width); + let bulletin_2 = + get_text_with_fallback(self.bulletin_2_full, self.bulletin_2_short, max_width); + let bulletin_3 = + get_text_with_fallback(self.bulletin_3_full, self.bulletin_3_short, max_width); + + let texts = &[ + self.usage_title, + bulletin_1, + bulletin_2, + bulletin_3, + self.bulletin_4, + ]; + let width = calculate_max_length(texts); + let height = 5; + (width, height) + } + + pub fn first_time_usage_width_and_height(&self, _max_width: usize) -> (usize, usize) { + let texts = &[self.first_time_usage_title, self.first_time_bulletin_1]; + let width = calculate_max_length(texts); + let height = 2; + (width, height) + } + + pub fn render_usage(&self, x: usize, y: usize, max_width: usize) { + if self.has_login_tokens { + self.render_full_usage(x, y, max_width) + } else { + self.render_first_time_usage(x, y) + } + } + + pub fn render_full_usage(&self, x: usize, y: usize, max_width: usize) { + let bulletin_1 = + get_text_with_fallback(self.bulletin_1_full, self.bulletin_1_short, max_width); + let bulletin_2 = + get_text_with_fallback(self.bulletin_2_full, self.bulletin_2_short, max_width); + let bulletin_3 = + get_text_with_fallback(self.bulletin_3_full, self.bulletin_3_short, max_width); + + let usage_title = ColoredTextBuilder::new(self.usage_title.to_string()) + .highlight_all(COLOR_INDEX_2) + .build() + .0; + + let bulletin_1_text = Text::new(bulletin_1); + let bulletin_2_text = Text::new(bulletin_2); + let bulletin_3_text = Text::new(bulletin_3); + + let bulletin_4_text = + create_highlighted_shortcut(self.bulletin_4, "<t>", COLOR_HIGHLIGHT).0; + + let texts_and_positions = vec![ + (usage_title, y), + (bulletin_1_text, y + 1), + (bulletin_2_text, y + 2), + (bulletin_3_text, y + 3), + (bulletin_4_text, y + 4), + ]; + + for (text, y_pos) in texts_and_positions { + print_text_with_coordinates(text, x, y_pos, None, None); + } + } + + pub fn render_first_time_usage(&self, x: usize, y: usize) { + let usage_title = ColoredTextBuilder::new(self.first_time_usage_title.to_string()) + .highlight_all(COLOR_INDEX_1) + .build() + .0; + + let bulletin_1 = + create_highlighted_shortcut(self.first_time_bulletin_1, "<t>", COLOR_HIGHLIGHT).0; + + print_text_with_coordinates(usage_title, x, y, None, None); + print_text_with_coordinates(bulletin_1, x, y + 1, None, None); + } +} + +#[derive(Debug)] +pub struct WebServerStatusSection { + web_server_started: bool, + web_server_base_url: String, + web_server_error: Option<String>, + web_server_different_version_error: Option<String>, + connection_is_unencrypted: bool, + pub clickable_urls: HashMap<CoordinatesInLine, String>, + pub currently_hovering_over_link: bool, + pub currently_hovering_over_unencrypted: bool, +} + +impl WebServerStatusSection { + pub fn new( + web_server_started: bool, + web_server_error: Option<String>, + web_server_different_version_error: Option<String>, + web_server_base_url: String, + connection_is_unencrypted: bool, + ) -> Self { + WebServerStatusSection { + web_server_started, + clickable_urls: HashMap::new(), + currently_hovering_over_link: false, + currently_hovering_over_unencrypted: false, + web_server_error, + web_server_different_version_error, + web_server_base_url, + connection_is_unencrypted, + } + } + + pub fn web_server_status_width_and_height(&self) -> (usize, usize) { + let mut max_len = self.web_server_status_line().1; + + if let Some(error) = &self.web_server_error { + max_len = std::cmp::max(max_len, self.web_server_error_component(error).1); + } else if let Some(different_version) = &self.web_server_different_version_error { + max_len = std::cmp::max( + max_len, + self.web_server_different_version_error_component(different_version) + .1, + ); + } else if self.web_server_started { + let url_display = format_url_with_encryption_marker( + &self.web_server_base_url, + self.connection_is_unencrypted, + ); + max_len = std::cmp::max( + max_len, + URL_TITLE.chars().count() + url_display.chars().count(), + ); + } else { + max_len = std::cmp::max(max_len, self.start_server_line().1); + } + + (max_len, 2) + } + + pub fn render_web_server_status( + &mut self, + x: usize, + y: usize, + hover_coordinates: Option<(usize, usize)>, + ) { + let web_server_status_line = self.web_server_status_line().0; + print_text_with_coordinates(web_server_status_line, x, y, None, None); + + if let Some(error) = &self.web_server_error { + let error_component = self.web_server_error_component(error).0; + print_text_with_coordinates(error_component, x, y + 1, None, None); + } else if let Some(different_version) = &self.web_server_different_version_error { + let version_error_component = self + .web_server_different_version_error_component(different_version) + .0; + print_text_with_coordinates(version_error_component, x, y + 1, None, None); + } else if self.web_server_started { + self.render_server_url(x, y, hover_coordinates); + } else { + let info_line = self.start_server_line().0; + print_text_with_coordinates(info_line, x, y + 1, None, None); + } + } + + fn render_server_url(&mut self, x: usize, y: usize, hover_coordinates: Option<(usize, usize)>) { + let server_url = &self.web_server_base_url; + let url_x = x + URL_TITLE.chars().count(); + let url_width = server_url.chars().count(); + let url_y = y + 1; + + self.clickable_urls.insert( + CoordinatesInLine::new(url_x, url_y, url_width), + server_url.clone(), + ); + + let info_line = if self.connection_is_unencrypted { + let full_text = format!("{}{}{}", URL_TITLE, server_url, UNENCRYPTED_MARKER); + ColoredTextBuilder::new(full_text) + .highlight_range(0, URL_TITLE.chars().count(), COLOR_INDEX_0) + .highlight_substring(UNENCRYPTED_MARKER, COLOR_INDEX_1) + .build() + .0 + } else { + create_titled_text(URL_TITLE, server_url, COLOR_INDEX_0, COLOR_INDEX_1).0 + }; + + print_text_with_coordinates(info_line, x, y + 1, None, None); + + if hovering_on_line(url_x, url_y, url_width, hover_coordinates) { + self.currently_hovering_over_link = true; + render_text_with_underline(url_x, url_y, server_url); + } + } + + fn web_server_status_line(&self) -> (Text, usize) { + if self.web_server_started { + self.create_running_status_line() + } else if let Some(different_version) = &self.web_server_different_version_error { + self.create_incompatible_version_line(different_version) + } else { + create_titled_text( + WEB_SERVER_TITLE, + WEB_SERVER_NOT_RUNNING, + COLOR_INDEX_0, + COLOR_HIGHLIGHT, + ) + } + } + + fn create_running_status_line(&self) -> (Text, usize) { + let full_text = format!("{}{}{}", WEB_SERVER_TITLE, WEB_SERVER_RUNNING, CTRL_C_STOP); + ColoredTextBuilder::new(full_text) + .highlight_range(0, WEB_SERVER_TITLE.chars().count(), COLOR_INDEX_0) + .highlight_substring(WEB_SERVER_RUNNING.trim(), COLOR_HIGHLIGHT) + .highlight_substring("<Ctrl c>", COLOR_HIGHLIGHT) + .build() + } + + fn create_incompatible_version_line(&self, different_version: &str) -> (Text, usize) { + let value = format!("{}{}", WEB_SERVER_INCOMPATIBLE_PREFIX, different_version); + create_titled_text(WEB_SERVER_TITLE, &value, COLOR_INDEX_0, COLOR_HIGHLIGHT) + } + + fn start_server_line(&self) -> (Text, usize) { + create_highlighted_shortcut(PRESS_ENTER_START, "<ENTER>", COLOR_HIGHLIGHT) + } + + fn web_server_error_component(&self, error: &str) -> (Text, usize) { + let text = format!("{}{}", ERROR_PREFIX, error); + ColoredTextBuilder::new(text) + .highlight_all(COLOR_HIGHLIGHT) + .build() + } + + fn web_server_different_version_error_component(&self, _version: &str) -> (Text, usize) { + create_highlighted_shortcut(CTRL_C_STOP_OTHER, "<Ctrl c>", COLOR_HIGHLIGHT) + } +} + +#[derive(Debug)] +pub struct CurrentSessionSection { + web_server_started: bool, + web_server_ip: Option<IpAddr>, + web_server_port: Option<u16>, + web_sharing: WebSharing, + session_name: Option<String>, + connection_is_unencrypted: bool, + pub clickable_urls: HashMap<CoordinatesInLine, String>, + pub currently_hovering_over_link: bool, +} + +impl CurrentSessionSection { + pub fn new( + web_server_started: bool, + web_server_ip: Option<IpAddr>, + web_server_port: Option<u16>, + session_name: Option<String>, + web_sharing: WebSharing, + connection_is_unencrypted: bool, + ) -> Self { + CurrentSessionSection { + web_server_started, + web_server_ip, + web_server_port, + session_name, + web_sharing, + clickable_urls: HashMap::new(), + currently_hovering_over_link: false, + connection_is_unencrypted, + } + } + + pub fn current_session_status_width_and_height(&self) -> (usize, usize) { + let mut max_len = self.get_session_status_line_length(); + + if self.web_sharing.web_clients_allowed() && self.web_server_started { + let url_display = format_url_with_encryption_marker( + &self.session_url(), + self.connection_is_unencrypted, + ); + max_len = std::cmp::max( + max_len, + SESSION_URL_TITLE.chars().count() + url_display.chars().count(), + ); + } else if self.web_sharing.web_clients_allowed() { + max_len = std::cmp::max(max_len, WEB_SERVER_OFFLINE.chars().count()); + } else { + max_len = std::cmp::max(max_len, self.press_space_to_share().1); + } + + (max_len, 2) + } + + fn get_session_status_line_length(&self) -> usize { + match self.web_sharing { + WebSharing::On => self.render_current_session_sharing().1, + WebSharing::Disabled => self.render_sharing_is_disabled().1, + WebSharing::Off => self.render_not_sharing().1, + } + } + + pub fn render_current_session_status( + &mut self, + x: usize, + y: usize, + hover_coordinates: Option<(usize, usize)>, + ) { + let status_line = match self.web_sharing { + WebSharing::On => self.render_current_session_sharing().0, + WebSharing::Disabled => self.render_sharing_is_disabled().0, + WebSharing::Off => self.render_not_sharing().0, + }; + + print_text_with_coordinates(status_line, x, y, None, None); + + if self.web_sharing.web_clients_allowed() && self.web_server_started { + self.render_session_url(x, y, hover_coordinates); + } else if self.web_sharing.web_clients_allowed() { + let info_line = Text::new(WEB_SERVER_OFFLINE); + print_text_with_coordinates(info_line, x, y + 1, None, None); + } else if !self.web_sharing.sharing_is_disabled() { + let info_line = self.press_space_to_share().0; + print_text_with_coordinates(info_line, x, y + 1, None, None); + } + } + + fn render_session_url( + &mut self, + x: usize, + y: usize, + hover_coordinates: Option<(usize, usize)>, + ) { + let session_url = self.session_url(); + let url_x = x + SESSION_URL_TITLE.chars().count(); + let url_width = session_url.chars().count(); + let url_y = y + 1; + + self.clickable_urls.insert( + CoordinatesInLine::new(url_x, url_y, url_width), + session_url.clone(), + ); + + let info_line = if self.connection_is_unencrypted { + let full_text = format!("{}{}{}", SESSION_URL_TITLE, session_url, UNENCRYPTED_MARKER); + ColoredTextBuilder::new(full_text) + .highlight_range(0, SESSION_URL_TITLE.chars().count(), COLOR_INDEX_0) + .highlight_substring(UNENCRYPTED_MARKER, COLOR_INDEX_1) + .build() + .0 + } else { + create_titled_text( + SESSION_URL_TITLE, + &session_url, + COLOR_INDEX_0, + COLOR_INDEX_1, + ) + .0 + }; + + print_text_with_coordinates(info_line, x, y + 1, None, None); + + if hovering_on_line(url_x, url_y, url_width, hover_coordinates) { + self.currently_hovering_over_link = true; + render_text_with_underline(url_x, url_y, &session_url); + } + } + + fn render_current_session_sharing(&self) -> (Text, usize) { + let full_text = format!("{}{}", CURRENT_SESSION_TITLE, SHARING_STATUS); + ColoredTextBuilder::new(full_text) + .highlight_range(0, CURRENT_SESSION_TITLE.chars().count(), COLOR_INDEX_0) + .highlight_substring("SHARING", COLOR_HIGHLIGHT) + .highlight_substring("<SPACE>", COLOR_HIGHLIGHT) + .build() + } + + fn render_sharing_is_disabled(&self) -> (Text, usize) { + create_titled_text( + CURRENT_SESSION_TITLE, + SHARING_DISABLED, + COLOR_INDEX_0, + COLOR_HIGHLIGHT, + ) + } + + fn render_not_sharing(&self) -> (Text, usize) { + create_titled_text( + CURRENT_SESSION_TITLE, + NOT_SHARING, + COLOR_INDEX_0, + COLOR_HIGHLIGHT, + ) + } + + fn session_url(&self) -> String { + let web_server_ip = self + .web_server_ip + .map(|i| i.to_string()) + .unwrap_or_else(|| "UNDEFINED".to_owned()); + let web_server_port = self + .web_server_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "UNDEFINED".to_owned()); + let prefix = if self.connection_is_unencrypted { + "http" + } else { + "https" + }; + let session_name = self.session_name.as_deref().unwrap_or(""); + + format!( + "{}://{}:{}/{}", + prefix, web_server_ip, web_server_port, session_name + ) + } + + fn press_space_to_share(&self) -> (Text, usize) { + create_highlighted_shortcut(PRESS_SPACE_SHARE, "<SPACE>", COLOR_HIGHLIGHT) + } +} + +pub fn hovering_on_line( + x: usize, + y: usize, + width: usize, + hover_coordinates: Option<(usize, usize)>, +) -> bool { + match hover_coordinates { + Some((hover_x, hover_y)) => hover_y == y && hover_x <= x + width && hover_x > x, + None => false, + } +} + +pub fn render_text_with_underline(url_x: usize, url_y: usize, url_text: &str) { + print!( + "\u{1b}[{};{}H\u{1b}[m\u{1b}[1;4m{}", + url_y + 1, + url_x + 1, + url_text, + ); +} |
