From d929a7d8dd4bf0bdd3597e0f134eb9d2c4f9816a Mon Sep 17 00:00:00 2001 From: MeexReay Date: Tue, 2 Sep 2025 16:13:43 +0300 Subject: [PATCH 1/8] refactor: full gui module reorganize --- flake.nix | 6 +- src/chat/gui.rs | 1536 -------------------------- src/chat/{ => gui}/images/avatar.png | Bin src/chat/{ => gui}/images/icon.png | Bin src/chat/{ => gui}/images/konata.png | Bin src/chat/{ => gui}/images/logo.gif | Bin src/chat/gui/mod.rs | 590 ++++++++++ src/chat/gui/page.rs | 575 ++++++++++ src/chat/gui/preferences.rs | 453 ++++++++ src/chat/{ => gui}/styles/dark.css | 0 src/chat/{ => gui}/styles/light.css | 0 src/chat/{ => gui}/styles/style.css | 0 12 files changed, 1621 insertions(+), 1539 deletions(-) delete mode 100644 src/chat/gui.rs rename src/chat/{ => gui}/images/avatar.png (100%) rename src/chat/{ => gui}/images/icon.png (100%) rename src/chat/{ => gui}/images/konata.png (100%) rename src/chat/{ => gui}/images/logo.gif (100%) create mode 100644 src/chat/gui/mod.rs create mode 100644 src/chat/gui/page.rs create mode 100644 src/chat/gui/preferences.rs rename src/chat/{ => gui}/styles/dark.css (100%) rename src/chat/{ => gui}/styles/light.css (100%) rename src/chat/{ => gui}/styles/style.css (100%) diff --git a/flake.nix b/flake.nix index 9faec03..6e55570 100644 --- a/flake.nix +++ b/flake.nix @@ -7,7 +7,7 @@ rust-overlay.url = "github:oxalica/rust-overlay"; }; - outputs = { self, nixpkgs, rust-overlay, flake-utils, ... }: + outputs = { nixpkgs, rust-overlay, flake-utils, ... }: flake-utils.lib.eachDefaultSystem (system: let devDeps = with pkgs; [ pkg-config openssl gtk4 pango libnotify libadwaita ]; @@ -25,8 +25,8 @@ nativeBuildInputs = devDeps ++ [ rustc ]; }; in { - devShells.nightly = (mkDevShell (pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default))); - devShells.default = (mkDevShell pkgs.rust-bin.stable.latest.default); + devShells.default = (mkDevShell (pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default))); + devShells.stable = (mkDevShell pkgs.rust-bin.stable.latest.default); packages.default = (pkgs.makeRustPlatform { cargo = pkgs.rust-bin.nightly.latest.minimal; diff --git a/src/chat/gui.rs b/src/chat/gui.rs deleted file mode 100644 index 0e251a4..0000000 --- a/src/chat/gui.rs +++ /dev/null @@ -1,1536 +0,0 @@ -use std::cell::RefCell; -use std::collections::HashMap; -use std::error::Error; -use std::hash::{DefaultHasher, Hasher}; -use std::io::Read; -use std::path::PathBuf; -use std::sync::atomic::AtomicU64; -use std::sync::Mutex; -use std::sync::{atomic::Ordering, mpsc::channel, Arc, RwLock}; -use std::thread; -use std::time::{Duration, SystemTime}; - -use chrono::Local; -use clap::crate_version; - -use libadwaita::gdk::{Texture, BUTTON_PRIMARY, BUTTON_SECONDARY}; -use libadwaita::gtk::gdk_pixbuf::InterpType; -use libadwaita::gtk::{Adjustment, GestureLongPress, MenuButton, Popover}; -use libadwaita::{ - self as adw, ActionRow, Avatar, ButtonRow, EntryRow, HeaderBar, PreferencesDialog, PreferencesGroup, PreferencesPage, SpinRow, SwitchRow -}; -use adw::gdk::{Cursor, Display}; -use adw::gio::{ActionEntry, ApplicationFlags, MemoryInputStream, Menu}; -use adw::glib::clone; -use adw::glib::{ - self, clone::Downgrade, source::timeout_add_local_once, - timeout_add_local, timeout_add_once, - ControlFlow, -}; -use adw::prelude::*; -use adw::{Application, ApplicationWindow}; - -use adw::gtk; -use gtk::gdk_pixbuf::{Pixbuf, PixbufAnimation, PixbufLoader}; -use gtk::pango::WrapMode; -use gtk::{ - Align, Box as GtkBox, Button, Calendar, - CssProvider, Entry, Fixed, GestureClick, Justification, Label, ListBox, - Orientation, Overlay, Picture, ScrolledWindow, Settings, -}; - -use crate::chat::grab_avatar; - -use super::{ - config::{ - get_config_path, save_config, Config, - }, - ctx::Context, - on_send_message, parse_message, print_message, recv_tick, sanitize_message, SERVER_LIST, -}; - -pub fn try_save_config(path: PathBuf, config: &Config) { - match save_config(path, config) { - Ok(_) => {}, - Err(e) => { - println!("save config error: {e}") - } - } -} - -struct UiModel { - is_dark_theme: bool, - chat_box: GtkBox, - chat_scrolled: ScrolledWindow, - app: Application, - window: ApplicationWindow, - #[cfg(feature = "libnotify")] - notifications: Arc>>, - #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] - notifications: Arc>>, - avatars: Arc>>>, - latest_sign: Arc -} - -thread_local!( - static GLOBAL: RefCell> = RefCell::new(None); -); - -pub fn clear_chat_messages(ctx: Arc, messages: Vec) { - let _ = ctx - .sender - .read() - .unwrap() - .clone() - .unwrap() - .send((messages, true)); -} - -pub fn add_chat_messages(ctx: Arc, messages: Vec) { - println!("add chat messages: {}", messages.len()); - let _ = ctx - .sender - .read() - .unwrap() - .clone() - .unwrap() - .send((messages, false)); -} - -fn load_pixbuf(data: &[u8]) -> Result> { - let loader = PixbufLoader::new(); - loader.write(data)?; - loader.close()?; - Ok(loader.pixbuf().ok_or("laod pixbuf error")?) -} - -fn update_window_title(ctx: Arc) { - GLOBAL.with(|global| { - if let Some(ui) = &*global.borrow() { - ui.window.set_title(Some(&format!( - "bRAC - Connected to {} as {}", - ctx.config(|o| o.host.clone()), - &ctx.name() - ))) - } - }) -} - -fn open_settings(ctx: Arc, app: &Application) { - let dialog = PreferencesDialog::builder().build(); - - - let page = PreferencesPage::builder() - .title("General") - .icon_name("avatar-default-symbolic") - .build(); - - let group = PreferencesGroup::builder() - .title("User Profile") - .description("Profile preferences") - .build(); - - - // Name preference - - let name = EntryRow::builder() - .title("Name") - .text(ctx.config(|o| o.name.clone()).unwrap_or_default()) - .build(); - - group.add(&name); - - - // Avatar preference - - let avatar = EntryRow::builder() - .title("Avatar Link") - .text(ctx.config(|o| o.avatar.clone()).unwrap_or_default()) - .build(); - - group.add(&avatar); - - - page.add(&group); - - - - let group = PreferencesGroup::builder() - .title("Server") - .description("Connection preferences") - .build(); - - - // Host preference - - let host = EntryRow::builder() - .title("Host") - .text(ctx.config(|o| o.host.clone())) - .build(); - - group.add(&host); - - - // Messages limit preference - - let messages_limit = SpinRow::builder() - .title("Messages limit") - .adjustment(&Adjustment::builder() - .lower(1.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.max_messages) as f64) - .build()) - .build(); - - group.add(&messages_limit); - - - // Update interval preference - - let update_interval = SpinRow::builder() - .title("Update interval") - .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.update_time) as f64) - .build()) - .build(); - - group.add(&update_interval); - - - // Update interval OOF preference - - let update_interval_oof = SpinRow::builder() - .title("Update interval when unfocused") - .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.oof_update_time) as f64) - .build()) - .build(); - - group.add(&update_interval_oof); - - page.add(&group); - - - - let group = PreferencesGroup::builder() - .title("Config") - .description("Configuration tools") - .build(); - - let display = Display::default().unwrap(); - let clipboard = display.clipboard(); - - let config_path = ActionRow::builder() - .title("Config path") - .subtitle(get_config_path().to_string_lossy()) - .css_classes(["property", "monospace"]) - .build(); - - let config_path_copy = Button::from_icon_name("edit-copy-symbolic"); - - // config_path_copy.set_css_classes(&["circular"]); - config_path_copy.set_margin_top(10); - config_path_copy.set_margin_bottom(10); - config_path_copy.connect_clicked(clone!( - #[weak] clipboard, - move |_| { - if let Some(text) = get_config_path().to_str() { - clipboard.set_text(text); - } - } - )); - - config_path.add_suffix(&config_path_copy); - config_path.set_activatable(false); - - group.add(&config_path); - - // Reset button - - let reset_button = ButtonRow::builder() - .title("Reset all") - .build(); - - reset_button.connect_activated(clone!( - #[weak] ctx, - #[weak] app, - #[weak] dialog, - move |_| { - dialog.close(); - let config = Config::default(); - ctx.set_config(&config); - try_save_config(get_config_path(), &config); - open_settings(ctx, &app); - } - )); - - group.add(&reset_button); - - page.add(&group); - - dialog.add(&page); - - - - let page = PreferencesPage::builder() - .title("Protocol") - .icon_name("network-wired-symbolic") - .build(); - - let group = PreferencesGroup::builder() - .title("Network") - .description("Network preferences") - .build(); - - - // Proxy preference - - let proxy = EntryRow::builder() - .title("Socks proxy") - .text(ctx.config(|o| o.proxy.clone()).unwrap_or_default()) - .build(); - - group.add(&proxy); - - - // Max avatar size preference - - let max_avatar_size = SpinRow::builder() - .title("Max avatar size") - .subtitle("Maximum avatar size in bytes") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(1074790400.0) - .page_increment(1024.0) - .step_increment(1024.0) - .value(ctx.config(|o| o.max_avatar_size) as f64) - .build()) - .build(); - - group.add(&max_avatar_size); - - - page.add(&group); - - - let group = PreferencesGroup::builder() - .title("Protocol") - .description("Rac protocol preferences") - .build(); - - - // Message format preference - - let message_format = EntryRow::builder() - .title("Message format") - .text(ctx.config(|o| o.message_format.clone())) - .build(); - - group.add(&message_format); - - page.add(&group); - - - // Hide IP preference - - let hide_my_ip = SwitchRow::builder() - .title("Hide IP") - .subtitle("Hides only for clRAC and other dummy clients") - .active(ctx.config(|o| o.hide_my_ip)) - .build(); - - group.add(&hide_my_ip); - - - // Chunked reading preference - - let chunked_reading = SwitchRow::builder() - .title("Chunked reading") - .subtitle("Read messages in chunks (less traffic usage, less compatibility)") - .active(ctx.config(|o| o.chunked_enabled)) - .build(); - - group.add(&chunked_reading); - - - // Enable commands preference - - let enable_commands = SwitchRow::builder() - .title("Enable commands") - .subtitle("Enable slash commands (eg. /login) on client-side") - .active(ctx.config(|o| o.commands_enabled)) - .build(); - - group.add(&enable_commands); - - - page.add(&group); - - dialog.add(&page); - - - let page = PreferencesPage::builder() - .title("Interface") - .icon_name("applications-graphics-symbolic") - .build(); - - let group = PreferencesGroup::builder() - .title("Messages") - .description("Messages render preferences") - .build(); - - - // Debug logs preference - - let debug_logs = SwitchRow::builder() - .title("Debug logs") - .subtitle("Print debug logs to the chat") - .active(ctx.config(|o| o.debug_logs)) - .build(); - - group.add(&debug_logs); - - - // Show IPs preference - - let show_ips = SwitchRow::builder() - .title("Show IPs") - .subtitle("Show authors IP addresses if possible") - .active(ctx.config(|o| o.show_other_ip)) - .build(); - - group.add(&show_ips); - - - // Format messages preference - - let format_messages = SwitchRow::builder() - .title("Format messages") - .subtitle("Disable to see raw messages") - .active(ctx.config(|o| o.formatting_enabled)) - .build(); - - group.add(&format_messages); - - - // Show avatars preference - - let show_avatars = SwitchRow::builder() - .title("Show avatars") - .subtitle("Enables new messages UI") - .active(ctx.config(|o| o.new_ui_enabled)) - .build(); - - group.add(&show_avatars); - page.add(&group); - - - let group = PreferencesGroup::builder() - .title("Interface") - .description("General interface preferences (restart after changing)") - .build(); - - - // Remove GUI shit preference - - let remove_gui_shit = SwitchRow::builder() - .title("Remove GUI shit") - .subtitle("Removes calendar, konata and clock") - .active(ctx.config(|o| o.remove_gui_shit)) - .build(); - - group.add(&remove_gui_shit); - - - // Konata size preference - - let konata_size = SpinRow::builder() - .title("Konata size") - .subtitle("Set konata size percent") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(200.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.konata_size) as f64) - .build()) - .build(); - - group.add(&konata_size); - - - // Enable notifications preference - - let enable_notifications = SwitchRow::builder() - .title("Enable notifications") - .subtitle("Send notifications on chat and system messages") - .active(ctx.config(|o| o.notifications_enabled)) - .build(); - - group.add(&enable_notifications); - page.add(&group); - - - dialog.add(&page); - - - dialog.connect_closed(move |_| { - let config = Config { - host: host.text().to_string(), - name: { - let name = name.text().to_string(); - - if name.is_empty() { - None - } else { - Some(name) - } - }, - avatar: { - let avatar = avatar.text().to_string(); - - if avatar.is_empty() { - None - } else { - Some(avatar) - } - }, - message_format: message_format.text().to_string(), - update_time: update_interval.value() as usize, - oof_update_time: update_interval_oof.value() as usize, - konata_size: konata_size.value() as usize, - max_messages: messages_limit.value() as usize, - max_avatar_size: max_avatar_size.value() as u64, - hide_my_ip: hide_my_ip.is_active(), - remove_gui_shit: remove_gui_shit.is_active(), - show_other_ip: show_ips.is_active(), - chunked_enabled: chunked_reading.is_active(), - formatting_enabled: format_messages.is_active(), - commands_enabled: enable_commands.is_active(), - notifications_enabled: enable_notifications.is_active(), - new_ui_enabled: show_avatars.is_active(), - debug_logs: debug_logs.is_active(), - proxy: { - let proxy = proxy.text().to_string(); - - if proxy.is_empty() { - None - } else { - Some(proxy) - } - }, - }; - ctx.set_config(&config); - try_save_config(get_config_path(), &config); - update_window_title(ctx.clone()); - }); - - dialog.present(app.active_window().as_ref()); -} - -fn build_menu(ctx: Arc, app: &Application) -> Menu { - let menu = Menu::new(); - - menu.append(Some("Settings"), Some("app.settings")); - menu.append(Some("About"), Some("app.about")); - menu.append(Some("Close"), Some("app.close")); - - app.add_action_entries([ - ActionEntry::builder("settings") - .activate(clone!( - #[weak] - ctx, - move |a: &Application, _, _| { - open_settings(ctx, a); - } - )) - .build(), - ActionEntry::builder("close") - .activate(move |a: &Application, _, _| { - a.quit(); - }) - .build(), - ActionEntry::builder("about") - .activate(clone!( - #[weak] - app, - move |_, _, _| { - let dialog = adw::AboutDialog::builder() - .developer_name("MeexReay") - .license(glib::markup_escape_text(include_str!("../../LICENSE"))) - .comments("better RAC client") - .website("https://github.com/MeexReay/bRAC") - .application_name("bRAC") - .application_icon("ru.themixray.bRAC") - .version(crate_version!()) - .build(); - dialog.present(app.active_window().as_ref()); - } - )) - .build(), - ]); - - menu -} - -fn build_ui(ctx: Arc, app: &Application) -> UiModel { - let is_dark_theme = if let Some(settings) = Settings::default() { - settings.is_gtk_application_prefer_dark_theme() - || settings - .gtk_theme_name() - .map(|o| o.to_lowercase().contains("dark")) - .unwrap_or_default() - } else { - false - }; - - #[cfg(target_os = "windows")] - let is_dark_theme = true; - - let main_box = GtkBox::new(Orientation::Vertical, 5); - - let header = HeaderBar::new(); - - header.pack_end(&MenuButton::builder() - .icon_name("open-menu-symbolic") - .menu_model(&build_menu(ctx.clone(), &app)) - .build()); - - main_box.append(&header); - - main_box.set_css_classes(&["main-box"]); - - let widget_box_overlay = Overlay::new(); - - let widget_box = GtkBox::new(Orientation::Horizontal, 5); - - widget_box.set_css_classes(&["widget_box"]); - - let remove_gui_shit = ctx.config(|c| c.remove_gui_shit); - - if !remove_gui_shit { - widget_box.append( - &Calendar::builder() - .css_classes(["calendar"]) - .show_heading(false) - .can_target(false) - .build(), - ); - } - - let server_list_vbox = GtkBox::new(Orientation::Vertical, 5); - - let server_list = ListBox::new(); - - for url in SERVER_LIST.iter() { - let url = url.to_string(); - - let label = Label::builder().label(&url).halign(Align::Start).build(); - - let click = GestureClick::new(); - - click.connect_pressed(clone!( - #[weak] - ctx, - move |_, _, _, _| { - let mut config = ctx.config.read().unwrap().clone(); - config.host = url.clone(); - ctx.set_config(&config); - try_save_config(get_config_path(), &config); - update_window_title(ctx.clone()); - } - )); - - label.add_controller(click); - - server_list.append(&label); - } - - server_list_vbox.append(&Label::builder().label("Server List:").build()); - - server_list_vbox.append(&server_list); - - widget_box.append(&server_list_vbox); - - if !remove_gui_shit { - let fixed = Fixed::new(); - fixed.set_can_target(false); - - let konata_size = ctx.config(|c| c.konata_size) as i32; - - let konata = - Picture::for_pixbuf(&load_pixbuf(include_bytes!("images/konata.png")).unwrap()); - konata.set_size_request(174 * konata_size / 100, 127 * konata_size / 100); - - fixed.put( - &konata, - (499 - 174 * konata_size / 100) as f64, - (131 - 127 * konata_size / 100) as f64, - ); - - let logo_gif = include_bytes!("images/logo.gif"); - - let logo = Picture::for_pixbuf(&load_pixbuf(logo_gif).unwrap()); - logo.set_size_request(152 * konata_size / 100, 64 * konata_size / 100); - - let logo_anim = PixbufAnimation::from_stream( - &MemoryInputStream::from_bytes(&glib::Bytes::from(logo_gif)), - None::<&adw::gtk::gio::Cancellable>, - ) - .unwrap() - .iter(Some(SystemTime::now())); - - timeout_add_local(Duration::from_millis(30), { - let logo = logo.clone(); - let logo_anim = logo_anim.clone(); - let ctx = ctx.clone(); - - move || { - if ctx.is_focused.load(Ordering::SeqCst) { - logo.set_pixbuf(Some(&logo_anim.pixbuf())); - logo_anim.advance(SystemTime::now()); - } - ControlFlow::Continue - } - }); - - // 262, 4 - fixed.put( - &logo, - (436 - 174 * konata_size / 100) as f64, - (131 - 127 * konata_size / 100) as f64, - ); - - let time = Label::builder() - .label(&Local::now().format("%H:%M").to_string()) - .justify(Justification::Right) - .css_classes(["time"]) - .build(); - - timeout_add_local(Duration::from_secs(1), { - let time = time.clone(); - - move || { - time.set_label(&Local::now().format("%H:%M").to_string()); - - ControlFlow::Continue - } - }); - - fixed.put(&time, 432.0, 4.0); - fixed.set_halign(Align::End); - - widget_box_overlay.add_overlay(&fixed); - } - - widget_box_overlay.set_child(Some(&widget_box)); - - main_box.append(&widget_box_overlay); - - let chat_box = GtkBox::new(Orientation::Vertical, 2); - - chat_box.set_css_classes(&["chat-box"]); - - let chat_scrolled = ScrolledWindow::builder() - .child(&chat_box) - .vexpand(true) - .hexpand(true) - .margin_bottom(5) - .margin_end(5) - .margin_start(5) - .propagate_natural_height(true) - .build(); - - main_box.append(&chat_scrolled); - - let send_box = GtkBox::new(Orientation::Horizontal, 5); - - send_box.set_margin_bottom(5); - send_box.set_margin_end(5); - send_box.set_margin_start(5); - - let text_entry = Entry::builder() - .placeholder_text("Message") - .css_classes(["send-button"]) - .hexpand(true) - .build(); - - send_box.append(&text_entry); - - let send_btn = Button::builder() - .label("Send") - .css_classes(["send-text"]) - .cursor(&Cursor::from_name("pointer", None).unwrap()) - .build(); - - send_btn.connect_clicked(clone!( - #[weak] - text_entry, - #[weak] - ctx, - move |_| { - let text = text_entry.text().clone(); - - if text.is_empty() { - return; - } - - text_entry.set_text(""); - - thread::spawn({ - move || { - if let Err(e) = on_send_message(ctx.clone(), &text) { - if ctx.config(|o| o.debug_logs) { - let msg = format!("Send message error: {}", e.to_string()).to_string(); - add_chat_messages(ctx.clone(), vec![msg]); - } - } - } - }); - } - )); - - text_entry.connect_activate(clone!( - #[weak] - text_entry, - #[weak] - ctx, - move |_| { - let text = text_entry.text().clone(); - - if text.is_empty() { - return; - } - - text_entry.set_text(""); - - thread::spawn({ - move || { - if let Err(e) = on_send_message(ctx.clone(), &text) { - if ctx.config(|o| o.debug_logs) { - let msg = format!("Send message error: {}", e.to_string()).to_string(); - add_chat_messages(ctx.clone(), vec![msg]); - } - } - } - }); - } - )); - - send_box.append(&send_btn); - - main_box.append(&send_box); - - let scrolled_window_weak = Downgrade::downgrade(&chat_scrolled); - - timeout_add_local_once(Duration::ZERO, { - let scrolled_window_weak = scrolled_window_weak.clone(); - - move || { - if let Some(o) = scrolled_window_weak.upgrade() { - o.vadjustment() - .set_value(o.vadjustment().upper() - o.vadjustment().page_size()); - } - } - }); - - let window = ApplicationWindow::builder() - .application(app) - .title(format!( - "bRAC - Connected to {} as {}", - ctx.config(|o| o.host.clone()), - &ctx.name() - )) - .default_width(500) - .default_height(500) - .resizable(true) - .decorated(true) - .show_menubar(true) - .content(&main_box) - .build(); - - window.connect_default_width_notify({ - let scrolled_window_weak = scrolled_window_weak.clone(); - - move |_| { - let scrolled_window_weak = scrolled_window_weak.clone(); - timeout_add_local_once(Duration::ZERO, move || { - if let Some(o) = scrolled_window_weak.upgrade() { - o.vadjustment() - .set_value(o.vadjustment().upper() - o.vadjustment().page_size()); - } - }); - } - }); - - window.present(); - - UiModel { - is_dark_theme, - chat_scrolled, - chat_box, - app: app.clone(), - window: window.clone(), - #[cfg(feature = "libnotify")] - notifications: Arc::new(RwLock::new(Vec::::new())), - #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] - notifications: Arc::new(RwLock::new(Vec::::new())), - avatars: Arc::new(Mutex::new(HashMap::new())), - latest_sign: Arc::new(AtomicU64::new(0)) - } -} - -fn setup(_: &Application, ctx: Arc, ui: UiModel) { - let (sender, receiver) = channel(); - - *ctx.sender.write().unwrap() = Some(Arc::new(sender)); - - run_recv_loop(ctx.clone()); - - ui.window.connect_notify(Some("is-active"), { - let ctx = ctx.clone(); - - move |a, _| { - let is_focused = a.is_active(); - - ctx.is_focused.store(is_focused, Ordering::SeqCst); - - if is_focused { - thread::spawn({ - let ctx = ctx.clone(); - move || { - make_recv_tick(ctx.clone()); - } - }); - - #[cfg(not(feature = "notify-rust"))] - GLOBAL.with(|global| { - if let Some(ui) = &*global.borrow() { - #[cfg(feature = "libnotify")] - for i in ui.notifications.read().unwrap().clone() { - i.close().expect("libnotify close error"); - } - #[cfg(not(feature = "libnotify"))] - for i in ui.notifications.read().unwrap().clone() { - ui.app.withdraw_notification(&i); - } - } - }); - } - } - }); - - GLOBAL.with(|global| { - *global.borrow_mut() = Some(ui); - }); - - thread::spawn({ - let ctx = ctx.clone(); - move || { - while let Ok((messages, clear)) = receiver.recv() { - println!("got chat messages: {}", messages.len()); - let ctx = ctx.clone(); - let messages = Arc::new(messages); - - timeout_add_once(Duration::ZERO, { - let messages = messages.clone(); - - move || { - GLOBAL.with(|global| { - if let Some(ui) = &*global.borrow() { - if clear { - while let Some(row) = ui.chat_box.last_child() { - ui.chat_box.remove(&row); - } - } - - for message in messages.iter() { - on_add_message(ctx.clone(), &ui, message.to_string(), !clear); - } - } - }); - - if ctx.config(|o| !o.new_ui_enabled) { - return; - } - - thread::spawn(move || { - for message in messages.iter() { - let Some(avatar_url) = grab_avatar(message) else { continue }; - let avatar_id = get_avatar_id(&avatar_url); - - let Some(avatar) = load_avatar(&avatar_url, ctx.config(|o| o.proxy.clone()), ctx.config(|o| o.max_avatar_size as usize)) else { println!("cant load avatar: {avatar_url} request error"); continue }; - let Ok(pixbuf) = load_pixbuf(&avatar) else { println!("cant load avatar: {avatar_url} pixbuf error"); continue; }; - let Some(pixbuf) = pixbuf.scale_simple(32, 32, InterpType::Bilinear) else { println!("cant load avatar: {avatar_url} scale image error"); continue }; - let texture = Texture::for_pixbuf(&pixbuf); - - timeout_add_once(Duration::ZERO, { - move || { - GLOBAL.with(|global| { - if let Some(ui) = &*global.borrow() { - if let Some(pics) = ui.avatars.lock().unwrap().remove(&avatar_id) { - for pic in pics { - pic.set_custom_image(Some(&texture)); - } - } - } - }); - } - }); - } - }); - } - }); - } - } - }); -} - -fn load_css(is_dark_theme: bool) { - let provider = CssProvider::new(); - provider.load_from_data(&format!( - "{}\n{}", - if is_dark_theme { - include_str!("styles/dark.css") - } else { - include_str!("styles/light.css") - }, - include_str!("styles/style.css") - )); - - gtk::style_context_add_provider_for_display( - &Display::default().expect("Could not connect to a display."), - &provider, - gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); -} - -#[cfg(feature = "notify-rust")] -fn send_notification(_: Arc, _: &UiModel, title: &str, message: &str) { - use notify_rust::{Notification, Timeout}; - - Notification::new() - .summary(title) - .body(message) - .auto_icon() - .appname("bRAC") - .timeout(Timeout::Default) // this however is - .show() - .expect("notify-rust send error"); -} - -#[cfg(feature = "libnotify")] -fn send_notification(_: Arc, ui: &UiModel, title: &str, message: &str) { - use libnotify::Notification; - - let notification = Notification::new(title, message, None); - notification.set_app_name("bRAC"); - let pixbuf_loader = gdk_pixbuf::PixbufLoader::new(); - pixbuf_loader - .loader_write(include_bytes!("images/icon.png")) - .unwrap(); - pixbuf_loader.close().unwrap(); - notification.set_image_from_pixbuf(&pixbuf_loader.get_pixbuf().unwrap()); - notification.show().expect("libnotify send error"); - - ui.notifications.write().unwrap().push(notification); -} - -#[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] -fn send_notification(_: Arc, ui: &UiModel, title: &str, message: &str) { - use std::{ - hash::{DefaultHasher, Hasher}, - time::UNIX_EPOCH, - }; - - use gtk::gio::Notification; - - let mut hash = DefaultHasher::new(); - hash.write(title.as_bytes()); - hash.write(message.as_bytes()); - - let id = format!( - "bRAC-{}-{}", - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_millis(), - hash.finish() - ); - - let notif = Notification::new(title); - notif.set_body(Some(&message)); - ui.app.send_notification(Some(&id), ¬if); - - ui.notifications.write().unwrap().push(id); -} - -fn get_message_box( - ctx: Arc, - ui: &UiModel, - message: String, - notify: bool, - formatting_enabled: bool, -) -> GtkBox { - // TODO: softcode these colors - - let (ip_color, date_color, text_color) = if ui.is_dark_theme { - ("#494949", "#929292", "#FFFFFF") - } else { - ("#585858", "#292929", "#000000") - }; - - let mut label = String::new(); - - if let (true, Some((date, ip, content, nick, _))) = - (formatting_enabled, parse_message(message.clone())) - { - if let Some(ip) = ip { - if ctx.config(|o| o.show_other_ip) { - label.push_str(&format!( - "{} ", - glib::markup_escape_text(&ip) - )); - } - } - - label.push_str(&format!( - "[{}] ", - glib::markup_escape_text(&date) - )); - - if let Some((name, color)) = nick { - label.push_str(&format!( - "<{}> ", - color.to_uppercase(), - glib::markup_escape_text(&name) - )); - - if notify && !ui.window.is_active() { - if ctx.config(|o| o.chunked_enabled) { - send_notification( - ctx.clone(), - ui, - &format!("{}'s Message", &name), - &glib::markup_escape_text(&content), - ); - } - } - } else { - if notify && !ui.window.is_active() { - if ctx.config(|o| o.chunked_enabled) { - send_notification(ctx.clone(), ui, "System Message", &content); - } - } - } - - label.push_str(&format!( - "{}", - glib::markup_escape_text(&content) - )); - } else { - label.push_str(&format!( - "{}", - glib::markup_escape_text(&message) - )); - - if notify && !ui.window.is_active() { - if ctx.config(|o| o.chunked_enabled) { - send_notification(ctx.clone(), ui, "Chat Message", &message); - } - } - } - - let hbox = GtkBox::new(Orientation::Horizontal, 2); - - hbox.append( - &Label::builder() - .label(&label) - .halign(Align::Start) - .valign(Align::Start) - .selectable(true) - .wrap(true) - .wrap_mode(WrapMode::WordChar) - .use_markup(true) - .build(), - ); - - hbox.set_hexpand(true); - - hbox -} - -fn get_avatar_id(url: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - hasher.write(url.as_bytes()); - hasher.finish() -} - -fn load_avatar(url: &str, proxy: Option, response_limit: usize) -> Option> { - let client = if let Some(proxy) = proxy { - let proxy = if proxy.starts_with("socks5://") { - proxy - } else { - format!("socks5://{proxy}") - }; - - reqwest::blocking::Client::builder() - .proxy(reqwest::Proxy::all(&proxy).ok()?) - .build().ok()? - } else { - reqwest::blocking::Client::new() - }; - - client.get(url).send().ok() - .and_then(|mut resp| { - let mut data = Vec::new(); - let mut length = 0; - - loop { - if length >= response_limit { - break; - } - let mut buf = vec![0; (response_limit - length).min(1024)]; - let now_len = resp.read(&mut buf).ok()?; - if now_len == 0 { - break; - } - buf.truncate(now_len); - length += now_len; - data.append(&mut buf); - } - - Some(data) - }) -} - -fn open_avatar_popup(avatar: String, avatar_picture: &Avatar) { - let display = Display::default().unwrap(); - let clipboard = display.clipboard(); - - let popover = Popover::new(); - - let button = Button::with_label("Copy Link"); - button.connect_clicked(clone!( - #[weak] clipboard, - #[weak] popover, - #[strong] avatar, - move |_| { - clipboard.set_text(avatar.as_str()); - popover.popdown(); - }) - ); - - let vbox = GtkBox::builder() - .orientation(Orientation::Vertical) - .spacing(6) - .build(); - vbox.append(&button); - - popover.set_child(Some(&vbox)); - popover.set_parent(avatar_picture); - popover.popup(); -} - -fn get_new_message_box( - ctx: Arc, - ui: &UiModel, - message: String, - notify: bool, - formatting_enabled: bool -) -> Overlay { - // TODO: softcode these colors - - let (ip_color, date_color, text_color) = if ui.is_dark_theme { - ("#494949", "#929292", "#FFFFFF") - } else { - ("#585858", "#292929", "#000000") - }; - - let latest_sign = ui.latest_sign.load(Ordering::SeqCst); - - let (date, ip, content, name, color, avatar, avatar_id) = - if let (true, Some((date, ip, content, nick, avatar))) = - (formatting_enabled, parse_message(message.clone())) - { - ( - date, - ip, - content, - nick.as_ref() - .map(|o| o.0.to_string()) - .unwrap_or("System".to_string()), - nick.as_ref() - .map(|o| o.1.to_string()) - .unwrap_or("#DDDDDD".to_string()), - avatar.clone(), - avatar.map(|o| get_avatar_id(&o)).unwrap_or_default() - ) - } else { - ( - Local::now().format("%d.%m.%Y %H:%M").to_string(), - None, - message, - "System".to_string(), - "#DDDDDD".to_string(), - None, - 0 - ) - }; - - if notify && !ui.window.is_active() { - if ctx.config(|o| o.chunked_enabled) { - send_notification( - ctx.clone(), - ui, - &if name == "System" { - "System Message".to_string() - } else { - format!("{}'s Message", name) - }, - &glib::markup_escape_text(&content), - ); - } - } - - let sign = get_message_sign(&name, &date); - - let squashed = latest_sign == sign; - - ui.latest_sign.store(sign, Ordering::SeqCst); - - let overlay = Overlay::new(); - - if !squashed { - let fixed = Fixed::new(); - fixed.set_can_target(false); - - let avatar_picture = Avatar::builder() - .text(&name) - .show_initials(true) - .size(32) - .build(); - - avatar_picture.set_vexpand(false); - avatar_picture.set_hexpand(false); - avatar_picture.set_valign(Align::Start); - avatar_picture.set_halign(Align::Start); - - if let Some(avatar) = avatar { - let long_gesture = GestureLongPress::builder() - .button(BUTTON_PRIMARY) - .build(); - - long_gesture.connect_pressed(clone!( - #[weak] avatar_picture, - #[strong] avatar, - move |_, x, y| { - if x < 32.0 && y > 4.0 && y < 32.0 { - open_avatar_popup(avatar.clone(), &avatar_picture); - } - } - )); - - overlay.add_controller(long_gesture); - - let short_gesture = GestureClick::builder() - .button(BUTTON_SECONDARY) - .build(); - - short_gesture.connect_released(clone!( - #[weak] avatar_picture, - #[strong] avatar, - move |_, _, x, y| { - if x < 32.0 && y > 4.0 && y < 32.0 { - open_avatar_popup(avatar.clone(), &avatar_picture); - } - } - )); - - overlay.add_controller(short_gesture); - } - - if avatar_id != 0 { - let mut lock = ui.avatars.lock().unwrap(); - - if let Some(pics) = lock.get_mut(&avatar_id) { - pics.push(avatar_picture.clone()); - } else { - lock.insert(avatar_id, vec![avatar_picture.clone()]); - } - } - - fixed.put(&avatar_picture, 0.0, 4.0); - - overlay.add_overlay(&fixed); - } - - let vbox = GtkBox::new(Orientation::Vertical, 2); - - if !squashed { - vbox.append(&Label::builder() - .label(format!( - "{} {} {}", - glib::markup_escape_text(&name), - glib::markup_escape_text(&date), - glib::markup_escape_text(&ip.unwrap_or_default()), - )) - .halign(Align::Start) - .valign(Align::Start) - .selectable(true) - .wrap(true) - .wrap_mode(WrapMode::WordChar) - .use_markup(true) - .build()); - } - - vbox.append(&Label::builder() - .label(format!( - "{}", - glib::markup_escape_text(&content) - )) - .halign(Align::Start) - .hexpand(true) - .selectable(true) - .wrap(true) - .wrap_mode(WrapMode::WordChar) - .use_markup(true) - .build()); - - vbox.set_margin_start(37); - vbox.set_hexpand(true); - - overlay.set_child(Some(&vbox)); - - if !squashed { - overlay.set_margin_top(7); - } else { - overlay.set_margin_top(2); - } - - overlay -} - -// creates sign that expires in 0-20 minutes -fn get_message_sign(name: &str, date: &str) -> u64 { - let mut hasher = DefaultHasher::new(); - hasher.write(name.as_bytes()); - hasher.write(date[..date.len()-2].as_bytes()); - hasher.finish() -} - -/// returns message sign -fn on_add_message(ctx: Arc, ui: &UiModel, message: String, notify: bool) { - let notify = notify && ctx.config(|c| c.notifications_enabled); - - let formatting_enabled = ctx.config(|c| c.formatting_enabled); - - let Some(sanitized) = (if formatting_enabled { - sanitize_message(message.clone()) - } else { - Some(message.clone()) - }) else { - return; - }; - - if sanitized.is_empty() { - return; - } - - if ctx.config(|o| o.new_ui_enabled) { - ui.chat_box.append(&get_new_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); - } else { - ui.chat_box.append(&get_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); - }; - - timeout_add_local_once(Duration::from_millis(1000), move || { - GLOBAL.with(|global| { - if let Some(ui) = &*global.borrow() { - let o = &ui.chat_scrolled; - o.vadjustment() - .set_value(o.vadjustment().upper() - o.vadjustment().page_size()); - } - }); - }); -} - -fn make_recv_tick(ctx: Arc) { - if let Err(e) = recv_tick(ctx.clone()) { - if ctx.config(|o| o.debug_logs) { - let _ = print_message( - ctx.clone(), - format!("Print messages error: {}", e.to_string()).to_string(), - ); - } - thread::sleep(Duration::from_secs(1)); - } -} - -fn run_recv_loop(ctx: Arc) { - thread::spawn(move || loop { - make_recv_tick(ctx.clone()); - - thread::sleep(Duration::from_millis( - if ctx.is_focused.load(Ordering::SeqCst) { - ctx.config(|o| o.update_time) as u64 - } else { - ctx.config(|o| o.oof_update_time) as u64 - }, - )); - }); -} - -pub fn run_main_loop(ctx: Arc) { - #[cfg(feature = "libnotify")] - { - libnotify::init("ru.themixray.bRAC").expect("libnotify init error"); - } - - #[cfg(target_os = "windows")] - { - use std::env; - env::set_var("GTK_THEME", "Adwaita:dark"); - } - - let application = Application::builder() - .application_id("ru.themixray.bRAC") - .flags(ApplicationFlags::FLAGS_NONE) - .build(); - - application.connect_activate({ - let ctx = ctx.clone(); - - move |app| { - let ui = build_ui(ctx.clone(), app); - load_css(ui.is_dark_theme); - setup(app, ctx.clone(), ui); - } - }); - - application.connect_startup({ - let ctx = ctx.clone(); - - move |app| { - build_menu(ctx.clone(), app); - } - }); - - application.run_with_args::<&str>(&[]); - - #[cfg(feature = "libnotify")] - { - libnotify::uninit(); - } -} diff --git a/src/chat/images/avatar.png b/src/chat/gui/images/avatar.png similarity index 100% rename from src/chat/images/avatar.png rename to src/chat/gui/images/avatar.png diff --git a/src/chat/images/icon.png b/src/chat/gui/images/icon.png similarity index 100% rename from src/chat/images/icon.png rename to src/chat/gui/images/icon.png diff --git a/src/chat/images/konata.png b/src/chat/gui/images/konata.png similarity index 100% rename from src/chat/images/konata.png rename to src/chat/gui/images/konata.png diff --git a/src/chat/images/logo.gif b/src/chat/gui/images/logo.gif similarity index 100% rename from src/chat/images/logo.gif rename to src/chat/gui/images/logo.gif diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs new file mode 100644 index 0000000..ecea4a1 --- /dev/null +++ b/src/chat/gui/mod.rs @@ -0,0 +1,590 @@ +use std::cell::RefCell; +use std::collections::HashMap; +use std::error::Error; +use std::hash::{DefaultHasher, Hasher}; +use std::io::Read; +use std::path::PathBuf; +use std::sync::atomic::AtomicU64; +use std::sync::Mutex; +use std::sync::{atomic::Ordering, mpsc::channel, Arc, RwLock}; +use std::thread; +use std::time::{Duration, SystemTime}; + +use clap::crate_version; + +use libadwaita::gdk::Texture; +use libadwaita::gtk::gdk_pixbuf::InterpType; +use libadwaita::gtk::MenuButton; +use libadwaita::{ + self as adw, Avatar, HeaderBar +}; +use adw::gdk::Display; +use adw::gio::{ActionEntry, ApplicationFlags, Menu}; +use adw::glib::clone; +use adw::glib::{ + self, source::timeout_add_local_once, timeout_add_once, +}; +use adw::prelude::*; +use adw::{Application, ApplicationWindow}; + +use adw::gtk; +use gtk::gdk_pixbuf::{Pixbuf, PixbufLoader}; +use gtk::{ + Box as GtkBox, + CssProvider, + Orientation, ScrolledWindow, Settings, +}; + +use crate::chat::grab_avatar; + +use super::{ + config::{ + save_config, Config, + }, + ctx::Context, print_message, recv_tick, sanitize_message, +}; + +mod preferences; +mod page; + +use page::*; +use preferences::*; + +pub fn try_save_config(path: PathBuf, config: &Config) { + match save_config(path, config) { + Ok(_) => {}, + Err(e) => { + println!("save config error: {e}") + } + } +} + +struct UiModel { + is_dark_theme: bool, + chat_box: GtkBox, + chat_scrolled: ScrolledWindow, + app: Application, + window: ApplicationWindow, + #[cfg(feature = "libnotify")] + notifications: Arc>>, + #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] + notifications: Arc>>, + avatars: Arc>>>, + latest_sign: Arc +} + +thread_local!( + static GLOBAL: RefCell> = RefCell::new(None); +); + +pub fn clear_chat_messages(ctx: Arc, messages: Vec) { + let _ = ctx + .sender + .read() + .unwrap() + .clone() + .unwrap() + .send((messages, true)); +} + +pub fn add_chat_messages(ctx: Arc, messages: Vec) { + println!("add chat messages: {}", messages.len()); + let _ = ctx + .sender + .read() + .unwrap() + .clone() + .unwrap() + .send((messages, false)); +} + +fn load_pixbuf(data: &[u8]) -> Result> { + let loader = PixbufLoader::new(); + loader.write(data)?; + loader.close()?; + Ok(loader.pixbuf().ok_or("laod pixbuf error")?) +} + +fn update_window_title(ctx: Arc) { + GLOBAL.with(|global| { + if let Some(ui) = &*global.borrow() { + ui.window.set_title(Some(&format!( + "bRAC - Connected to {} as {}", + ctx.config(|o| o.host.clone()), + &ctx.name() + ))) + } + }) +} + +fn build_menu(ctx: Arc, app: &Application) -> Menu { + let menu = Menu::new(); + + menu.append(Some("Settings"), Some("app.settings")); + menu.append(Some("About"), Some("app.about")); + menu.append(Some("Close"), Some("app.close")); + + app.add_action_entries([ + ActionEntry::builder("settings") + .activate(clone!( + #[weak] + ctx, + move |a: &Application, _, _| { + open_settings(ctx, a); + } + )) + .build(), + ActionEntry::builder("close") + .activate(move |a: &Application, _, _| { + a.quit(); + }) + .build(), + ActionEntry::builder("about") + .activate(clone!( + #[weak] + app, + move |_, _, _| { + let dialog = adw::AboutDialog::builder() + .developer_name("MeexReay") + .license(glib::markup_escape_text(include_str!("../../../LICENSE"))) + .comments("better RAC client") + .website("https://github.com/MeexReay/bRAC") + .application_name("bRAC") + .application_icon("ru.themixray.bRAC") + .version(crate_version!()) + .build(); + dialog.present(app.active_window().as_ref()); + } + )) + .build(), + ]); + + menu +} + +fn build_ui(ctx: Arc, app: &Application) -> UiModel { + let is_dark_theme = if let Some(settings) = Settings::default() { + settings.is_gtk_application_prefer_dark_theme() + || settings + .gtk_theme_name() + .map(|o| o.to_lowercase().contains("dark")) + .unwrap_or_default() + } else { + false + }; + + #[cfg(target_os = "windows")] + let is_dark_theme = true; + + let main_box = GtkBox::new(Orientation::Vertical, 0); + + let header = HeaderBar::new(); + + header.pack_end(&MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&build_menu(ctx.clone(), &app)) + .build()); + + main_box.append(&header); + + let (page_box, chat_box, chat_scrolled) = build_page_box(ctx.clone(), app); + + main_box.append(&page_box); + + let window = ApplicationWindow::builder() + .application(app) + .title(format!( + "bRAC - Connected to {} as {}", + ctx.config(|o| o.host.clone()), + &ctx.name() + )) + .default_width(500) + .default_height(500) + .resizable(true) + .decorated(true) + .content(&main_box) + .build(); + + // window.connect_default_width_notify(clone!( + // #[weak] chat_scrolled, + // move |_| { + // timeout_add_local_once(Duration::ZERO, clone!( + // #[weak] chat_scrolled, + // move || { + // let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + // chat_scrolled.vadjustment().set_value(value); + // } + // )); + // } + // )); + + // window.connect_default_height_notify(clone!( + // #[weak] chat_scrolled, + // move |_| { + // timeout_add_local_once(Duration::ZERO, clone!( + // #[weak] chat_scrolled, + // move || { + // let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + // chat_scrolled.vadjustment().set_value(value); + // } + // )); + // } + // )); + + window.present(); + + UiModel { + is_dark_theme, + chat_scrolled, + chat_box, + app: app.clone(), + window: window.clone(), + #[cfg(feature = "libnotify")] + notifications: Arc::new(RwLock::new(Vec::::new())), + #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] + notifications: Arc::new(RwLock::new(Vec::::new())), + avatars: Arc::new(Mutex::new(HashMap::new())), + latest_sign: Arc::new(AtomicU64::new(0)) + } +} + +fn setup(_: &Application, ctx: Arc, ui: UiModel) { + let (sender, receiver) = channel(); + + *ctx.sender.write().unwrap() = Some(Arc::new(sender)); + + run_recv_loop(ctx.clone()); + + ui.window.connect_notify(Some("is-active"), { + let ctx = ctx.clone(); + + move |a, _| { + let is_focused = a.is_active(); + + ctx.is_focused.store(is_focused, Ordering::SeqCst); + + if is_focused { + thread::spawn({ + let ctx = ctx.clone(); + move || { + make_recv_tick(ctx.clone()); + } + }); + + #[cfg(not(feature = "notify-rust"))] + GLOBAL.with(|global| { + if let Some(ui) = &*global.borrow() { + #[cfg(feature = "libnotify")] + for i in ui.notifications.read().unwrap().clone() { + i.close().expect("libnotify close error"); + } + #[cfg(not(feature = "libnotify"))] + for i in ui.notifications.read().unwrap().clone() { + ui.app.withdraw_notification(&i); + } + } + }); + } + } + }); + + GLOBAL.with(|global| { + *global.borrow_mut() = Some(ui); + }); + + thread::spawn({ + let ctx = ctx.clone(); + move || { + while let Ok((messages, clear)) = receiver.recv() { + println!("got chat messages: {}", messages.len()); + let ctx = ctx.clone(); + let messages = Arc::new(messages); + + timeout_add_once(Duration::ZERO, { + let messages = messages.clone(); + + move || { + GLOBAL.with(|global| { + if let Some(ui) = &*global.borrow() { + if clear { + while let Some(row) = ui.chat_box.last_child() { + ui.chat_box.remove(&row); + } + } + + for message in messages.iter() { + on_add_message(ctx.clone(), &ui, message.to_string(), !clear); + } + } + }); + + if ctx.config(|o| !o.new_ui_enabled) { + return; + } + + thread::spawn(move || { + for message in messages.iter() { + let Some(avatar_url) = grab_avatar(message) else { continue }; + let avatar_id = get_avatar_id(&avatar_url); + + let Some(avatar) = load_avatar(&avatar_url, ctx.config(|o| o.proxy.clone()), ctx.config(|o| o.max_avatar_size as usize)) else { println!("cant load avatar: {avatar_url} request error"); continue }; + let Ok(pixbuf) = load_pixbuf(&avatar) else { println!("cant load avatar: {avatar_url} pixbuf error"); continue; }; + let Some(pixbuf) = pixbuf.scale_simple(32, 32, InterpType::Bilinear) else { println!("cant load avatar: {avatar_url} scale image error"); continue }; + let texture = Texture::for_pixbuf(&pixbuf); + + timeout_add_once(Duration::ZERO, { + move || { + GLOBAL.with(|global| { + if let Some(ui) = &*global.borrow() { + if let Some(pics) = ui.avatars.lock().unwrap().remove(&avatar_id) { + for pic in pics { + pic.set_custom_image(Some(&texture)); + } + } + } + }); + } + }); + } + }); + } + }); + } + } + }); +} + +fn load_css(is_dark_theme: bool) { + let provider = CssProvider::new(); + provider.load_from_data(&format!( + "{}\n{}", + if is_dark_theme { + include_str!("styles/dark.css") + } else { + include_str!("styles/light.css") + }, + include_str!("styles/style.css") + )); + + gtk::style_context_add_provider_for_display( + &Display::default().expect("Could not connect to a display."), + &provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); +} + +#[cfg(feature = "notify-rust")] +fn send_notification(_: Arc, _: &UiModel, title: &str, message: &str) { + use notify_rust::{Notification, Timeout}; + + Notification::new() + .summary(title) + .body(message) + .auto_icon() + .appname("bRAC") + .timeout(Timeout::Default) // this however is + .show() + .expect("notify-rust send error"); +} + +#[cfg(feature = "libnotify")] +fn send_notification(_: Arc, ui: &UiModel, title: &str, message: &str) { + use libnotify::Notification; + + let notification = Notification::new(title, message, None); + notification.set_app_name("bRAC"); + let pixbuf_loader = gdk_pixbuf::PixbufLoader::new(); + pixbuf_loader + .loader_write(include_bytes!("images/icon.png")) + .unwrap(); + pixbuf_loader.close().unwrap(); + notification.set_image_from_pixbuf(&pixbuf_loader.get_pixbuf().unwrap()); + notification.show().expect("libnotify send error"); + + ui.notifications.write().unwrap().push(notification); +} + +#[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] +fn send_notification(_: Arc, ui: &UiModel, title: &str, message: &str) { + use std::{ + hash::{DefaultHasher, Hasher}, + time::UNIX_EPOCH, + }; + + use gtk::gio::Notification; + + let mut hash = DefaultHasher::new(); + hash.write(title.as_bytes()); + hash.write(message.as_bytes()); + + let id = format!( + "bRAC-{}-{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_millis(), + hash.finish() + ); + + let notif = Notification::new(title); + notif.set_body(Some(&message)); + ui.app.send_notification(Some(&id), ¬if); + + ui.notifications.write().unwrap().push(id); +} + +fn get_avatar_id(url: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + hasher.write(url.as_bytes()); + hasher.finish() +} + +fn load_avatar(url: &str, proxy: Option, response_limit: usize) -> Option> { + let client = if let Some(proxy) = proxy { + let proxy = if proxy.starts_with("socks5://") { + proxy + } else { + format!("socks5://{proxy}") + }; + + reqwest::blocking::Client::builder() + .proxy(reqwest::Proxy::all(&proxy).ok()?) + .build().ok()? + } else { + reqwest::blocking::Client::new() + }; + + client.get(url).send().ok() + .and_then(|mut resp| { + let mut data = Vec::new(); + let mut length = 0; + + loop { + if length >= response_limit { + break; + } + let mut buf = vec![0; (response_limit - length).min(1024)]; + let now_len = resp.read(&mut buf).ok()?; + if now_len == 0 { + break; + } + buf.truncate(now_len); + length += now_len; + data.append(&mut buf); + } + + Some(data) + }) +} + +// creates sign that expires in 0-20 minutes +fn get_message_sign(name: &str, date: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + hasher.write(name.as_bytes()); + hasher.write(date[..date.len()-2].as_bytes()); + hasher.finish() +} + +/// returns message sign +fn on_add_message(ctx: Arc, ui: &UiModel, message: String, notify: bool) { + let notify = notify && ctx.config(|c| c.notifications_enabled); + + let formatting_enabled = ctx.config(|c| c.formatting_enabled); + + let Some(sanitized) = (if formatting_enabled { + sanitize_message(message.clone()) + } else { + Some(message.clone()) + }) else { + return; + }; + + if sanitized.is_empty() { + return; + } + + if ctx.config(|o| o.new_ui_enabled) { + ui.chat_box.append(&get_new_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + } else { + ui.chat_box.append(&get_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + }; + + timeout_add_local_once(Duration::from_millis(1000), move || { + GLOBAL.with(|global| { + if let Some(ui) = &*global.borrow() { + let o = &ui.chat_scrolled; + o.vadjustment() + .set_value(o.vadjustment().upper() - o.vadjustment().page_size()); + } + }); + }); +} + +fn make_recv_tick(ctx: Arc) { + if let Err(e) = recv_tick(ctx.clone()) { + if ctx.config(|o| o.debug_logs) { + let _ = print_message( + ctx.clone(), + format!("Print messages error: {}", e.to_string()).to_string(), + ); + } + thread::sleep(Duration::from_secs(1)); + } +} + +fn run_recv_loop(ctx: Arc) { + thread::spawn(move || loop { + make_recv_tick(ctx.clone()); + + thread::sleep(Duration::from_millis( + if ctx.is_focused.load(Ordering::SeqCst) { + ctx.config(|o| o.update_time) as u64 + } else { + ctx.config(|o| o.oof_update_time) as u64 + }, + )); + }); +} + +pub fn run_main_loop(ctx: Arc) { + #[cfg(feature = "libnotify")] + { + libnotify::init("ru.themixray.bRAC").expect("libnotify init error"); + } + + #[cfg(target_os = "windows")] + { + use std::env; + env::set_var("GTK_THEME", "Adwaita:dark"); + } + + let application = Application::builder() + .application_id("ru.themixray.bRAC") + .flags(ApplicationFlags::FLAGS_NONE) + .build(); + + application.connect_activate({ + let ctx = ctx.clone(); + + move |app| { + let ui = build_ui(ctx.clone(), app); + load_css(ui.is_dark_theme); + setup(app, ctx.clone(), ui); + } + }); + + application.connect_startup({ + let ctx = ctx.clone(); + + move |app| { + build_menu(ctx.clone(), app); + } + }); + + application.run_with_args::<&str>(&[]); + + #[cfg(feature = "libnotify")] + { + libnotify::uninit(); + } +} diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs new file mode 100644 index 0000000..65029b4 --- /dev/null +++ b/src/chat/gui/page.rs @@ -0,0 +1,575 @@ +use std::sync::{atomic::Ordering, Arc}; +use std::thread; +use std::time::{Duration, SystemTime}; + +use chrono::Local; + +use libadwaita::gdk::{BUTTON_PRIMARY, BUTTON_SECONDARY}; +use libadwaita::gtk::{GestureLongPress, Popover}; +use libadwaita::{ + self as adw, Avatar +}; +use adw::gdk::{Cursor, Display}; +use adw::gio::MemoryInputStream; +use adw::glib::clone; +use adw::glib::{ + self, source::timeout_add_local_once, + timeout_add_local, + ControlFlow, +}; +use adw::prelude::*; +use adw::Application; + +use adw::gtk; +use gtk::gdk_pixbuf::PixbufAnimation; +use gtk::pango::WrapMode; +use gtk::{ + Align, Box as GtkBox, Button, Calendar, Entry, Fixed, GestureClick, Justification, Label, ListBox, + Orientation, Overlay, Picture, ScrolledWindow, +}; + + +use crate::chat::{ + config::get_config_path, + ctx::Context, + on_send_message, parse_message, SERVER_LIST, +}; + +use super::{add_chat_messages, get_avatar_id, get_message_sign, load_pixbuf, send_notification, try_save_config, update_window_title, UiModel}; + +pub fn get_message_box( + ctx: Arc, + ui: &UiModel, + message: String, + notify: bool, + formatting_enabled: bool, +) -> GtkBox { + // TODO: softcode these colors + + let (ip_color, date_color, text_color) = if ui.is_dark_theme { + ("#494949", "#929292", "#FFFFFF") + } else { + ("#585858", "#292929", "#000000") + }; + + let mut label = String::new(); + + if let (true, Some((date, ip, content, nick, _))) = + (formatting_enabled, parse_message(message.clone())) + { + if let Some(ip) = ip { + if ctx.config(|o| o.show_other_ip) { + label.push_str(&format!( + "{} ", + glib::markup_escape_text(&ip) + )); + } + } + + label.push_str(&format!( + "[{}] ", + glib::markup_escape_text(&date) + )); + + if let Some((name, color)) = nick { + label.push_str(&format!( + "<{}> ", + color.to_uppercase(), + glib::markup_escape_text(&name) + )); + + if notify && !ui.window.is_active() { + if ctx.config(|o| o.chunked_enabled) { + send_notification( + ctx.clone(), + ui, + &format!("{}'s Message", &name), + &glib::markup_escape_text(&content), + ); + } + } + } else { + if notify && !ui.window.is_active() { + if ctx.config(|o| o.chunked_enabled) { + send_notification(ctx.clone(), ui, "System Message", &content); + } + } + } + + label.push_str(&format!( + "{}", + glib::markup_escape_text(&content) + )); + } else { + label.push_str(&format!( + "{}", + glib::markup_escape_text(&message) + )); + + if notify && !ui.window.is_active() { + if ctx.config(|o| o.chunked_enabled) { + send_notification(ctx.clone(), ui, "Chat Message", &message); + } + } + } + + let hbox = GtkBox::new(Orientation::Horizontal, 2); + + hbox.append( + &Label::builder() + .label(&label) + .halign(Align::Start) + .valign(Align::Start) + .selectable(true) + .wrap(true) + .wrap_mode(WrapMode::WordChar) + .use_markup(true) + .build(), + ); + + hbox.set_hexpand(true); + + hbox +} + +fn open_avatar_popup(avatar: String, avatar_picture: &Avatar) { + let display = Display::default().unwrap(); + let clipboard = display.clipboard(); + + let popover = Popover::new(); + + let button = Button::with_label("Copy Link"); + button.connect_clicked(clone!( + #[weak] clipboard, + #[weak] popover, + #[strong] avatar, + move |_| { + clipboard.set_text(avatar.as_str()); + popover.popdown(); + }) + ); + + let vbox = GtkBox::builder() + .orientation(Orientation::Vertical) + .spacing(6) + .build(); + vbox.append(&button); + + popover.set_child(Some(&vbox)); + popover.set_parent(avatar_picture); + popover.popup(); +} + +pub fn get_new_message_box( + ctx: Arc, + ui: &UiModel, + message: String, + notify: bool, + formatting_enabled: bool +) -> Overlay { + // TODO: softcode these colors + + let (ip_color, date_color, text_color) = if ui.is_dark_theme { + ("#494949", "#929292", "#FFFFFF") + } else { + ("#585858", "#292929", "#000000") + }; + + let latest_sign = ui.latest_sign.load(Ordering::SeqCst); + + let (date, ip, content, name, color, avatar, avatar_id) = + if let (true, Some((date, ip, content, nick, avatar))) = + (formatting_enabled, parse_message(message.clone())) + { + ( + date, + ip, + content, + nick.as_ref() + .map(|o| o.0.to_string()) + .unwrap_or("System".to_string()), + nick.as_ref() + .map(|o| o.1.to_string()) + .unwrap_or("#DDDDDD".to_string()), + avatar.clone(), + avatar.map(|o| get_avatar_id(&o)).unwrap_or_default() + ) + } else { + ( + Local::now().format("%d.%m.%Y %H:%M").to_string(), + None, + message, + "System".to_string(), + "#DDDDDD".to_string(), + None, + 0 + ) + }; + + if notify && !ui.window.is_active() { + if ctx.config(|o| o.chunked_enabled) { + send_notification( + ctx.clone(), + ui, + &if name == *"System" { + "System Message".to_string() + } else { + format!("{}'s Message", name) + }, + &glib::markup_escape_text(&content), + ); + } + } + + let sign = get_message_sign(&name, &date); + + let squashed = latest_sign == sign; + + ui.latest_sign.store(sign, Ordering::SeqCst); + + let overlay = Overlay::new(); + + if !squashed { + let fixed = Fixed::new(); + fixed.set_can_target(false); + + let avatar_picture = Avatar::builder() + .text(&name) + .show_initials(true) + .size(32) + .build(); + + avatar_picture.set_vexpand(false); + avatar_picture.set_hexpand(false); + avatar_picture.set_valign(Align::Start); + avatar_picture.set_halign(Align::Start); + + if let Some(avatar) = avatar { + let long_gesture = GestureLongPress::builder() + .button(BUTTON_PRIMARY) + .build(); + + long_gesture.connect_pressed(clone!( + #[weak] avatar_picture, + #[strong] avatar, + move |_, x, y| { + if x < 32.0 && y > 4.0 && y < 32.0 { + open_avatar_popup(avatar.clone(), &avatar_picture); + } + } + )); + + overlay.add_controller(long_gesture); + + let short_gesture = GestureClick::builder() + .button(BUTTON_SECONDARY) + .build(); + + short_gesture.connect_released(clone!( + #[weak] avatar_picture, + #[strong] avatar, + move |_, _, x, y| { + if x < 32.0 && y > 4.0 && y < 32.0 { + open_avatar_popup(avatar.clone(), &avatar_picture); + } + } + )); + + overlay.add_controller(short_gesture); + } + + if avatar_id != 0 { + let mut lock = ui.avatars.lock().unwrap(); + + if let Some(pics) = lock.get_mut(&avatar_id) { + pics.push(avatar_picture.clone()); + } else { + lock.insert(avatar_id, vec![avatar_picture.clone()]); + } + } + + fixed.put(&avatar_picture, 0.0, 4.0); + + overlay.add_overlay(&fixed); + } + + let vbox = GtkBox::new(Orientation::Vertical, 2); + + if !squashed { + vbox.append(&Label::builder() + .label(format!( + "{} {} {}", + glib::markup_escape_text(&name), + glib::markup_escape_text(&date), + glib::markup_escape_text(&ip.unwrap_or_default()), + )) + .halign(Align::Start) + .valign(Align::Start) + .selectable(true) + .wrap(true) + .wrap_mode(WrapMode::WordChar) + .use_markup(true) + .build()); + } + + vbox.append(&Label::builder() + .label(format!( + "{}", + glib::markup_escape_text(&content) + )) + .halign(Align::Start) + .hexpand(true) + .selectable(true) + .wrap(true) + .wrap_mode(WrapMode::WordChar) + .use_markup(true) + .build()); + + vbox.set_margin_start(37); + vbox.set_hexpand(true); + + overlay.set_child(Some(&vbox)); + + if !squashed { + overlay.set_margin_top(7); + } else { + overlay.set_margin_top(2); + } + + overlay +} + +/// page_box, chat_box, chat_scrolled +pub fn build_page_box(ctx: Arc, app: &Application) -> (GtkBox, GtkBox, ScrolledWindow) { + let page_box = GtkBox::new(Orientation::Vertical, 5); + page_box.set_css_classes(&["page-box"]); + + page_box.append(&build_widget_box(ctx.clone(), app)); + + let chat_box = GtkBox::new(Orientation::Vertical, 2); + chat_box.set_css_classes(&["chat-box"]); + + let chat_scrolled = ScrolledWindow::builder() + .child(&chat_box) + .vexpand(true) + .hexpand(true) + .margin_bottom(5) + .margin_end(5) + .margin_start(5) + .propagate_natural_height(true) + .build(); + + timeout_add_local_once(Duration::ZERO, clone!( + #[weak] chat_scrolled, + move || { + let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + chat_scrolled.vadjustment().set_value(value); + } + )); + + page_box.append(&chat_scrolled); + + let send_box = GtkBox::new(Orientation::Horizontal, 5); + + send_box.set_margin_bottom(5); + send_box.set_margin_end(5); + send_box.set_margin_start(5); + + let text_entry = Entry::builder() + .placeholder_text("Message") + .css_classes(["send-button"]) + .hexpand(true) + .build(); + + send_box.append(&text_entry); + + let send_btn = Button::builder() + .label("Send") + .css_classes(["send-text"]) + .cursor(&Cursor::from_name("pointer", None).unwrap()) + .build(); + + send_btn.connect_clicked(clone!( + #[weak] text_entry, + #[weak] ctx, + move |_| { + let text = text_entry.text().clone(); + + if text.is_empty() { + return; + } + + text_entry.set_text(""); + + thread::spawn({ + move || { + if let Err(e) = on_send_message(ctx.clone(), &text) { + if ctx.config(|o| o.debug_logs) { + let msg = format!("Send message error: {}", e.to_string()).to_string(); + add_chat_messages(ctx.clone(), vec![msg]); + } + } + } + }); + } + )); + + text_entry.connect_activate(clone!( + #[weak] text_entry, + #[weak] ctx, + move |_| { + let text = text_entry.text().clone(); + + if text.is_empty() { + return; + } + + text_entry.set_text(""); + + thread::spawn({ + move || { + if let Err(e) = on_send_message(ctx.clone(), &text) { + if ctx.config(|o| o.debug_logs) { + let msg = format!("Send message error: {}", e.to_string()).to_string(); + add_chat_messages(ctx.clone(), vec![msg]); + } + } + } + }); + } + )); + + send_box.append(&send_btn); + + page_box.append(&send_box); + + (page_box, chat_box, chat_scrolled) +} + +fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { + let widget_box_overlay = Overlay::new(); + + let widget_box = GtkBox::new(Orientation::Horizontal, 5); + widget_box.set_css_classes(&["widget-box"]); + + let remove_gui_shit = ctx.config(|c| c.remove_gui_shit); + + if !remove_gui_shit { + widget_box.append( + &Calendar::builder() + .css_classes(["calendar"]) + .show_heading(false) + .can_target(false) + .build(), + ); + } + + let server_list_vbox = GtkBox::new(Orientation::Vertical, 5); + + let server_list = ListBox::new(); + + for url in SERVER_LIST.iter() { + let url = url.to_string(); + + let label = Label::builder().label(&url).halign(Align::Start).build(); + + let click = GestureClick::new(); + + click.connect_pressed(clone!( + #[weak] ctx, + move |_, _, _, _| { + let mut config = ctx.config.read().unwrap().clone(); + config.host = url.clone(); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + update_window_title(ctx.clone()); + } + )); + + label.add_controller(click); + + server_list.append(&label); + } + + server_list_vbox.append(&Label::builder().label("Server List:").build()); + + server_list_vbox.append(&server_list); + + widget_box.append(&server_list_vbox); + + if !remove_gui_shit { + let fixed = Fixed::new(); + fixed.set_can_target(false); + + let konata_size = ctx.config(|c| c.konata_size) as i32; + + let konata = + Picture::for_pixbuf(&load_pixbuf(include_bytes!("images/konata.png")).unwrap()); + konata.set_size_request(174 * konata_size / 100, 127 * konata_size / 100); + + fixed.put( + &konata, + (499 - 174 * konata_size / 100) as f64, + (131 - 127 * konata_size / 100) as f64, + ); + + let logo_gif = include_bytes!("images/logo.gif"); + + let logo = Picture::for_pixbuf(&load_pixbuf(logo_gif).unwrap()); + logo.set_size_request(152 * konata_size / 100, 64 * konata_size / 100); + + let logo_anim = PixbufAnimation::from_stream( + &MemoryInputStream::from_bytes(&glib::Bytes::from(logo_gif)), + None::<&adw::gtk::gio::Cancellable>, + ) + .unwrap() + .iter(Some(SystemTime::now())); + + timeout_add_local(Duration::from_millis(30), { + let logo = logo.clone(); + let logo_anim = logo_anim.clone(); + let ctx = ctx.clone(); + + move || { + if ctx.is_focused.load(Ordering::SeqCst) { + logo.set_pixbuf(Some(&logo_anim.pixbuf())); + logo_anim.advance(SystemTime::now()); + } + ControlFlow::Continue + } + }); + + // 262, 4 + fixed.put( + &logo, + (436 - 174 * konata_size / 100) as f64, + (131 - 127 * konata_size / 100) as f64, + ); + + let time = Label::builder() + .label(&Local::now().format("%H:%M").to_string()) + .justify(Justification::Right) + .css_classes(["time"]) + .build(); + + timeout_add_local(Duration::from_secs(1), { + let time = time.clone(); + + move || { + time.set_label(&Local::now().format("%H:%M").to_string()); + + ControlFlow::Continue + } + }); + + fixed.put(&time, 432.0, 4.0); + fixed.set_halign(Align::End); + + widget_box_overlay.add_overlay(&fixed); + } + + widget_box_overlay.set_child(Some(&widget_box)); + + widget_box_overlay +} + diff --git a/src/chat/gui/preferences.rs b/src/chat/gui/preferences.rs new file mode 100644 index 0000000..949a362 --- /dev/null +++ b/src/chat/gui/preferences.rs @@ -0,0 +1,453 @@ +use std::sync::Arc; + +use libadwaita::gtk::Adjustment; +use libadwaita::{ + self as adw, ActionRow, ButtonRow, EntryRow, PreferencesDialog, PreferencesGroup, PreferencesPage, SpinRow, SwitchRow +}; +use adw::gdk::Display; +use adw::glib::clone; +use adw::glib::{ + self, +}; +use adw::prelude::*; +use adw::Application; + +use adw::gtk; +use gtk::Button; + + +use crate::chat::{ + config::{ + get_config_path, Config, + }, + ctx::Context, +}; + +use super::{try_save_config, update_window_title}; + + +pub fn open_settings(ctx: Arc, app: &Application) { + let dialog = PreferencesDialog::builder().build(); + + + let page = PreferencesPage::builder() + .title("General") + .icon_name("avatar-default-symbolic") + .build(); + + let group = PreferencesGroup::builder() + .title("User Profile") + .description("Profile preferences") + .build(); + + + // Name preference + + let name = EntryRow::builder() + .title("Name") + .text(ctx.config(|o| o.name.clone()).unwrap_or_default()) + .build(); + + group.add(&name); + + + // Avatar preference + + let avatar = EntryRow::builder() + .title("Avatar Link") + .text(ctx.config(|o| o.avatar.clone()).unwrap_or_default()) + .build(); + + group.add(&avatar); + + + page.add(&group); + + + + let group = PreferencesGroup::builder() + .title("Server") + .description("Connection preferences") + .build(); + + + // Host preference + + let host = EntryRow::builder() + .title("Host") + .text(ctx.config(|o| o.host.clone())) + .build(); + + group.add(&host); + + + // Messages limit preference + + let messages_limit = SpinRow::builder() + .title("Messages limit") + .adjustment(&Adjustment::builder() + .lower(1.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.max_messages) as f64) + .build()) + .build(); + + group.add(&messages_limit); + + + // Update interval preference + + let update_interval = SpinRow::builder() + .title("Update interval") + .subtitle("In milliseconds") + .adjustment(&Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.update_time) as f64) + .build()) + .build(); + + group.add(&update_interval); + + + // Update interval OOF preference + + let update_interval_oof = SpinRow::builder() + .title("Update interval when unfocused") + .subtitle("In milliseconds") + .adjustment(&Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.oof_update_time) as f64) + .build()) + .build(); + + group.add(&update_interval_oof); + + page.add(&group); + + + + let group = PreferencesGroup::builder() + .title("Config") + .description("Configuration tools") + .build(); + + let display = Display::default().unwrap(); + let clipboard = display.clipboard(); + + let config_path = ActionRow::builder() + .title("Config path") + .subtitle(get_config_path().to_string_lossy()) + .css_classes(["property", "monospace"]) + .build(); + + let config_path_copy = Button::from_icon_name("edit-copy-symbolic"); + + // config_path_copy.set_css_classes(&["circular"]); + config_path_copy.set_margin_top(10); + config_path_copy.set_margin_bottom(10); + config_path_copy.connect_clicked(clone!( + #[weak] clipboard, + move |_| { + if let Some(text) = get_config_path().to_str() { + clipboard.set_text(text); + } + } + )); + + config_path.add_suffix(&config_path_copy); + config_path.set_activatable(false); + + group.add(&config_path); + + // Reset button + + let reset_button = ButtonRow::builder() + .title("Reset all") + .build(); + + reset_button.connect_activated(clone!( + #[weak] ctx, + #[weak] app, + #[weak] dialog, + move |_| { + dialog.close(); + let config = Config::default(); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + open_settings(ctx, &app); + } + )); + + group.add(&reset_button); + + page.add(&group); + + dialog.add(&page); + + + + let page = PreferencesPage::builder() + .title("Protocol") + .icon_name("network-wired-symbolic") + .build(); + + let group = PreferencesGroup::builder() + .title("Network") + .description("Network preferences") + .build(); + + + // Proxy preference + + let proxy = EntryRow::builder() + .title("Socks proxy") + .text(ctx.config(|o| o.proxy.clone()).unwrap_or_default()) + .build(); + + group.add(&proxy); + + + // Max avatar size preference + + let max_avatar_size = SpinRow::builder() + .title("Max avatar size") + .subtitle("Maximum avatar size in bytes") + .adjustment(&Adjustment::builder() + .lower(0.0) + .upper(1074790400.0) + .page_increment(1024.0) + .step_increment(1024.0) + .value(ctx.config(|o| o.max_avatar_size) as f64) + .build()) + .build(); + + group.add(&max_avatar_size); + + + page.add(&group); + + + let group = PreferencesGroup::builder() + .title("Protocol") + .description("Rac protocol preferences") + .build(); + + + // Message format preference + + let message_format = EntryRow::builder() + .title("Message format") + .text(ctx.config(|o| o.message_format.clone())) + .build(); + + group.add(&message_format); + + page.add(&group); + + + // Hide IP preference + + let hide_my_ip = SwitchRow::builder() + .title("Hide IP") + .subtitle("Hides only for clRAC and other dummy clients") + .active(ctx.config(|o| o.hide_my_ip)) + .build(); + + group.add(&hide_my_ip); + + + // Chunked reading preference + + let chunked_reading = SwitchRow::builder() + .title("Chunked reading") + .subtitle("Read messages in chunks (less traffic usage, less compatibility)") + .active(ctx.config(|o| o.chunked_enabled)) + .build(); + + group.add(&chunked_reading); + + + // Enable commands preference + + let enable_commands = SwitchRow::builder() + .title("Enable commands") + .subtitle("Enable slash commands (eg. /login) on client-side") + .active(ctx.config(|o| o.commands_enabled)) + .build(); + + group.add(&enable_commands); + + + page.add(&group); + + dialog.add(&page); + + + let page = PreferencesPage::builder() + .title("Interface") + .icon_name("applications-graphics-symbolic") + .build(); + + let group = PreferencesGroup::builder() + .title("Messages") + .description("Messages render preferences") + .build(); + + + // Debug logs preference + + let debug_logs = SwitchRow::builder() + .title("Debug logs") + .subtitle("Print debug logs to the chat") + .active(ctx.config(|o| o.debug_logs)) + .build(); + + group.add(&debug_logs); + + + // Show IPs preference + + let show_ips = SwitchRow::builder() + .title("Show IPs") + .subtitle("Show authors IP addresses if possible") + .active(ctx.config(|o| o.show_other_ip)) + .build(); + + group.add(&show_ips); + + + // Format messages preference + + let format_messages = SwitchRow::builder() + .title("Format messages") + .subtitle("Disable to see raw messages") + .active(ctx.config(|o| o.formatting_enabled)) + .build(); + + group.add(&format_messages); + + + // Show avatars preference + + let show_avatars = SwitchRow::builder() + .title("Show avatars") + .subtitle("Enables new messages UI") + .active(ctx.config(|o| o.new_ui_enabled)) + .build(); + + group.add(&show_avatars); + page.add(&group); + + + let group = PreferencesGroup::builder() + .title("Interface") + .description("General interface preferences (restart after changing)") + .build(); + + + // Remove GUI shit preference + + let remove_gui_shit = SwitchRow::builder() + .title("Remove GUI shit") + .subtitle("Removes calendar, konata and clock") + .active(ctx.config(|o| o.remove_gui_shit)) + .build(); + + group.add(&remove_gui_shit); + + + // Konata size preference + + let konata_size = SpinRow::builder() + .title("Konata size") + .subtitle("Set konata size percent") + .adjustment(&Adjustment::builder() + .lower(0.0) + .upper(200.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.konata_size) as f64) + .build()) + .build(); + + group.add(&konata_size); + + + // Enable notifications preference + + let enable_notifications = SwitchRow::builder() + .title("Enable notifications") + .subtitle("Send notifications on chat and system messages") + .active(ctx.config(|o| o.notifications_enabled)) + .build(); + + group.add(&enable_notifications); + page.add(&group); + + + dialog.add(&page); + + + dialog.connect_closed(move |_| { + let config = Config { + host: host.text().to_string(), + name: { + let name = name.text().to_string(); + + if name.is_empty() { + None + } else { + Some(name) + } + }, + avatar: { + let avatar = avatar.text().to_string(); + + if avatar.is_empty() { + None + } else { + Some(avatar) + } + }, + message_format: message_format.text().to_string(), + update_time: update_interval.value() as usize, + oof_update_time: update_interval_oof.value() as usize, + konata_size: konata_size.value() as usize, + max_messages: messages_limit.value() as usize, + max_avatar_size: max_avatar_size.value() as u64, + hide_my_ip: hide_my_ip.is_active(), + remove_gui_shit: remove_gui_shit.is_active(), + show_other_ip: show_ips.is_active(), + chunked_enabled: chunked_reading.is_active(), + formatting_enabled: format_messages.is_active(), + commands_enabled: enable_commands.is_active(), + notifications_enabled: enable_notifications.is_active(), + new_ui_enabled: show_avatars.is_active(), + debug_logs: debug_logs.is_active(), + proxy: { + let proxy = proxy.text().to_string(); + + if proxy.is_empty() { + None + } else { + Some(proxy) + } + }, + }; + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + update_window_title(ctx.clone()); + }); + + dialog.present(app.active_window().as_ref()); +} + + diff --git a/src/chat/styles/dark.css b/src/chat/gui/styles/dark.css similarity index 100% rename from src/chat/styles/dark.css rename to src/chat/gui/styles/dark.css diff --git a/src/chat/styles/light.css b/src/chat/gui/styles/light.css similarity index 100% rename from src/chat/styles/light.css rename to src/chat/gui/styles/light.css diff --git a/src/chat/styles/style.css b/src/chat/gui/styles/style.css similarity index 100% rename from src/chat/styles/style.css rename to src/chat/gui/styles/style.css From eb1c585ecbe4da10b3d8ab4235437577f737922a Mon Sep 17 00:00:00 2001 From: MeexReay Date: Tue, 2 Sep 2025 20:15:15 +0300 Subject: [PATCH 2/8] fix: make scrolled_window always scrolled to the end --- src/chat/gui/mod.rs | 1 + src/chat/gui/page.rs | 16 +++++++++++ src/chat/gui/widgets/imp.rs | 53 +++++++++++++++++++++++++++++++++++++ src/chat/gui/widgets/mod.rs | 15 +++++++++++ 4 files changed, 85 insertions(+) create mode 100644 src/chat/gui/widgets/imp.rs create mode 100644 src/chat/gui/widgets/mod.rs diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs index ecea4a1..4cefa94 100644 --- a/src/chat/gui/mod.rs +++ b/src/chat/gui/mod.rs @@ -46,6 +46,7 @@ use super::{ mod preferences; mod page; +mod widgets; use page::*; use preferences::*; diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs index 65029b4..269e627 100644 --- a/src/chat/gui/page.rs +++ b/src/chat/gui/page.rs @@ -35,6 +35,7 @@ use crate::chat::{ on_send_message, parse_message, SERVER_LIST, }; +use super::widgets::CustomLayout; use super::{add_chat_messages, get_avatar_id, get_message_sign, load_pixbuf, send_notification, try_save_config, update_window_title, UiModel}; pub fn get_message_box( @@ -359,6 +360,21 @@ pub fn build_page_box(ctx: Arc, app: &Application) -> (GtkBox, GtkBox, .propagate_natural_height(true) .build(); + let layout = CustomLayout::default(); + + layout.connect_local("size-changed", false, { + let chat_scrolled = chat_scrolled.downgrade(); + move |_| { + if let Some(chat_scrolled) = chat_scrolled.upgrade() { + let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + chat_scrolled.vadjustment().set_value(value); + } + return None; + } + }); + + page_box.set_layout_manager(Some(layout)); + timeout_add_local_once(Duration::ZERO, clone!( #[weak] chat_scrolled, move || { diff --git a/src/chat/gui/widgets/imp.rs b/src/chat/gui/widgets/imp.rs new file mode 100644 index 0000000..1f4f056 --- /dev/null +++ b/src/chat/gui/widgets/imp.rs @@ -0,0 +1,53 @@ +use libadwaita::{glib, gtk}; + +use glib::object::ObjectExt; +use gtk::{subclass::prelude::*, prelude::LayoutManagerExt, BoxLayout}; + +#[derive(Debug)] +pub struct CustomLayout { + box_layout: BoxLayout +} + +impl Default for CustomLayout { + fn default() -> Self { + CustomLayout { + box_layout: BoxLayout::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(5) + .build() + } + } +} + +#[glib::object_subclass] +impl ObjectSubclass for CustomLayout { + const NAME: &'static str = "CustomLayout"; + type Type = super::CustomLayout; + type ParentType = gtk::LayoutManager; +} + +impl ObjectImpl for CustomLayout { + fn signals() -> &'static [glib::subclass::Signal] { + use std::sync::OnceLock; + + static SIGNALS: OnceLock> = OnceLock::new(); + + SIGNALS.get_or_init(|| { + vec![glib::subclass::Signal::builder("size-changed").build()] + }) + } +} +impl LayoutManagerImpl for CustomLayout { + fn allocate(&self, widget: >k::Widget, width: i32, height: i32, baseline: i32) { + self.obj().emit_by_name::<()>("size-changed", &[]); + self.box_layout.allocate(widget, width, height, baseline) + } + fn measure( + &self, + widget: >k::Widget, + orientation: gtk::Orientation, + for_size: i32, + ) -> (i32, i32, i32, i32) { + self.box_layout.measure(widget, orientation, for_size) + } +} diff --git a/src/chat/gui/widgets/mod.rs b/src/chat/gui/widgets/mod.rs new file mode 100644 index 0000000..03f88c5 --- /dev/null +++ b/src/chat/gui/widgets/mod.rs @@ -0,0 +1,15 @@ +mod imp; + +use libadwaita::gtk::glib; +use libadwaita::gtk; + +glib::wrapper! { + pub struct CustomLayout(ObjectSubclass) + @extends gtk::LayoutManager; +} + +impl Default for CustomLayout { + fn default() -> Self { + glib::Object::new() + } +} From da657875a1ad7a5f52601a2f648b481d1145062d Mon Sep 17 00:00:00 2001 From: MeexReay Date: Tue, 2 Sep 2025 21:09:56 +0300 Subject: [PATCH 3/8] refactor: add sidebar with hello world --- src/chat/gui/mod.rs | 82 ++++++++++++++++++++++---------------------- src/chat/gui/page.rs | 25 +++++++++++--- 2 files changed, 61 insertions(+), 46 deletions(-) diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs index 4cefa94..46ce6ce 100644 --- a/src/chat/gui/mod.rs +++ b/src/chat/gui/mod.rs @@ -14,9 +14,9 @@ use clap::crate_version; use libadwaita::gdk::Texture; use libadwaita::gtk::gdk_pixbuf::InterpType; -use libadwaita::gtk::MenuButton; +use libadwaita::gtk::Label; use libadwaita::{ - self as adw, Avatar, HeaderBar + self as adw, Avatar, Breakpoint, BreakpointCondition, NavigationPage, NavigationSplitView }; use adw::gdk::Display; use adw::gio::{ActionEntry, ApplicationFlags, Menu}; @@ -163,6 +163,19 @@ fn build_menu(ctx: Arc, app: &Application) -> Menu { menu } +fn build_sidebar(_ctx: Arc, _app: &Application) -> NavigationPage { + let sidebar = GtkBox::new(Orientation::Vertical, 0); + + sidebar.append(&Label::new(Some("hello worlding"))); + + let page = NavigationPage::builder() + .child(&sidebar) + .title("sidebar") + .build(); + + page +} + fn build_ui(ctx: Arc, app: &Application) -> UiModel { let is_dark_theme = if let Some(settings) = Settings::default() { settings.is_gtk_application_prefer_dark_theme() @@ -178,27 +191,28 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { let is_dark_theme = true; let main_box = GtkBox::new(Orientation::Vertical, 0); - - let header = HeaderBar::new(); - header.pack_end(&MenuButton::builder() - .icon_name("open-menu-symbolic") - .menu_model(&build_menu(ctx.clone(), &app)) - .build()); - - main_box.append(&header); + let title = format!( + "bRAC - Connected to {} as {}", + ctx.config(|o| o.host.clone()), + &ctx.name() + ); - let (page_box, chat_box, chat_scrolled) = build_page_box(ctx.clone(), app); + let (page, chat_box, chat_scrolled) = build_page(ctx.clone(), app, &title); + + let sidebar = build_sidebar(ctx.clone(), &app); + + let split_view = NavigationSplitView::builder() + .sidebar(&sidebar) + .content(&page) + .show_content(true) + .build(); - main_box.append(&page_box); + main_box.append(&split_view); let window = ApplicationWindow::builder() .application(app) - .title(format!( - "bRAC - Connected to {} as {}", - ctx.config(|o| o.host.clone()), - &ctx.name() - )) + .title(&title) .default_width(500) .default_height(500) .resizable(true) @@ -206,31 +220,17 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { .content(&main_box) .build(); - // window.connect_default_width_notify(clone!( - // #[weak] chat_scrolled, - // move |_| { - // timeout_add_local_once(Duration::ZERO, clone!( - // #[weak] chat_scrolled, - // move || { - // let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); - // chat_scrolled.vadjustment().set_value(value); - // } - // )); - // } - // )); + let breakpoint = Breakpoint::new( + BreakpointCondition::new_length( + libadwaita::BreakpointConditionLengthType::MaxWidth, + 700.0, + libadwaita::LengthUnit::Px + ) + ); + + breakpoint.add_setter(&split_view, "collapsed", Some(&true.into())); - // window.connect_default_height_notify(clone!( - // #[weak] chat_scrolled, - // move |_| { - // timeout_add_local_once(Duration::ZERO, clone!( - // #[weak] chat_scrolled, - // move || { - // let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); - // chat_scrolled.vadjustment().set_value(value); - // } - // )); - // } - // )); + window.add_breakpoint(breakpoint); window.present(); diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs index 269e627..3986699 100644 --- a/src/chat/gui/page.rs +++ b/src/chat/gui/page.rs @@ -5,9 +5,9 @@ use std::time::{Duration, SystemTime}; use chrono::Local; use libadwaita::gdk::{BUTTON_PRIMARY, BUTTON_SECONDARY}; -use libadwaita::gtk::{GestureLongPress, Popover}; +use libadwaita::gtk::{GestureLongPress, MenuButton, Popover}; use libadwaita::{ - self as adw, Avatar + self as adw, Avatar, HeaderBar, NavigationPage, ToolbarView }; use adw::gdk::{Cursor, Display}; use adw::gio::MemoryInputStream; @@ -36,7 +36,7 @@ use crate::chat::{ }; use super::widgets::CustomLayout; -use super::{add_chat_messages, get_avatar_id, get_message_sign, load_pixbuf, send_notification, try_save_config, update_window_title, UiModel}; +use super::{add_chat_messages, build_menu, get_avatar_id, get_message_sign, load_pixbuf, send_notification, try_save_config, update_window_title, UiModel}; pub fn get_message_box( ctx: Arc, @@ -341,9 +341,22 @@ pub fn get_new_message_box( } /// page_box, chat_box, chat_scrolled -pub fn build_page_box(ctx: Arc, app: &Application) -> (GtkBox, GtkBox, ScrolledWindow) { +pub fn build_page(ctx: Arc, app: &Application, title: &str) -> (NavigationPage, GtkBox, ScrolledWindow) { let page_box = GtkBox::new(Orientation::Vertical, 5); page_box.set_css_classes(&["page-box"]); + + let toolbar = ToolbarView::new(); + + let header = HeaderBar::new(); + + header.pack_end(&MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&build_menu(ctx.clone(), &app)) + .build()); + + toolbar.set_content(Some(&header)); + + page_box.append(&toolbar); page_box.append(&build_widget_box(ctx.clone(), app)); @@ -458,8 +471,10 @@ pub fn build_page_box(ctx: Arc, app: &Application) -> (GtkBox, GtkBox, send_box.append(&send_btn); page_box.append(&send_box); + + let page = NavigationPage::new(&page_box, title); - (page_box, chat_box, chat_scrolled) + (page, chat_box, chat_scrolled) } fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { From ce4e5e40b39aedb45a86a80ddba4b84a827724d5 Mon Sep 17 00:00:00 2001 From: MeexReay Date: Tue, 2 Sep 2025 21:10:42 +0300 Subject: [PATCH 4/8] fix: widget box style --- src/chat/gui/styles/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/chat/gui/styles/style.css b/src/chat/gui/styles/style.css index bf3dc22..e052079 100644 --- a/src/chat/gui/styles/style.css +++ b/src/chat/gui/styles/style.css @@ -4,7 +4,7 @@ margin: -35px; } -.widget_box { +.widget-box { box-shadow: 0 10px 10px rgba(0, 0, 0, 0.20); border-bottom: 2px solid rgba(0, 0, 0, 0.20); min-height: 121px; @@ -29,4 +29,4 @@ /* .message-name-green { color: #70fa7a; } .message-name-red { color: #fa7070; } .message-name-magenta { color: #da70fa; } -.message-name-cyan { color: #70fadc; } */ \ No newline at end of file +.message-name-cyan { color: #70fadc; } */ From 02c4862178f7a80af9d194faa9ec10bda611588a Mon Sep 17 00:00:00 2001 From: MeexReay Date: Wed, 3 Sep 2025 17:07:19 +0300 Subject: [PATCH 5/8] refactor: improve sidebar --- src/chat/gui/mod.rs | 39 +++++++++++++++++++++++---------------- src/chat/gui/page.rs | 10 ++++------ 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs index 46ce6ce..f9ec74d 100644 --- a/src/chat/gui/mod.rs +++ b/src/chat/gui/mod.rs @@ -14,9 +14,9 @@ use clap::crate_version; use libadwaita::gdk::Texture; use libadwaita::gtk::gdk_pixbuf::InterpType; -use libadwaita::gtk::Label; +use libadwaita::gtk::{Button, Label}; use libadwaita::{ - self as adw, Avatar, Breakpoint, BreakpointCondition, NavigationPage, NavigationSplitView + self as adw, Avatar, Breakpoint, BreakpointCondition, OverlaySplitView }; use adw::gdk::Display; use adw::gio::{ActionEntry, ApplicationFlags, Menu}; @@ -163,17 +163,10 @@ fn build_menu(ctx: Arc, app: &Application) -> Menu { menu } -fn build_sidebar(_ctx: Arc, _app: &Application) -> NavigationPage { +fn build_sidebar(_ctx: Arc, _app: &Application) -> GtkBox { let sidebar = GtkBox::new(Orientation::Vertical, 0); - sidebar.append(&Label::new(Some("hello worlding"))); - - let page = NavigationPage::builder() - .child(&sidebar) - .title("sidebar") - .build(); - - page + sidebar } fn build_ui(ctx: Arc, app: &Application) -> UiModel { @@ -198,18 +191,31 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { &ctx.name() ); - let (page, chat_box, chat_scrolled) = build_page(ctx.clone(), app, &title); + let (header, page, chat_box, chat_scrolled) = build_page(ctx.clone(), app); let sidebar = build_sidebar(ctx.clone(), &app); - let split_view = NavigationSplitView::builder() + let split_view = OverlaySplitView::builder() .sidebar(&sidebar) .content(&page) - .show_content(true) + .enable_hide_gesture(true) + .enable_show_gesture(true) + .collapsed(true) .build(); main_box.append(&split_view); + let toggle_button = Button::from_icon_name("go-previous-symbolic"); + + toggle_button.connect_clicked(clone!( + #[weak] split_view, + move |_| { + split_view.set_show_sidebar(!split_view.shows_sidebar()); + } + )); + + header.pack_start(&toggle_button); + let window = ApplicationWindow::builder() .application(app) .title(&title) @@ -222,13 +228,14 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { let breakpoint = Breakpoint::new( BreakpointCondition::new_length( - libadwaita::BreakpointConditionLengthType::MaxWidth, + libadwaita::BreakpointConditionLengthType::MinWidth, 700.0, libadwaita::LengthUnit::Px ) ); - breakpoint.add_setter(&split_view, "collapsed", Some(&true.into())); + breakpoint.add_setter(&split_view, "collapsed", Some(&false.into())); + breakpoint.add_setter(&toggle_button, "visible", Some(&false.into())); window.add_breakpoint(breakpoint); diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs index 3986699..15c6821 100644 --- a/src/chat/gui/page.rs +++ b/src/chat/gui/page.rs @@ -7,7 +7,7 @@ use chrono::Local; use libadwaita::gdk::{BUTTON_PRIMARY, BUTTON_SECONDARY}; use libadwaita::gtk::{GestureLongPress, MenuButton, Popover}; use libadwaita::{ - self as adw, Avatar, HeaderBar, NavigationPage, ToolbarView + self as adw, Avatar, HeaderBar, ToolbarView }; use adw::gdk::{Cursor, Display}; use adw::gio::MemoryInputStream; @@ -340,8 +340,8 @@ pub fn get_new_message_box( overlay } -/// page_box, chat_box, chat_scrolled -pub fn build_page(ctx: Arc, app: &Application, title: &str) -> (NavigationPage, GtkBox, ScrolledWindow) { +/// header, page_box, chat_box, chat_scrolled +pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, GtkBox, ScrolledWindow) { let page_box = GtkBox::new(Orientation::Vertical, 5); page_box.set_css_classes(&["page-box"]); @@ -472,9 +472,7 @@ pub fn build_page(ctx: Arc, app: &Application, title: &str) -> (Navigat page_box.append(&send_box); - let page = NavigationPage::new(&page_box, title); - - (page, chat_box, chat_scrolled) + (header, page_box, chat_box, chat_scrolled) } fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { From 431736e967b9f7c11ff5b5e1f62bb0126fc2d208 Mon Sep 17 00:00:00 2001 From: MeexReay Date: Wed, 3 Sep 2025 20:14:33 +0300 Subject: [PATCH 6/8] refactor: add server list to sidebar --- src/chat/config.rs | 9 + src/chat/gui/images/servers.png | Bin 0 -> 26593 bytes src/chat/gui/mod.rs | 345 ++++++++++++++++++++++++-------- src/chat/gui/preferences.rs | 214 +++++++++----------- 4 files changed, 369 insertions(+), 199 deletions(-) create mode 100644 src/chat/gui/images/servers.png diff --git a/src/chat/config.rs b/src/chat/config.rs index 079720f..5c74470 100644 --- a/src/chat/config.rs +++ b/src/chat/config.rs @@ -3,6 +3,8 @@ use serde_default::DefaultFromSerde; use serde_yml; use std::{error::Error, fs, path::PathBuf}; +use super::SERVER_LIST; + const MESSAGE_FORMAT: &str = "\u{B9AC}\u{3E70}<{name}> {text}"; fn default_true() -> bool { @@ -30,6 +32,10 @@ pub fn default_message_format() -> String { MESSAGE_FORMAT.to_string() } +pub fn default_servers() -> Vec { + SERVER_LIST.to_vec() +} + #[derive(serde::Serialize, serde::Deserialize, DefaultFromSerde, Clone)] pub struct Config { #[serde(default = "default_host")] @@ -70,6 +76,8 @@ pub struct Config { pub debug_logs: bool, #[serde(default)] pub avatar: Option, + #[serde(default = "default_servers")] + pub servers: Vec, } #[cfg(target_os = "windows")] @@ -183,6 +191,7 @@ pub struct Args { pub avatar: Option, #[arg(long)] pub debug_logs: bool, + // TODO: add servers } impl Args { diff --git a/src/chat/gui/images/servers.png b/src/chat/gui/images/servers.png new file mode 100644 index 0000000000000000000000000000000000000000..544c24819a7eb1f17f1245d96fdf2bf9dad88fcb GIT binary patch literal 26593 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 {}, + Ok(_) => {} Err(e) => { println!("save config error: {e}") } @@ -71,7 +63,7 @@ struct UiModel { #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] notifications: Arc>>, avatars: Arc>>>, - latest_sign: Arc + latest_sign: Arc, } thread_local!( @@ -163,9 +155,175 @@ fn build_menu(ctx: Arc, app: &Application) -> Menu { menu } -fn build_sidebar(_ctx: Arc, _app: &Application) -> GtkBox { - let sidebar = GtkBox::new(Orientation::Vertical, 0); - sidebar.append(&Label::new(Some("hello worlding"))); +fn build_sidebar_button( + ctx: Arc, + split_view: &OverlaySplitView, + server: String, + servers_list: &GtkBox, +) -> GtkBox { + let hbox = GtkBox::new(Orientation::Horizontal, 5); + + let button = Button::builder().label(&server).hexpand(true).build(); + + button.connect_clicked(clone!( + #[weak] + split_view, + #[weak] + ctx, + #[strong] + server, + move |_| { + let mut config = ctx.config.read().unwrap().clone(); + config.host = server.clone(); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + update_window_title(ctx.clone()); + if split_view.is_collapsed() { + split_view.set_show_sidebar(false); + } + } + )); + + hbox.append(&button); + + let delete_button = Button::from_icon_name("user-trash-symbolic"); + + delete_button.connect_clicked(clone!( + #[weak] + ctx, + #[weak] + hbox, + #[weak] + servers_list, + #[strong] + server, + move |_| { + servers_list.remove(&hbox); + let mut config = ctx.config.read().unwrap().clone(); + let index = config.servers.iter().position(|x| *x == server).unwrap(); + config.servers.remove(index); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + } + )); + + hbox.append(&delete_button); + + hbox +} + +fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplitView) -> GtkBox { + let sidebar = GtkBox::new(Orientation::Vertical, 5); + + sidebar.append( + &Picture::builder() + .paintable(&Texture::for_pixbuf( + &load_pixbuf(include_bytes!("images/servers.png")).unwrap(), + )) + .build(), + ); + + let servers_list = GtkBox::new(Orientation::Vertical, 5); + + for server in ctx.config(|o| o.servers.clone()) { + servers_list.append(&build_sidebar_button( + ctx.clone(), + &split_view, + server, + &servers_list, + )); + } + + sidebar.append(&servers_list); + + let add_server = Button::builder() + .label("Add Server") + // .start_icon_name("list-add-symbolic") + .margin_top(10) + .build(); + + add_server.connect_clicked(clone!( + #[weak] + app, + #[weak] + servers_list, + #[weak] + ctx, + #[weak] + split_view, + move |_| { + let dialog = Dialog::new(); + + let vbox = GtkBox::new(Orientation::Vertical, 5); + + vbox.set_margin_bottom(20); + vbox.set_margin_top(20); + vbox.set_margin_end(20); + vbox.set_margin_start(20); + + vbox.append(&Label::builder().label("Add server").build()); + + let entry = Entry::builder().placeholder_text("Server host").build(); + + vbox.append(&entry); + + let hbox = GtkBox::new(Orientation::Horizontal, 5); + + let confirm = Button::builder().label("Confirm").build(); + + confirm.connect_clicked(clone!( + #[weak] + dialog, + #[weak] + servers_list, + #[weak] + ctx, + #[weak] + split_view, + #[weak] + entry, + move |_| { + let server: String = entry.text().into(); + + let mut config = ctx.config.read().unwrap().clone(); + config.servers.push(server.clone()); + ctx.set_config(&config); + try_save_config(get_config_path(), &config); + + servers_list.append(&build_sidebar_button( + ctx.clone(), + &split_view, + server, + &servers_list, + )); + dialog.close(); + } + )); + + hbox.append(&confirm); + + let cancel = Button::builder().label("Cancel").build(); + + cancel.connect_clicked(clone!( + #[weak] + dialog, + move |_| { + dialog.close(); + } + )); + + hbox.append(&cancel); + + vbox.append(&hbox); + + dialog.set_child(Some(&vbox)); + + dialog.present(app.active_window().as_ref()); + } + )); + + sidebar.append(&add_server); + sidebar } @@ -182,43 +340,43 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { #[cfg(target_os = "windows")] let is_dark_theme = true; - - let main_box = GtkBox::new(Orientation::Vertical, 0); - let title = format!( - "bRAC - Connected to {} as {}", - ctx.config(|o| o.host.clone()), - &ctx.name() - ); + let main_box = GtkBox::new(Orientation::Vertical, 0); let (header, page, chat_box, chat_scrolled) = build_page(ctx.clone(), app); - let sidebar = build_sidebar(ctx.clone(), &app); - let split_view = OverlaySplitView::builder() - .sidebar(&sidebar) .content(&page) .enable_hide_gesture(true) .enable_show_gesture(true) .collapsed(true) .build(); - + + let sidebar = build_sidebar(ctx.clone(), &app, &split_view); + + split_view.set_sidebar(Some(&sidebar)); + main_box.append(&split_view); let toggle_button = Button::from_icon_name("go-previous-symbolic"); toggle_button.connect_clicked(clone!( - #[weak] split_view, + #[weak] + split_view, move |_| { split_view.set_show_sidebar(!split_view.shows_sidebar()); } )); - + header.pack_start(&toggle_button); let window = ApplicationWindow::builder() .application(app) - .title(&title) + .title(&format!( + "bRAC - Connected to {} as {}", + ctx.config(|o| o.host.clone()), + &ctx.name() + )) .default_width(500) .default_height(500) .resizable(true) @@ -226,17 +384,15 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { .content(&main_box) .build(); - let breakpoint = Breakpoint::new( - BreakpointCondition::new_length( - libadwaita::BreakpointConditionLengthType::MinWidth, - 700.0, - libadwaita::LengthUnit::Px - ) - ); + let breakpoint = Breakpoint::new(BreakpointCondition::new_length( + libadwaita::BreakpointConditionLengthType::MinWidth, + 700.0, + libadwaita::LengthUnit::Px, + )); breakpoint.add_setter(&split_view, "collapsed", Some(&false.into())); breakpoint.add_setter(&toggle_button, "visible", Some(&false.into())); - + window.add_breakpoint(breakpoint); window.present(); @@ -252,7 +408,7 @@ fn build_ui(ctx: Arc, app: &Application) -> UiModel { #[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))] notifications: Arc::new(RwLock::new(Vec::::new())), avatars: Arc::new(Mutex::new(HashMap::new())), - latest_sign: Arc::new(AtomicU64::new(0)) + latest_sign: Arc::new(AtomicU64::new(0)), } } @@ -329,22 +485,41 @@ fn setup(_: &Application, ctx: Arc, ui: UiModel) { if ctx.config(|o| !o.new_ui_enabled) { return; } - + thread::spawn(move || { for message in messages.iter() { - let Some(avatar_url) = grab_avatar(message) else { continue }; + let Some(avatar_url) = grab_avatar(message) else { + continue; + }; let avatar_id = get_avatar_id(&avatar_url); - let Some(avatar) = load_avatar(&avatar_url, ctx.config(|o| o.proxy.clone()), ctx.config(|o| o.max_avatar_size as usize)) else { println!("cant load avatar: {avatar_url} request error"); continue }; - let Ok(pixbuf) = load_pixbuf(&avatar) else { println!("cant load avatar: {avatar_url} pixbuf error"); continue; }; - let Some(pixbuf) = pixbuf.scale_simple(32, 32, InterpType::Bilinear) else { println!("cant load avatar: {avatar_url} scale image error"); continue }; + let Some(avatar) = load_avatar( + &avatar_url, + ctx.config(|o| o.proxy.clone()), + ctx.config(|o| o.max_avatar_size as usize), + ) else { + println!("cant load avatar: {avatar_url} request error"); + continue; + }; + let Ok(pixbuf) = load_pixbuf(&avatar) else { + println!("cant load avatar: {avatar_url} pixbuf error"); + continue; + }; + let Some(pixbuf) = + pixbuf.scale_simple(32, 32, InterpType::Bilinear) + else { + println!("cant load avatar: {avatar_url} scale image error"); + continue; + }; let texture = Texture::for_pixbuf(&pixbuf); timeout_add_once(Duration::ZERO, { move || { GLOBAL.with(|global| { if let Some(ui) = &*global.borrow() { - if let Some(pics) = ui.avatars.lock().unwrap().remove(&avatar_id) { + if let Some(pics) = + ui.avatars.lock().unwrap().remove(&avatar_id) + { for pic in pics { pic.set_custom_image(Some(&texture)); } @@ -454,42 +629,42 @@ fn load_avatar(url: &str, proxy: Option, response_limit: usize) -> Optio } else { format!("socks5://{proxy}") }; - + reqwest::blocking::Client::builder() .proxy(reqwest::Proxy::all(&proxy).ok()?) - .build().ok()? + .build() + .ok()? } else { reqwest::blocking::Client::new() }; - - client.get(url).send().ok() - .and_then(|mut resp| { - let mut data = Vec::new(); - let mut length = 0; - - loop { - if length >= response_limit { - break; - } - let mut buf = vec![0; (response_limit - length).min(1024)]; - let now_len = resp.read(&mut buf).ok()?; - if now_len == 0 { - break; - } - buf.truncate(now_len); - length += now_len; - data.append(&mut buf); - } - Some(data) - }) + client.get(url).send().ok().and_then(|mut resp| { + let mut data = Vec::new(); + let mut length = 0; + + loop { + if length >= response_limit { + break; + } + let mut buf = vec![0; (response_limit - length).min(1024)]; + let now_len = resp.read(&mut buf).ok()?; + if now_len == 0 { + break; + } + buf.truncate(now_len); + length += now_len; + data.append(&mut buf); + } + + Some(data) + }) } // creates sign that expires in 0-20 minutes fn get_message_sign(name: &str, date: &str) -> u64 { let mut hasher = DefaultHasher::new(); hasher.write(name.as_bytes()); - hasher.write(date[..date.len()-2].as_bytes()); + hasher.write(date[..date.len() - 2].as_bytes()); hasher.finish() } @@ -512,9 +687,21 @@ fn on_add_message(ctx: Arc, ui: &UiModel, message: String, notify: bool } if ctx.config(|o| o.new_ui_enabled) { - ui.chat_box.append(&get_new_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + ui.chat_box.append(&get_new_message_box( + ctx.clone(), + ui, + message, + notify, + formatting_enabled, + )); } else { - ui.chat_box.append(&get_message_box(ctx.clone(), ui, message, notify, formatting_enabled)); + ui.chat_box.append(&get_message_box( + ctx.clone(), + ui, + message, + notify, + formatting_enabled, + )); }; timeout_add_local_once(Duration::from_millis(1000), move || { diff --git a/src/chat/gui/preferences.rs b/src/chat/gui/preferences.rs index 949a362..3135577 100644 --- a/src/chat/gui/preferences.rs +++ b/src/chat/gui/preferences.rs @@ -1,35 +1,29 @@ use std::sync::Arc; -use libadwaita::gtk::Adjustment; -use libadwaita::{ - self as adw, ActionRow, ButtonRow, EntryRow, PreferencesDialog, PreferencesGroup, PreferencesPage, SpinRow, SwitchRow -}; use adw::gdk::Display; use adw::glib::clone; -use adw::glib::{ - self, -}; +use adw::glib::{self}; use adw::prelude::*; use adw::Application; +use libadwaita::gtk::Adjustment; +use libadwaita::{ + self as adw, ActionRow, ButtonRow, EntryRow, PreferencesDialog, PreferencesGroup, + PreferencesPage, SpinRow, SwitchRow, +}; use adw::gtk; use gtk::Button; - use crate::chat::{ - config::{ - get_config_path, Config, - }, + config::{get_config_path, Config}, ctx::Context, }; use super::{try_save_config, update_window_title}; - pub fn open_settings(ctx: Arc, app: &Application) { let dialog = PreferencesDialog::builder().build(); - let page = PreferencesPage::builder() .title("General") .icon_name("avatar-default-symbolic") @@ -40,7 +34,6 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Profile preferences") .build(); - // Name preference let name = EntryRow::builder() @@ -50,27 +43,22 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&name); - // Avatar preference - + let avatar = EntryRow::builder() .title("Avatar Link") .text(ctx.config(|o| o.avatar.clone()).unwrap_or_default()) .build(); group.add(&avatar); - page.add(&group); - - let group = PreferencesGroup::builder() .title("Server") .description("Connection preferences") .build(); - // Host preference let host = EntryRow::builder() @@ -80,60 +68,61 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&host); - // Messages limit preference - + let messages_limit = SpinRow::builder() .title("Messages limit") - .adjustment(&Adjustment::builder() - .lower(1.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.max_messages) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(1.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.max_messages) as f64) + .build(), + ) .build(); - + group.add(&messages_limit); - // Update interval preference - + let update_interval = SpinRow::builder() .title("Update interval") .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.update_time) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.update_time) as f64) + .build(), + ) .build(); - + group.add(&update_interval); - // Update interval OOF preference - + let update_interval_oof = SpinRow::builder() .title("Update interval when unfocused") .subtitle("In milliseconds") - .adjustment(&Adjustment::builder() - .lower(10.0) - .upper(1048576.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.oof_update_time) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(10.0) + .upper(1048576.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.oof_update_time) as f64) + .build(), + ) .build(); group.add(&update_interval_oof); page.add(&group); - - let group = PreferencesGroup::builder() .title("Config") .description("Configuration tools") @@ -154,7 +143,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { config_path_copy.set_margin_top(10); config_path_copy.set_margin_bottom(10); config_path_copy.connect_clicked(clone!( - #[weak] clipboard, + #[weak] + clipboard, move |_| { if let Some(text) = get_config_path().to_str() { clipboard.set_text(text); @@ -164,19 +154,20 @@ pub fn open_settings(ctx: Arc, app: &Application) { config_path.add_suffix(&config_path_copy); config_path.set_activatable(false); - + group.add(&config_path); // Reset button - let reset_button = ButtonRow::builder() - .title("Reset all") - .build(); + let reset_button = ButtonRow::builder().title("Reset all").build(); reset_button.connect_activated(clone!( - #[weak] ctx, - #[weak] app, - #[weak] dialog, + #[weak] + ctx, + #[weak] + app, + #[weak] + dialog, move |_| { dialog.close(); let config = Config::default(); @@ -185,15 +176,13 @@ pub fn open_settings(ctx: Arc, app: &Application) { open_settings(ctx, &app); } )); - + group.add(&reset_button); - + page.add(&group); dialog.add(&page); - - let page = PreferencesPage::builder() .title("Protocol") .icon_name("network-wired-symbolic") @@ -204,7 +193,6 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Network preferences") .build(); - // Proxy preference let proxy = EntryRow::builder() @@ -214,35 +202,33 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&proxy); - // Max avatar size preference - + let max_avatar_size = SpinRow::builder() .title("Max avatar size") .subtitle("Maximum avatar size in bytes") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(1074790400.0) - .page_increment(1024.0) - .step_increment(1024.0) - .value(ctx.config(|o| o.max_avatar_size) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(0.0) + .upper(1074790400.0) + .page_increment(1024.0) + .step_increment(1024.0) + .value(ctx.config(|o| o.max_avatar_size) as f64) + .build(), + ) .build(); group.add(&max_avatar_size); - page.add(&group); - let group = PreferencesGroup::builder() .title("Protocol") .description("Rac protocol preferences") .build(); - // Message format preference - + let message_format = EntryRow::builder() .title("Message format") .text(ctx.config(|o| o.message_format.clone())) @@ -252,9 +238,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { page.add(&group); - // Hide IP preference - + let hide_my_ip = SwitchRow::builder() .title("Hide IP") .subtitle("Hides only for clRAC and other dummy clients") @@ -263,9 +248,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&hide_my_ip); - // Chunked reading preference - + let chunked_reading = SwitchRow::builder() .title("Chunked reading") .subtitle("Read messages in chunks (less traffic usage, less compatibility)") @@ -274,9 +258,8 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&chunked_reading); - // Enable commands preference - + let enable_commands = SwitchRow::builder() .title("Enable commands") .subtitle("Enable slash commands (eg. /login) on client-side") @@ -285,11 +268,9 @@ pub fn open_settings(ctx: Arc, app: &Application) { group.add(&enable_commands); - page.add(&group); - - dialog.add(&page); + dialog.add(&page); let page = PreferencesPage::builder() .title("Interface") @@ -301,102 +282,96 @@ pub fn open_settings(ctx: Arc, app: &Application) { .description("Messages render preferences") .build(); - // Debug logs preference - + let debug_logs = SwitchRow::builder() .title("Debug logs") .subtitle("Print debug logs to the chat") .active(ctx.config(|o| o.debug_logs)) .build(); - + group.add(&debug_logs); - // Show IPs preference - + let show_ips = SwitchRow::builder() .title("Show IPs") .subtitle("Show authors IP addresses if possible") .active(ctx.config(|o| o.show_other_ip)) .build(); - + group.add(&show_ips); - // Format messages preference - + let format_messages = SwitchRow::builder() .title("Format messages") .subtitle("Disable to see raw messages") .active(ctx.config(|o| o.formatting_enabled)) .build(); - + group.add(&format_messages); - // Show avatars preference - + let show_avatars = SwitchRow::builder() .title("Show avatars") .subtitle("Enables new messages UI") .active(ctx.config(|o| o.new_ui_enabled)) .build(); - + group.add(&show_avatars); page.add(&group); - let group = PreferencesGroup::builder() .title("Interface") .description("General interface preferences (restart after changing)") .build(); - // Remove GUI shit preference - + let remove_gui_shit = SwitchRow::builder() .title("Remove GUI shit") .subtitle("Removes calendar, konata and clock") .active(ctx.config(|o| o.remove_gui_shit)) .build(); - + group.add(&remove_gui_shit); - // Konata size preference - + let konata_size = SpinRow::builder() .title("Konata size") .subtitle("Set konata size percent") - .adjustment(&Adjustment::builder() - .lower(0.0) - .upper(200.0) - .page_increment(10.0) - .step_increment(10.0) - .value(ctx.config(|o| o.konata_size) as f64) - .build()) + .adjustment( + &Adjustment::builder() + .lower(0.0) + .upper(200.0) + .page_increment(10.0) + .step_increment(10.0) + .value(ctx.config(|o| o.konata_size) as f64) + .build(), + ) .build(); group.add(&konata_size); - - + // Enable notifications preference - + let enable_notifications = SwitchRow::builder() .title("Enable notifications") .subtitle("Send notifications on chat and system messages") .active(ctx.config(|o| o.notifications_enabled)) .build(); - + group.add(&enable_notifications); page.add(&group); - - + dialog.add(&page); - dialog.connect_closed(move |_| { + let old_config = ctx.config.read().unwrap().clone(); + let config = Config { host: host.text().to_string(), name: { @@ -441,6 +416,7 @@ pub fn open_settings(ctx: Arc, app: &Application) { Some(proxy) } }, + servers: old_config.servers, }; ctx.set_config(&config); try_save_config(get_config_path(), &config); @@ -449,5 +425,3 @@ pub fn open_settings(ctx: Arc, app: &Application) { dialog.present(app.active_window().as_ref()); } - - From 5e4957a1e43c31fd689e5d160699320a4c65882d Mon Sep 17 00:00:00 2001 From: MeexReay Date: Wed, 3 Sep 2025 20:16:04 +0300 Subject: [PATCH 7/8] refactor: rustfmt --- src/chat/gui/page.rs | 168 +++++++++++++++++++----------------- src/chat/gui/widgets/imp.rs | 22 +++-- src/chat/gui/widgets/mod.rs | 2 +- 3 files changed, 102 insertions(+), 90 deletions(-) diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs index 15c6821..01a55b6 100644 --- a/src/chat/gui/page.rs +++ b/src/chat/gui/page.rs @@ -4,39 +4,33 @@ use std::time::{Duration, SystemTime}; use chrono::Local; -use libadwaita::gdk::{BUTTON_PRIMARY, BUTTON_SECONDARY}; -use libadwaita::gtk::{GestureLongPress, MenuButton, Popover}; -use libadwaita::{ - self as adw, Avatar, HeaderBar, ToolbarView -}; use adw::gdk::{Cursor, Display}; use adw::gio::MemoryInputStream; use adw::glib::clone; -use adw::glib::{ - self, source::timeout_add_local_once, - timeout_add_local, - ControlFlow, -}; +use adw::glib::{self, source::timeout_add_local_once, timeout_add_local, ControlFlow}; use adw::prelude::*; use adw::Application; +use libadwaita::gdk::{BUTTON_PRIMARY, BUTTON_SECONDARY}; +use libadwaita::gtk::{GestureLongPress, MenuButton, Popover}; +use libadwaita::{self as adw, Avatar, HeaderBar, ToolbarView}; use adw::gtk; use gtk::gdk_pixbuf::PixbufAnimation; use gtk::pango::WrapMode; use gtk::{ - Align, Box as GtkBox, Button, Calendar, Entry, Fixed, GestureClick, Justification, Label, ListBox, - Orientation, Overlay, Picture, ScrolledWindow, + Align, Box as GtkBox, Button, Calendar, Entry, Fixed, GestureClick, Justification, Label, + ListBox, Orientation, Overlay, Picture, ScrolledWindow, }; - use crate::chat::{ - config::get_config_path, - ctx::Context, - on_send_message, parse_message, SERVER_LIST, + config::get_config_path, ctx::Context, on_send_message, parse_message, SERVER_LIST, }; use super::widgets::CustomLayout; -use super::{add_chat_messages, build_menu, get_avatar_id, get_message_sign, load_pixbuf, send_notification, try_save_config, update_window_title, UiModel}; +use super::{ + add_chat_messages, build_menu, get_avatar_id, get_message_sign, load_pixbuf, send_notification, + try_save_config, update_window_title, UiModel, +}; pub fn get_message_box( ctx: Arc, @@ -136,19 +130,22 @@ pub fn get_message_box( fn open_avatar_popup(avatar: String, avatar_picture: &Avatar) { let display = Display::default().unwrap(); let clipboard = display.clipboard(); - + let popover = Popover::new(); let button = Button::with_label("Copy Link"); button.connect_clicked(clone!( - #[weak] clipboard, - #[weak] popover, - #[strong] avatar, + #[weak] + clipboard, + #[weak] + popover, + #[strong] + avatar, move |_| { clipboard.set_text(avatar.as_str()); popover.popdown(); - }) - ); + } + )); let vbox = GtkBox::builder() .orientation(Orientation::Vertical) @@ -166,7 +163,7 @@ pub fn get_new_message_box( ui: &UiModel, message: String, notify: bool, - formatting_enabled: bool + formatting_enabled: bool, ) -> Overlay { // TODO: softcode these colors @@ -193,7 +190,7 @@ pub fn get_new_message_box( .map(|o| o.1.to_string()) .unwrap_or("#DDDDDD".to_string()), avatar.clone(), - avatar.map(|o| get_avatar_id(&o)).unwrap_or_default() + avatar.map(|o| get_avatar_id(&o)).unwrap_or_default(), ) } else { ( @@ -203,18 +200,18 @@ pub fn get_new_message_box( "System".to_string(), "#DDDDDD".to_string(), None, - 0 + 0, ) }; - + if notify && !ui.window.is_active() { if ctx.config(|o| o.chunked_enabled) { send_notification( ctx.clone(), ui, - &if name == *"System" { + &if name == *"System" { "System Message".to_string() - } else { + } else { format!("{}'s Message", name) }, &glib::markup_escape_text(&content), @@ -239,20 +236,20 @@ pub fn get_new_message_box( .show_initials(true) .size(32) .build(); - + avatar_picture.set_vexpand(false); avatar_picture.set_hexpand(false); avatar_picture.set_valign(Align::Start); avatar_picture.set_halign(Align::Start); if let Some(avatar) = avatar { - let long_gesture = GestureLongPress::builder() - .button(BUTTON_PRIMARY) - .build(); + let long_gesture = GestureLongPress::builder().button(BUTTON_PRIMARY).build(); long_gesture.connect_pressed(clone!( - #[weak] avatar_picture, - #[strong] avatar, + #[weak] + avatar_picture, + #[strong] + avatar, move |_, x, y| { if x < 32.0 && y > 4.0 && y < 32.0 { open_avatar_popup(avatar.clone(), &avatar_picture); @@ -261,14 +258,14 @@ pub fn get_new_message_box( )); overlay.add_controller(long_gesture); - - let short_gesture = GestureClick::builder() - .button(BUTTON_SECONDARY) - .build(); + + let short_gesture = GestureClick::builder().button(BUTTON_SECONDARY).build(); short_gesture.connect_released(clone!( - #[weak] avatar_picture, - #[strong] avatar, + #[weak] + avatar_picture, + #[strong] + avatar, move |_, _, x, y| { if x < 32.0 && y > 4.0 && y < 32.0 { open_avatar_popup(avatar.clone(), &avatar_picture); @@ -281,7 +278,7 @@ pub fn get_new_message_box( if avatar_id != 0 { let mut lock = ui.avatars.lock().unwrap(); - + if let Some(pics) = lock.get_mut(&avatar_id) { pics.push(avatar_picture.clone()); } else { @@ -300,7 +297,7 @@ pub fn get_new_message_box( vbox.append(&Label::builder() .label(format!( "{} {} {}", - glib::markup_escape_text(&name), + glib::markup_escape_text(&name), glib::markup_escape_text(&date), glib::markup_escape_text(&ip.unwrap_or_default()), )) @@ -313,18 +310,20 @@ pub fn get_new_message_box( .build()); } - vbox.append(&Label::builder() - .label(format!( - "{}", - glib::markup_escape_text(&content) - )) - .halign(Align::Start) - .hexpand(true) - .selectable(true) - .wrap(true) - .wrap_mode(WrapMode::WordChar) - .use_markup(true) - .build()); + vbox.append( + &Label::builder() + .label(format!( + "{}", + glib::markup_escape_text(&content) + )) + .halign(Align::Start) + .hexpand(true) + .selectable(true) + .wrap(true) + .wrap_mode(WrapMode::WordChar) + .use_markup(true) + .build(), + ); vbox.set_margin_start(37); vbox.set_hexpand(true); @@ -341,7 +340,10 @@ pub fn get_new_message_box( } /// header, page_box, chat_box, chat_scrolled -pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, GtkBox, ScrolledWindow) { +pub fn build_page( + ctx: Arc, + app: &Application, +) -> (HeaderBar, GtkBox, GtkBox, ScrolledWindow) { let page_box = GtkBox::new(Orientation::Vertical, 5); page_box.set_css_classes(&["page-box"]); @@ -349,15 +351,17 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G let header = HeaderBar::new(); - header.pack_end(&MenuButton::builder() - .icon_name("open-menu-symbolic") - .menu_model(&build_menu(ctx.clone(), &app)) - .build()); + header.pack_end( + &MenuButton::builder() + .icon_name("open-menu-symbolic") + .menu_model(&build_menu(ctx.clone(), &app)) + .build(), + ); toolbar.set_content(Some(&header)); - + page_box.append(&toolbar); - + page_box.append(&build_widget_box(ctx.clone(), app)); let chat_box = GtkBox::new(Orientation::Vertical, 2); @@ -379,7 +383,8 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G let chat_scrolled = chat_scrolled.downgrade(); move |_| { if let Some(chat_scrolled) = chat_scrolled.upgrade() { - let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + let value = + chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); chat_scrolled.vadjustment().set_value(value); } return None; @@ -388,13 +393,18 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G page_box.set_layout_manager(Some(layout)); - timeout_add_local_once(Duration::ZERO, clone!( - #[weak] chat_scrolled, - move || { - let value = chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); - chat_scrolled.vadjustment().set_value(value); - } - )); + timeout_add_local_once( + Duration::ZERO, + clone!( + #[weak] + chat_scrolled, + move || { + let value = + chat_scrolled.vadjustment().upper() - chat_scrolled.vadjustment().page_size(); + chat_scrolled.vadjustment().set_value(value); + } + ), + ); page_box.append(&chat_scrolled); @@ -419,8 +429,10 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G .build(); send_btn.connect_clicked(clone!( - #[weak] text_entry, - #[weak] ctx, + #[weak] + text_entry, + #[weak] + ctx, move |_| { let text = text_entry.text().clone(); @@ -444,8 +456,10 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G )); text_entry.connect_activate(clone!( - #[weak] text_entry, - #[weak] ctx, + #[weak] + text_entry, + #[weak] + ctx, move |_| { let text = text_entry.text().clone(); @@ -477,7 +491,7 @@ pub fn build_page(ctx: Arc, app: &Application) -> (HeaderBar, GtkBox, G fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { let widget_box_overlay = Overlay::new(); - + let widget_box = GtkBox::new(Orientation::Horizontal, 5); widget_box.set_css_classes(&["widget-box"]); @@ -505,7 +519,8 @@ fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { let click = GestureClick::new(); click.connect_pressed(clone!( - #[weak] ctx, + #[weak] + ctx, move |_, _, _, _| { let mut config = ctx.config.read().unwrap().clone(); config.host = url.clone(); @@ -601,4 +616,3 @@ fn build_widget_box(ctx: Arc, _app: &Application) -> Overlay { widget_box_overlay } - diff --git a/src/chat/gui/widgets/imp.rs b/src/chat/gui/widgets/imp.rs index 1f4f056..63b87b7 100644 --- a/src/chat/gui/widgets/imp.rs +++ b/src/chat/gui/widgets/imp.rs @@ -1,11 +1,11 @@ use libadwaita::{glib, gtk}; use glib::object::ObjectExt; -use gtk::{subclass::prelude::*, prelude::LayoutManagerExt, BoxLayout}; +use gtk::{prelude::LayoutManagerExt, subclass::prelude::*, BoxLayout}; #[derive(Debug)] pub struct CustomLayout { - box_layout: BoxLayout + box_layout: BoxLayout, } impl Default for CustomLayout { @@ -14,7 +14,7 @@ impl Default for CustomLayout { box_layout: BoxLayout::builder() .orientation(gtk::Orientation::Vertical) .spacing(5) - .build() + .build(), } } } @@ -29,12 +29,10 @@ impl ObjectSubclass for CustomLayout { impl ObjectImpl for CustomLayout { fn signals() -> &'static [glib::subclass::Signal] { use std::sync::OnceLock; - + static SIGNALS: OnceLock> = OnceLock::new(); - SIGNALS.get_or_init(|| { - vec![glib::subclass::Signal::builder("size-changed").build()] - }) + SIGNALS.get_or_init(|| vec![glib::subclass::Signal::builder("size-changed").build()]) } } impl LayoutManagerImpl for CustomLayout { @@ -43,11 +41,11 @@ impl LayoutManagerImpl for CustomLayout { self.box_layout.allocate(widget, width, height, baseline) } fn measure( - &self, - widget: >k::Widget, - orientation: gtk::Orientation, - for_size: i32, - ) -> (i32, i32, i32, i32) { + &self, + widget: >k::Widget, + orientation: gtk::Orientation, + for_size: i32, + ) -> (i32, i32, i32, i32) { self.box_layout.measure(widget, orientation, for_size) } } diff --git a/src/chat/gui/widgets/mod.rs b/src/chat/gui/widgets/mod.rs index 03f88c5..3f0a901 100644 --- a/src/chat/gui/widgets/mod.rs +++ b/src/chat/gui/widgets/mod.rs @@ -1,7 +1,7 @@ mod imp; -use libadwaita::gtk::glib; use libadwaita::gtk; +use libadwaita::gtk::glib; glib::wrapper! { pub struct CustomLayout(ObjectSubclass) From 5986461b59fecaccd604e76d8a1ce3af0adb6847 Mon Sep 17 00:00:00 2001 From: MeexReay Date: Wed, 3 Sep 2025 20:30:34 +0300 Subject: [PATCH 8/8] refactor: coloring buttons --- src/chat/gui/mod.rs | 31 ++++++++++++++++++++++++------- src/chat/gui/page.rs | 2 +- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/src/chat/gui/mod.rs b/src/chat/gui/mod.rs index eab4e27..9037a30 100644 --- a/src/chat/gui/mod.rs +++ b/src/chat/gui/mod.rs @@ -188,6 +188,8 @@ fn build_sidebar_button( let delete_button = Button::from_icon_name("user-trash-symbolic"); + delete_button.set_css_classes(&["destructive-action"]); + delete_button.connect_clicked(clone!( #[weak] ctx, @@ -213,7 +215,10 @@ fn build_sidebar_button( } fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplitView) -> GtkBox { - let sidebar = GtkBox::new(Orientation::Vertical, 5); + let sidebar = GtkBox::new(Orientation::Vertical, 15); + + sidebar.set_margin_start(5); + sidebar.set_margin_end(5); sidebar.append( &Picture::builder() @@ -239,7 +244,7 @@ fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplit let add_server = Button::builder() .label("Add Server") // .start_icon_name("list-add-symbolic") - .margin_top(10) + .css_classes(["suggested-action"]) .build(); add_server.connect_clicked(clone!( @@ -254,22 +259,34 @@ fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplit move |_| { let dialog = Dialog::new(); - let vbox = GtkBox::new(Orientation::Vertical, 5); + let vbox = GtkBox::new(Orientation::Vertical, 10); vbox.set_margin_bottom(20); vbox.set_margin_top(20); vbox.set_margin_end(20); vbox.set_margin_start(20); - vbox.append(&Label::builder().label("Add server").build()); + vbox.append( + &Label::builder() + .label("Add server") + .css_classes(["title-2"]) + .build(), + ); - let entry = Entry::builder().placeholder_text("Server host").build(); + let entry = Entry::builder() + .placeholder_text("Server host") + .hexpand(true) + .build(); vbox.append(&entry); let hbox = GtkBox::new(Orientation::Horizontal, 5); - let confirm = Button::builder().label("Confirm").build(); + let confirm = Button::builder() + .label("Confirm") + .hexpand(true) + .css_classes(["suggested-action"]) + .build(); confirm.connect_clicked(clone!( #[weak] @@ -302,7 +319,7 @@ fn build_sidebar(ctx: Arc, app: &Application, split_view: &OverlaySplit hbox.append(&confirm); - let cancel = Button::builder().label("Cancel").build(); + let cancel = Button::builder().label("Cancel").hexpand(true).build(); cancel.connect_clicked(clone!( #[weak] diff --git a/src/chat/gui/page.rs b/src/chat/gui/page.rs index 01a55b6..578ae7f 100644 --- a/src/chat/gui/page.rs +++ b/src/chat/gui/page.rs @@ -424,7 +424,7 @@ pub fn build_page( let send_btn = Button::builder() .label("Send") - .css_classes(["send-text"]) + .css_classes(["send-text", "suggested-action"]) .cursor(&Cursor::from_name("pointer", None).unwrap()) .build();