Merge pull request #10 from MeexReay/feat/sidebar

Feat: sidebar
This commit is contained in:
MeexReay 2025-09-03 20:33:02 +03:00 committed by GitHub
commit 93384bb99b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 1927 additions and 1541 deletions

View File

@ -7,7 +7,7 @@
rust-overlay.url = "github:oxalica/rust-overlay"; 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: flake-utils.lib.eachDefaultSystem (system:
let let
devDeps = with pkgs; [ pkg-config openssl gtk4 pango libnotify libadwaita ]; devDeps = with pkgs; [ pkg-config openssl gtk4 pango libnotify libadwaita ];
@ -25,8 +25,8 @@
nativeBuildInputs = devDeps ++ [ rustc ]; nativeBuildInputs = devDeps ++ [ rustc ];
}; };
in { in {
devShells.nightly = (mkDevShell (pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default))); devShells.default = (mkDevShell (pkgs.rust-bin.selectLatestNightlyWith (toolchain: toolchain.default)));
devShells.default = (mkDevShell pkgs.rust-bin.stable.latest.default); devShells.stable = (mkDevShell pkgs.rust-bin.stable.latest.default);
packages.default = (pkgs.makeRustPlatform { packages.default = (pkgs.makeRustPlatform {
cargo = pkgs.rust-bin.nightly.latest.minimal; cargo = pkgs.rust-bin.nightly.latest.minimal;

View File

@ -3,6 +3,8 @@ use serde_default::DefaultFromSerde;
use serde_yml; use serde_yml;
use std::{error::Error, fs, path::PathBuf}; use std::{error::Error, fs, path::PathBuf};
use super::SERVER_LIST;
const MESSAGE_FORMAT: &str = "\u{B9AC}\u{3E70}<{name}> {text}"; const MESSAGE_FORMAT: &str = "\u{B9AC}\u{3E70}<{name}> {text}";
fn default_true() -> bool { fn default_true() -> bool {
@ -30,6 +32,10 @@ pub fn default_message_format() -> String {
MESSAGE_FORMAT.to_string() MESSAGE_FORMAT.to_string()
} }
pub fn default_servers() -> Vec<String> {
SERVER_LIST.to_vec()
}
#[derive(serde::Serialize, serde::Deserialize, DefaultFromSerde, Clone)] #[derive(serde::Serialize, serde::Deserialize, DefaultFromSerde, Clone)]
pub struct Config { pub struct Config {
#[serde(default = "default_host")] #[serde(default = "default_host")]
@ -70,6 +76,8 @@ pub struct Config {
pub debug_logs: bool, pub debug_logs: bool,
#[serde(default)] #[serde(default)]
pub avatar: Option<String>, pub avatar: Option<String>,
#[serde(default = "default_servers")]
pub servers: Vec<String>,
} }
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
@ -183,6 +191,7 @@ pub struct Args {
pub avatar: Option<String>, pub avatar: Option<String>,
#[arg(long)] #[arg(long)]
pub debug_logs: bool, pub debug_logs: bool,
// TODO: add servers
} }
impl Args { impl Args {

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

802
src/chat/gui/mod.rs Normal file
View File

@ -0,0 +1,802 @@
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 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 libadwaita::gdk::Texture;
use libadwaita::gtk::gdk_pixbuf::InterpType;
use libadwaita::gtk::{Button, Entry, Label, Picture};
use libadwaita::{self as adw, Avatar, Breakpoint, BreakpointCondition, Dialog, OverlaySplitView};
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::get_config_path;
use super::{
config::{save_config, Config},
ctx::Context,
print_message, recv_tick, sanitize_message,
};
mod page;
mod preferences;
mod widgets;
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<RwLock<Vec<libnotify::Notification>>>,
#[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))]
notifications: Arc<RwLock<Vec<String>>>,
avatars: Arc<Mutex<HashMap<u64, Vec<Avatar>>>>,
latest_sign: Arc<AtomicU64>,
}
thread_local!(
static GLOBAL: RefCell<Option<UiModel>> = RefCell::new(None);
);
pub fn clear_chat_messages(ctx: Arc<Context>, messages: Vec<String>) {
let _ = ctx
.sender
.read()
.unwrap()
.clone()
.unwrap()
.send((messages, true));
}
pub fn add_chat_messages(ctx: Arc<Context>, messages: Vec<String>) {
println!("add chat messages: {}", messages.len());
let _ = ctx
.sender
.read()
.unwrap()
.clone()
.unwrap()
.send((messages, false));
}
fn load_pixbuf(data: &[u8]) -> Result<Pixbuf, Box<dyn Error>> {
let loader = PixbufLoader::new();
loader.write(data)?;
loader.close()?;
Ok(loader.pixbuf().ok_or("laod pixbuf error")?)
}
fn update_window_title(ctx: Arc<Context>) {
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<Context>, 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_sidebar_button(
ctx: Arc<Context>,
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.set_css_classes(&["destructive-action"]);
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<Context>, app: &Application, split_view: &OverlaySplitView) -> GtkBox {
let sidebar = GtkBox::new(Orientation::Vertical, 15);
sidebar.set_margin_start(5);
sidebar.set_margin_end(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")
.css_classes(["suggested-action"])
.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, 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")
.css_classes(["title-2"])
.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")
.hexpand(true)
.css_classes(["suggested-action"])
.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").hexpand(true).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
}
fn build_ui(ctx: Arc<Context>, 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, page, chat_box, chat_scrolled) = build_page(ctx.clone(), app);
let split_view = OverlaySplitView::builder()
.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,
move |_| {
split_view.set_show_sidebar(!split_view.shows_sidebar());
}
));
header.pack_start(&toggle_button);
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();
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();
UiModel {
is_dark_theme,
chat_scrolled,
chat_box,
app: app.clone(),
window: window.clone(),
#[cfg(feature = "libnotify")]
notifications: Arc::new(RwLock::new(Vec::<libnotify::Notification>::new())),
#[cfg(all(not(feature = "libnotify"), not(feature = "notify-rust")))]
notifications: Arc::new(RwLock::new(Vec::<String>::new())),
avatars: Arc::new(Mutex::new(HashMap::new())),
latest_sign: Arc::new(AtomicU64::new(0)),
}
}
fn setup(_: &Application, ctx: Arc<Context>, 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<Context>, _: &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<Context>, 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<Context>, 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), &notif);
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<String>, response_limit: usize) -> Option<Vec<u8>> {
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<Context>, 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<Context>) {
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<Context>) {
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<Context>) {
#[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();
}
}

618
src/chat/gui/page.rs Normal file
View File

@ -0,0 +1,618 @@
use std::sync::{atomic::Ordering, Arc};
use std::thread;
use std::time::{Duration, SystemTime};
use chrono::Local;
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 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,
};
use crate::chat::{
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,
};
pub fn get_message_box(
ctx: Arc<Context>,
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!(
"<span color=\"{ip_color}\">{}</span> ",
glib::markup_escape_text(&ip)
));
}
}
label.push_str(&format!(
"<span color=\"{date_color}\">[{}]</span> ",
glib::markup_escape_text(&date)
));
if let Some((name, color)) = nick {
label.push_str(&format!(
"<span font_weight=\"bold\" color=\"{}\">&lt;{}&gt;</span> ",
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!(
"<span color=\"{text_color}\">{}</span>",
glib::markup_escape_text(&content)
));
} else {
label.push_str(&format!(
"<span color=\"{text_color}\">{}</span>",
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<Context>,
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!(
"<span color=\"{color}\">{}</span> <span color=\"{date_color}\">{}</span> <span color=\"{ip_color}\">{}</span>",
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!(
"<span color=\"{text_color}\">{}</span>",
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
}
/// header, page_box, chat_box, chat_scrolled
pub fn build_page(
ctx: Arc<Context>,
app: &Application,
) -> (HeaderBar, GtkBox, 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));
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();
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 || {
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", "suggested-action"])
.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);
(header, page_box, chat_box, chat_scrolled)
}
fn build_widget_box(ctx: Arc<Context>, _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
}

427
src/chat/gui/preferences.rs Normal file
View File

@ -0,0 +1,427 @@
use std::sync::Arc;
use adw::gdk::Display;
use adw::glib::clone;
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},
ctx::Context,
};
use super::{try_save_config, update_window_title};
pub fn open_settings(ctx: Arc<Context>, 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 old_config = ctx.config.read().unwrap().clone();
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)
}
},
servers: old_config.servers,
};
ctx.set_config(&config);
try_save_config(get_config_path(), &config);
update_window_title(ctx.clone());
});
dialog.present(app.active_window().as_ref());
}

