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/src/ui_components.rs | |
| 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/src/ui_components.rs')
| -rw-r--r-- | default-plugins/share/src/ui_components.rs | 637 |
1 files changed, 637 insertions, 0 deletions
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, + ); +} |