View File

@ -4,7 +4,7 @@
margin: -35px; margin: -35px;
} }
.widget_box { .widget-box {
box-shadow: 0 10px 10px rgba(0, 0, 0, 0.20); box-shadow: 0 10px 10px rgba(0, 0, 0, 0.20);
border-bottom: 2px solid rgba(0, 0, 0, 0.20); border-bottom: 2px solid rgba(0, 0, 0, 0.20);
min-height: 121px; min-height: 121px;
@ -29,4 +29,4 @@
/* .message-name-green { color: #70fa7a; } /* .message-name-green { color: #70fa7a; }
.message-name-red { color: #fa7070; } .message-name-red { color: #fa7070; }
.message-name-magenta { color: #da70fa; } .message-name-magenta { color: #da70fa; }
.message-name-cyan { color: #70fadc; } */ .message-name-cyan { color: #70fadc; } */

View File

@ -0,0 +1,51 @@
use libadwaita::{glib, gtk};
use glib::object::ObjectExt;
use gtk::{prelude::LayoutManagerExt, subclass::prelude::*, 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<Vec<glib::subclass::Signal>> = OnceLock::new();
SIGNALS.get_or_init(|| vec![glib::subclass::Signal::builder("size-changed").build()])
}
}
impl LayoutManagerImpl for CustomLayout {
fn allocate(&self, widget: &gtk::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: &gtk::Widget,
orientation: gtk::Orientation,
for_size: i32,
) -> (i32, i32, i32, i32) {
self.box_layout.measure(widget, orientation, for_size)
}
}

View File

@ -0,0 +1,15 @@
mod imp;
use libadwaita::gtk;
use libadwaita::gtk::glib;
glib::wrapper! {
pub struct CustomLayout(ObjectSubclass<imp::CustomLayout>)
@extends gtk::LayoutManager;
}
impl Default for CustomLayout {
fn default() -> Self {
glib::Object::new()
}
}