From ca4fe422f22866748f2cb6239b31170a974d254b Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sat, 25 Feb 2023 14:22:49 +0000 Subject: [PATCH 1/5] feat(truncate): ability to set fixed length BREAKING CHANGE: This changes the behaviour of `truncate.length`. A new property, `truncate.max_length`, has been introduced that uses the old behaviour. --- docs/modules/Focused.md | 17 +++++++++-------- docs/modules/Music.md | 35 ++++++++++++++++++----------------- src/config/truncate.rs | 22 +++++++++++++++++----- 3 files changed, 44 insertions(+), 30 deletions(-) diff --git a/docs/modules/Focused.md b/docs/modules/Focused.md index 52469d3..514cbcf 100644 --- a/docs/modules/Focused.md +++ b/docs/modules/Focused.md @@ -7,14 +7,15 @@ Displays the title and/or icon of the currently focused window. > Type: `focused` -| Name | Type | Default | Description | -|-------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `show_icon` | `boolean` | `true` | Whether to show the app's icon | -| `show_title` | `boolean` | `true` | Whether to show the app's title | -| `icon_size` | `integer` | `32` | Size of icon in pixels | -| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | -| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | -| `truncate.length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| Name | Type | Default | Description | +|-----------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `show_icon` | `boolean` | `true` | Whether to show the app's icon | +| `show_title` | `boolean` | `true` | Whether to show the app's title | +| `icon_size` | `integer` | `32` | Size of icon in pixels | +| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | +| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | +| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
JSON diff --git a/docs/modules/Music.md b/docs/modules/Music.md index 46608c7..044c816 100644 --- a/docs/modules/Music.md +++ b/docs/modules/Music.md @@ -11,23 +11,24 @@ in MPRIS mode, the widget will listen to all players and automatically detect/di > Type: `music` -| | Type | Default | Description | -|-------------------|---------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| -| `player_type` | `mpris` or `mpd` | `mpris` | Whether to connect to MPRIS players or an MPD server. | -| `format` | `string` | `{title} / {artist}` | Format string for the widget. More info below. | -| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | -| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | -| `truncate.length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | -| `icons.play` | `string/image` | `` | Icon to show when playing. | -| `icons.pause` | `string/image` | `` | Icon to show when paused. | -| `icons.prev` | `string/image` | `玲` | Icon to show on previous button. | -| `icons.next` | `string/image` | `怜` | Icon to show on next button. | -| `icons.volume` | `string/image` | `墳` | Icon to show under popup volume slider. | -| `icons.track` | `string/image` | `` | Icon to show next to track title. | -| `icons.album` | `string/image` | `` | Icon to show next to album name. | -| `icons.artist` | `string/image` | `ﴁ` | Icon to show next to artist name. | -| `host` | `string/image` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. | -| `music_dir` | `string/image` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. | +| | Type | Default | Description | +|-----------------------|---------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `player_type` | `mpris` or `mpd` | `mpris` | Whether to connect to MPRIS players or an MPD server. | +| `format` | `string` | `{title} / {artist}` | Format string for the widget. More info below. | +| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | +| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | +| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | +| `icons.play` | `string/image` | `` | Icon to show when playing. | +| `icons.pause` | `string/image` | `` | Icon to show when paused. | +| `icons.prev` | `string/image` | `玲` | Icon to show on previous button. | +| `icons.next` | `string/image` | `怜` | Icon to show on next button. | +| `icons.volume` | `string/image` | `墳` | Icon to show under popup volume slider. | +| `icons.track` | `string/image` | `` | Icon to show next to track title. | +| `icons.album` | `string/image` | `` | Icon to show next to album name. | +| `icons.artist` | `string/image` | `ﴁ` | Icon to show next to artist name. | +| `host` | `string/image` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. | +| `music_dir` | `string/image` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. | See [here](images) for information on images. diff --git a/src/config/truncate.rs b/src/config/truncate.rs index ebf8a8c..fed4ffc 100644 --- a/src/config/truncate.rs +++ b/src/config/truncate.rs @@ -24,31 +24,43 @@ impl From for GtkEllipsizeMode { #[serde(untagged)] pub enum TruncateMode { Auto(EllipsizeMode), - MaxLength { + Length { mode: EllipsizeMode, length: Option, + max_length: Option, }, } impl TruncateMode { const fn mode(&self) -> EllipsizeMode { match self { - Self::MaxLength { mode, .. } | Self::Auto(mode) => *mode, + Self::Length { mode, .. } | Self::Auto(mode) => *mode, } } const fn length(&self) -> Option { match self { Self::Auto(_) => None, - Self::MaxLength { length, .. } => *length, + Self::Length { length, .. } => *length, + } + } + + const fn max_length(&self) -> Option { + match self { + Self::Auto(_) => None, + Self::Length { max_length, .. } => *max_length, } } pub fn truncate_label(&self, label: >k::Label) { label.set_ellipsize(self.mode().into()); - if let Some(max_length) = self.length() { - label.set_max_width_chars(max_length); + if let Some(length) = self.length() { + label.set_width_chars(length); + } + + if let Some(length) = self.max_length() { + label.set_max_width_chars(length); } } } From d84139a914f9b35054dc6048715e1ed7e79d7441 Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sat, 25 Feb 2023 14:24:21 +0000 Subject: [PATCH 2/5] refactor: general tidy up fix clippy warnings from latest stable rust --- src/bar.rs | 2 -- src/config/mod.rs | 13 +------------ src/image/provider.rs | 6 +++--- src/modules/launcher/mod.rs | 12 ++++++++---- src/modules/sysinfo.rs | 9 ++++++--- 5 files changed, 18 insertions(+), 24 deletions(-) diff --git a/src/bar.rs b/src/bar.rs index 0c508f1..e2aa12e 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -394,8 +394,6 @@ fn setup_module_common_options(container: EventBox, common: CommonConfig) { let scroll_down_script = common.on_scroll_down.map(Script::new_polling); container.connect_scroll_event(move |_, event| { - println!("{:?}", event.direction()); - let script = match event.direction() { ScrollDirection::Up => scroll_up_script.as_ref(), ScrollDirection::Down => scroll_down_script.as_ref(), diff --git a/src/config/mod.rs b/src/config/mod.rs index a742fd0..395d194 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -74,7 +74,7 @@ impl Default for BarPosition { } } -#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)] +#[derive(Debug, Default, Deserialize, Copy, Clone, PartialEq, Eq)] pub struct MarginConfig { #[serde(default)] pub bottom: i32, @@ -86,17 +86,6 @@ pub struct MarginConfig { pub top: i32, } -impl Default for MarginConfig { - fn default() -> Self { - MarginConfig { - bottom: 0, - left: 0, - right: 0, - top: 0, - } - } -} - #[derive(Debug, Deserialize, Clone)] pub struct Config { #[serde(default)] diff --git a/src/image/provider.rs b/src/image/provider.rs index e70cd65..52be37c 100644 --- a/src/image/provider.rs +++ b/src/image/provider.rs @@ -132,16 +132,16 @@ impl<'a> ImageProvider<'a> { }); } } else { - self.load_into_image_sync(image)?; + self.load_into_image_sync(&image)?; }; #[cfg(not(feature = "http"))] - self.load_into_image_sync(image)?; + self.load_into_image_sync(&image)?; Ok(()) } - fn load_into_image_sync(&self, image: gtk::Image) -> Result<()> { + fn load_into_image_sync(&self, image: >k::Image) -> Result<()> { let pixbuf = match &self.location { ImageLocation::Icon { name, theme } => self.get_from_icon(name, theme), ImageLocation::Local(path) => self.get_from_file(path), diff --git a/src/modules/launcher/mod.rs b/src/modules/launcher/mod.rs index cbb61b1..43f905c 100644 --- a/src/modules/launcher/mod.rs +++ b/src/modules/launcher/mod.rs @@ -110,9 +110,9 @@ impl Module for LauncherModule { let wl = wayland::get_client().await; let open_windows = read_lock!(wl.toplevels); - let mut items = lock!(items); - - for (_, (window, _)) in open_windows.clone() { + let open_windows = open_windows.clone(); + for (_, (window, _)) in open_windows { + let mut items = lock!(items); let item = items.get_mut(&window.app_id); match item { Some(item) => { @@ -124,6 +124,7 @@ impl Module for LauncherModule { } } + let items = lock!(items); let items = items.iter(); for (_, item) in items { tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::AddItem( @@ -281,7 +282,7 @@ impl Module for LauncherModule { ItemEvent::FocusItem(app_id) => items .get(&app_id) .and_then(|item| item.windows.first().map(|(_, win)| win.id)), - ItemEvent::FocusWindow(id) => Some(id), + ItemEvent::FocusWindow(id) => Some(id), // FIXME: Broken on wlroots-git ItemEvent::OpenItem(_) => unreachable!(), }; @@ -292,6 +293,9 @@ impl Module for LauncherModule { handle.activate(seat); }; } + + // roundtrip to immediately send activate event + wl.roundtrip(); } } }); diff --git a/src/modules/sysinfo.rs b/src/modules/sysinfo.rs index 7107cd5..32e6a1d 100644 --- a/src/modules/sysinfo.rs +++ b/src/modules/sysinfo.rs @@ -361,16 +361,19 @@ fn refresh_system_tokens(format_info: &mut HashMap, sys: &System // no refresh required for these tokens let load_average = sys.load_average(); - format_info.insert(String::from("load_average:1"), load_average.one.to_string()); + format_info.insert( + String::from("load_average:1"), + format!("{:.2}", load_average.one), + ); format_info.insert( String::from("load_average:5"), - load_average.five.to_string(), + format!("{:.2}", load_average.five), ); format_info.insert( String::from("load_average:15"), - load_average.fifteen.to_string(), + format!("{:.2}", load_average.fifteen), ); let uptime = Duration::from_secs(sys.uptime()).as_secs(); From 83a49165c42fa793ef1224f93cbc147bc69de894 Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sat, 25 Feb 2023 14:28:37 +0000 Subject: [PATCH 3/5] docs(compiling): add info about build deps --- README.md | 17 +++++++++++++++-- docs/Compiling.md | 22 ++++++++++++++++++++++ docs/_Sidebar.md | 3 ++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eb1a206..12ef692 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,23 @@ It uses GTK3 and gtk-layer-shell. The bar can be styled to your liking using CSS and hot-loads style changes. For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki). -![Screenshot of fully configured bar with MPD widget open](https://f.jstanger.dev/github/ironbar/bar.png) +![Screenshot of fully configured bar with MPD widget open](https://f.jstanger.dev/github/ironbar/bar.png?raw) + +## Features + +- First-class support for Sway and Hyprland, but should (mostly) work on any wlroots compositor. +- Fully themeable with CSS and hot-loaded styles. +- Support for multiple configuration languages. +- Popups used by widgets to show rich content and controls on click. +- Out of the box widgets which can be used to create anything from a lightweight to a more traditional desktop experience. +- Ability to create custom widgets (including popups), run scripts and inject dynamic content. ## Installation ### Cargo +Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed. + ```sh cargo install ironbar ``` @@ -74,6 +85,8 @@ in case you don't want to compile Ironbar. ### Source +Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed. + ```sh git clone https://github.com/jakestanger/ironbar.git cd ironbar @@ -83,7 +96,7 @@ install target/release/ironbar ~/.local/bin/ironbar ``` By default, all features are enabled. -See [here](https://github.com/JakeStanger/ironbar/wiki/compiling) for controlling which features are included. +See [here](https://github.com/JakeStanger/ironbar/wiki/compiling#features) for controlling which features are included. [repo](https://github.com/jakestanger/ironbar) diff --git a/docs/Compiling.md b/docs/Compiling.md index 23e065c..d7a18b0 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -9,6 +9,28 @@ cargo build --release install target/release/ironbar ~/.local/bin/ironbar ``` +## Build requirements + +To build from source, you must have GTK (>= 3.22) and GTK Layer Shell installed. + +### Arch + +```shell +pacman -S gtk3 gtk-layer-shell +``` + +### Ubuntu/Debian + +```shell +apt install libgtk-3-dev libgtk-layer-shell-dev +``` + +### Fedora + +```shell +dnf install gtk3 gtk-layer-shell +``` + ## Features By default, all features are enabled for convenience. This can result in a significant compile time. diff --git a/docs/_Sidebar.md b/docs/_Sidebar.md index 58f98eb..61a393f 100644 --- a/docs/_Sidebar.md +++ b/docs/_Sidebar.md @@ -1,10 +1,10 @@ # Guides +- [Compiling from source](compiling) - [Configuration guide](configuration-guide) - [Scripts](scripts) - [Images](images) - [Styling guide](styling-guide) -- [Examples](https://github.com/JakeStanger/ironbar/tree/master/examples) # Examples @@ -17,6 +17,7 @@ # Modules +- [Clipboard](clipboard) - [Clock](clock) - [Custom](custom) - [Focused](focused) From 5bbe64bb86fb2db0921e284a1560db2f6c1a1920 Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sat, 25 Feb 2023 14:28:45 +0000 Subject: [PATCH 4/5] docs(clock): format table --- docs/modules/Clock.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/modules/Clock.md b/docs/modules/Clock.md index 72ed9fe..99a944b 100644 --- a/docs/modules/Clock.md +++ b/docs/modules/Clock.md @@ -69,9 +69,9 @@ end: ## Styling -| Selector | Description | -|-------------------------------|------------------------------------------------------------------------------------| -| `#clock` | Clock widget button | -| `#popup-clock` | Clock popup box | +| Selector | Description | +|--------------------------------|------------------------------------------------------------------------------------| +| `#clock` | Clock widget button | +| `#popup-clock` | Clock popup box | | `#popup-clock #calendar-clock` | Clock inside the popup | | `#popup-clock #calendar` | Calendar widget inside the popup. GTK provides some OOTB styling options for this. | \ No newline at end of file From 575d6cc30f9e28079aed8425566048abd3d9e022 Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sat, 25 Feb 2023 14:30:45 +0000 Subject: [PATCH 5/5] feat: new clipboard manager module --- Cargo.lock | 43 ++- Cargo.toml | 9 +- docs/Compiling.md | 1 + docs/modules/Clipboard.md | 93 ++++++ examples/config.corn | 25 +- examples/config.json | 20 +- examples/config.toml | 18 +- examples/config.yaml | 60 ++-- src/bar.rs | 20 +- src/clients/clipboard.rs | 245 ++++++++++++++ src/clients/mod.rs | 2 + src/clients/wayland/client.rs | 233 +++++++++++-- src/clients/wayland/mod.rs | 70 ++-- .../wayland/wlr_data_control/device.rs | 88 +++++ .../wayland/wlr_data_control/manager.rs | 253 ++++++++++++++ src/clients/wayland/wlr_data_control/mod.rs | 259 ++++++++++++++ src/clients/wayland/wlr_data_control/offer.rs | 74 ++++ .../wayland/wlr_data_control/source.rs | 54 +++ .../handle.rs} | 0 .../manager.rs} | 9 +- .../wayland/wlr_foreign_toplevel/mod.rs | 39 +++ src/config/mod.rs | 22 +- src/image/gtk.rs | 2 +- src/image/mod.rs | 2 +- src/modules/clipboard.rs | 315 ++++++++++++++++++ src/modules/mod.rs | 2 + 26 files changed, 1809 insertions(+), 149 deletions(-) create mode 100644 docs/modules/Clipboard.md create mode 100644 src/clients/clipboard.rs create mode 100644 src/clients/wayland/wlr_data_control/device.rs create mode 100644 src/clients/wayland/wlr_data_control/manager.rs create mode 100644 src/clients/wayland/wlr_data_control/mod.rs create mode 100644 src/clients/wayland/wlr_data_control/offer.rs create mode 100644 src/clients/wayland/wlr_data_control/source.rs rename src/clients/wayland/{toplevel.rs => wlr_foreign_toplevel/handle.rs} (100%) rename src/clients/wayland/{toplevel_manager.rs => wlr_foreign_toplevel/manager.rs} (96%) create mode 100644 src/clients/wayland/wlr_foreign_toplevel/mod.rs create mode 100644 src/modules/clipboard.rs diff --git a/Cargo.lock b/Cargo.lock index a707628..99e8b5d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1360,6 +1360,7 @@ dependencies = [ "libcorn", "mpd_client", "mpris", + "nix 0.26.2", "notify", "regex", "reqwest", @@ -1658,6 +1659,20 @@ dependencies = [ "memoffset 0.6.5", ] +[[package]] +name = "nix" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfdda3d196821d6af13126e40375cdf7da646a96114af134d5f417a9a1dc8e1a" +dependencies = [ + "bitflags", + "cfg-if", + "libc", + "memoffset 0.7.1", + "pin-utils", + "static_assertions", +] + [[package]] name = "nom" version = "7.1.1" @@ -3274,45 +3289,45 @@ dependencies = [ [[package]] name = "windows_aarch64_gnullvm" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d2aa71f6f0cbe00ae5167d90ef3cfe66527d6f613ca78ac8024c3ccab9a19e" +checksum = "8c9864e83243fdec7fc9c5444389dcbbfd258f745e7853198f365e3c4968a608" [[package]] name = "windows_aarch64_msvc" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd0f252f5a35cac83d6311b2e795981f5ee6e67eb1f9a7f64eb4500fbc4dcdb4" +checksum = "4c8b1b673ffc16c47a9ff48570a9d85e25d265735c503681332589af6253c6c7" [[package]] name = "windows_i686_gnu" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbeae19f6716841636c28d695375df17562ca208b2b7d0dc47635a50ae6c5de7" +checksum = "de3887528ad530ba7bdbb1faa8275ec7a1155a45ffa57c37993960277145d640" [[package]] name = "windows_i686_msvc" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c12f65daa39dd2babe6e442988fc329d6243fdce47d7d2d155b8d874862246" +checksum = "bf4d1122317eddd6ff351aa852118a2418ad4214e6613a50e0191f7004372605" [[package]] name = "windows_x86_64_gnu" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf7b1b21b5362cbc318f686150e5bcea75ecedc74dd157d874d754a2ca44b0ed" +checksum = "c1040f221285e17ebccbc2591ffdc2d44ee1f9186324dd3e84e99ac68d699c45" [[package]] name = "windows_x86_64_gnullvm" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09d525d2ba30eeb3297665bd434a54297e4170c7f1a44cad4ef58095b4cd2028" +checksum = "628bfdf232daa22b0d64fdb62b09fcc36bb01f05a3939e20ab73aaf9470d0463" [[package]] name = "windows_x86_64_msvc" -version = "0.42.0" +version = "0.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40009d85759725a34da6d89a94e63d7bdc50a862acf0dbc7c8e488f1edcb6f5" +checksum = "447660ad36a13288b1db4d4248e857b510e8c3a225c822ba4fb748c0aafecffd" [[package]] name = "winreg" diff --git a/Cargo.toml b/Cargo.toml index 2330d71..4cd4d4e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ description = "Customisable GTK Layer Shell wlroots/sway bar" default = [ "http", "config+all", + "clipboard", "clock", "music+all", "sys_info", @@ -24,6 +25,8 @@ http = ["dep:reqwest"] "config+toml" = ["toml"] "config+corn" = ["libcorn"] +clipboard = ["nix"] + clock = ["chrono"] music = ["regex"] @@ -60,6 +63,7 @@ notify = { version = "5.0.0", default-features = false } wayland-client = "0.29.5" wayland-protocols = { version = "0.29.5", features = ["unstable_protocols", "client"] } smithay-client-toolkit = { version = "0.16.0", default-features = false, features = ["calloop"] } + lazy_static = "1.4.0" async_once = "0.2.6" cfg-if = "1.0.0" @@ -73,6 +77,9 @@ serde_yaml = { version = "0.9.4", optional = true } toml = { version = "0.7.0", optional = true } libcorn = { version = "0.6.1", optional = true } +# clipboard +nix = { version = "0.26.2", optional = true } + # clock chrono = { version = "0.4.19", optional = true } @@ -92,4 +99,4 @@ hyprland = { version = "0.3.0", optional = true } futures-util = { version = "0.3.21", optional = true } # shared -regex = { version = "1.6.0", default-features = false, features = ["std"], optional = true } # music, sys_info \ No newline at end of file +regex = { version = "1.6.0", default-features = false, features = ["std"], optional = true } # music, sys_info diff --git a/docs/Compiling.md b/docs/Compiling.md index d7a18b0..6e02668 100644 --- a/docs/Compiling.md +++ b/docs/Compiling.md @@ -61,6 +61,7 @@ cargo build --release --no-default-features \ | config+toml | Enables configuration support for TOML. | | config+corn | Enables configuration support for [Corn](https://github.com/jakestanger.corn). | | **Modules** | | +| clipboard | Enables the `clipboard` module. | | clock | Enables the `clock` module. | | music+all | Enables the `music` module with support for all player types. | | music+mpris | Enables the `music` module with MPRIS support. | diff --git a/docs/modules/Clipboard.md b/docs/modules/Clipboard.md new file mode 100644 index 0000000..24161dc --- /dev/null +++ b/docs/modules/Clipboard.md @@ -0,0 +1,93 @@ +Shows recent clipboard items, allowing you to switch between them to re-copy previous values. +Clicking the icon button opens the popup containing all functionality. + +Supports plain text and images. + +![Screenshot of clipboard popup open, with two textual values and an image copied. Several other unrelated widgets are visible on the bar.](https://f.jstanger.dev/github/ironbar/clipboard.png?raw) + +## Configuration + +> Type: `clipboard` + +| Name | Type | Default | Description | +|-----------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------| +| `icon` | `string/image` | `󰨸` | Icon to show on the widget button. | +| `max_items` | `integer` | `10` | Maximum number of items to show on the bar. | +| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. | +| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. | +| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. | +| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. | + +See [here](images) for information on images. + +
+JSON + +```json +{ + "end": { + "type": "clipboard", + "max_items": 3, + "truncate": { + "mode": "end", + "length": 50 + } + } +} +``` +
+ +
+TOML + +```toml +[[end]] +type = "clipboard" +max_items = 3 + +[[end.truncate]] +mode = "end" +length = 50 +``` +
+ +
+YAML + +```yaml +end: + - type: 'clipboard' + max_items: 3 + truncate: + mode: 'end' + length: 50 +``` +
+ +
+Corn + +```corn +{ + end = [ { + type = "clipboard" + max_items = 3 + truncate.mode = "end" + truncate.length = 50 + } ] +} +``` +
+ +## Styling + +| Selector | Description | +|--------------------------------------|------------------------------------------------------| +| `#clipboard` | Clipboard widget. | +| `#clipboard .btn` | Clipboard widget button. | +| `#popup-clipboard` | Clipboard popup box. | +| `#popup-clipboard .item` | Clipboard row item inside the popup. | +| `#popup-clipboard .item .btn` | Clipboard row item radio button. | +| `#popup-clipboard .item .btn.text` | Clipboard row item radio button (text values only). | +| `#popup-clipboard .item .btn.image` | Clipboard row item radio button (image values only). | +| `#popup-clipboard .item .btn-remove` | Clipboard row item remove button. | \ No newline at end of file diff --git a/examples/config.corn b/examples/config.corn index 2f96167..9fc572c 100644 --- a/examples/config.corn +++ b/examples/config.corn @@ -20,8 +20,18 @@ let { show_icons = true } - $mpd_local = { type = "mpd" music_dir = "/home/jake/Music" } - $mpd_server = { type = "mpd" host = "chloe:6600" } + $mpris = { + type = "music" + player_type = "mpris" + + on_click_middle = "playerctl play-pause" + on_scroll_up = "playerctl volume +5" + on_scroll_down = "playerctl volume -5" + + } + + $mpd_local = { type = "music" player_type = "mpd" music_dir = "/home/jake/Music" truncate.mode = "end" truncate.max_length = 100 } + $mpd_server = { type = "music" player_type = "mpd" host = "chloe:6600" truncate = "end" } $sys_info = { type = "sys_info" @@ -55,6 +65,8 @@ let { show_if.interval = 500 } + $clipboard = { type = "clipboard" max_items = 3 truncate.mode = "end" truncate.length = 50 } + // -- begin custom -- $button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } @@ -86,10 +98,13 @@ let { // -- end custom -- $left = [ $workspaces $launcher ] - $right = [ $mpd_local $mpd_server $phone_battery $sys_info $power_menu $clock ] + $right = [ $mpd_local $mpd_server $phone_battery $sys_info $clipboard $power_menu $clock ] } in { anchor_to_edges = true - position = "top" - start = $left end = $right + position = "bottom" + icon_theme = "Paper" + + start = $left + end = $right } diff --git a/examples/config.json b/examples/config.json index 84f6c30..05f3e04 100644 --- a/examples/config.json +++ b/examples/config.json @@ -5,7 +5,7 @@ "music_dir": "/home/jake/Music", "player_type": "mpd", "truncate": { - "length": 100, + "max_length": 100, "mode": "end" }, "type": "music" @@ -43,6 +43,14 @@ }, "type": "sys_info" }, + { + "max_items": 3, + "truncate": { + "length": 50, + "mode": "end" + }, + "type": "clipboard" + }, { "bar": [ { @@ -98,16 +106,6 @@ "icon_theme": "Paper", "position": "bottom", "start": [ - { - "bar": [ - { - "size": 32, - "src": "file:///path/to/image.jpg", - "type": "image" - } - ], - "type": "custom" - }, { "all_monitors": false, "name_map": { diff --git a/examples/config.toml b/examples/config.toml index 181bb8b..a511b59 100644 --- a/examples/config.toml +++ b/examples/config.toml @@ -8,7 +8,7 @@ player_type = 'mpd' type = 'music' [end.truncate] -length = 100 +max_length = 100 mode = 'end' [[end]] @@ -44,6 +44,14 @@ memory = 30 networks = 3 temps = 5 +[[end]] +max_items = 3 +type = 'clipboard' + +[end.truncate] +length = 50 +mode = 'end' + [[end]] class = 'power-menu' tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}''' @@ -87,14 +95,6 @@ type = 'label' [[end]] type = 'clock' -[[start]] -type = 'custom' - -[[start.bar]] -size = 32 -src = 'file:///path/to/image.jpg' -type = 'image' - [[start]] all_monitors = false type = 'workspaces' diff --git a/examples/config.yaml b/examples/config.yaml index debb1cd..59ee18e 100644 --- a/examples/config.yaml +++ b/examples/config.yaml @@ -1,50 +1,20 @@ anchor_to_edges: true -icon_theme: Paper -position: bottom - -start: - - bar: - - size: 32 - src: file:///path/to/image.jpg - type: image - type: custom - - - all_monitors: false - name_map: - '1': ﭮ - '2': icon:firefox - '3':  - Code:  - Games: icon:steam - type: workspaces - - - favorites: - - firefox - - discord - - Steam - show_icons: true - show_names: false - type: launcher - end: - music_dir: /home/jake/Music player_type: mpd truncate: - length: 100 + max_length: 100 mode: end type: music - - host: chloe:6600 player_type: mpd truncate: end type: music - - cmd: /home/jake/bin/phone-battery show_if: cmd: /home/jake/bin/phone-connected interval: 500 type: script - - format: -  {cpu_percent}% | {temp_c:k10temp_Tccd1}°C -  {memory_used} / {memory_total} GB ({memory_percent}%) @@ -60,7 +30,11 @@ end: networks: 3 temps: 5 type: sys_info - + - max_items: 3 + truncate: + length: 50 + mode: end + type: clipboard - bar: - label:  name: power-btn @@ -89,9 +63,23 @@ end: type: label tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}' type: custom - - type: clock - - - +icon_theme: Paper +position: bottom +start: + - all_monitors: false + name_map: + '1': ﭮ + '2': icon:firefox + '3':  + Code:  + Games: icon:steam + type: workspaces + - favorites: + - firefox + - discord + - Steam + show_icons: true + show_names: false + type: launcher diff --git a/src/bar.rs b/src/bar.rs index e2aa12e..b18978b 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -193,7 +193,7 @@ fn add_modules(content: >k::Box, modules: Vec, info: &ModuleInfo macro_rules! add_module { ($module:expr, $id:expr) => {{ let common = $module.common.take().expect("Common config did not exist"); - let widget = create_module($module, $id, &info, &Arc::clone(&popup))?; + let widget = create_module(*$module, $id, &info, &Arc::clone(&popup))?; let container = wrap_widget(&widget); content.add(&container); @@ -203,6 +203,8 @@ fn add_modules(content: >k::Box, modules: Vec, info: &ModuleInfo for (id, config) in modules.into_iter().enumerate() { match config { + #[cfg(feature = "clipboard")] + ModuleConfig::Clipboard(mut module) => add_module!(module, id), #[cfg(feature = "clock")] ModuleConfig::Clock(mut module) => add_module!(module, id), ModuleConfig::Custom(mut module) => add_module!(module, id), @@ -289,6 +291,10 @@ fn setup_receiver( ) where TSend: Clone + Send + 'static, { + // some rare cases can cause the popup to incorrectly calculate its size on first open. + // we can fix that by just force re-rendering it on its first open. + let mut has_popup_opened = false; + channel.recv(move |ev| { match ev { ModuleUpdateEvent::Update(update) => { @@ -306,6 +312,12 @@ fn setup_receiver( } else { popup.show_content(id); popup.show(geometry); + + if !has_popup_opened { + popup.show_content(id); + popup.show(geometry); + has_popup_opened = true; + } } } ModuleUpdateEvent::OpenPopup(geometry) => { @@ -315,6 +327,12 @@ fn setup_receiver( popup.hide(); popup.show_content(id); popup.show(geometry); + + if !has_popup_opened { + popup.show_content(id); + popup.show(geometry); + has_popup_opened = true; + } } ModuleUpdateEvent::ClosePopup => { debug!("Closing popup for {} [#{}]", name, id); diff --git a/src/clients/clipboard.rs b/src/clients/clipboard.rs new file mode 100644 index 0000000..4301b2d --- /dev/null +++ b/src/clients/clipboard.rs @@ -0,0 +1,245 @@ +use super::wayland::{self, ClipboardItem}; +use crate::{lock, try_send}; +use indexmap::map::Iter; +use indexmap::IndexMap; +use lazy_static::lazy_static; +use std::sync::{Arc, Mutex}; +use tokio::spawn; +use tokio::sync::mpsc; +use tracing::debug; + +#[derive(Debug)] +pub enum ClipboardEvent { + Add(Arc), + Remove(usize), + Activate(usize), +} + +type EventSender = mpsc::Sender; + +/// Clipboard client singleton, +/// to ensure bars don't duplicate requests to the compositor. +pub struct ClipboardClient { + senders: Arc>>, + cache: Arc>, +} + +impl ClipboardClient { + fn new() -> Self { + let senders = Arc::new(Mutex::new(Vec::<(EventSender, usize)>::new())); + + let cache = Arc::new(Mutex::new(ClipboardCache::new())); + + { + let senders = senders.clone(); + let cache = cache.clone(); + + spawn(async move { + let mut rx = { + let wl = wayland::get_client().await; + wl.subscribe_clipboard() + }; + + while let Ok(item) = rx.recv().await { + debug!("Received clipboard item (ID: {})", item.id); + + let (existing_id, cache_size) = { + let cache = lock!(cache); + (cache.contains(&item), cache.len()) + }; + + existing_id.map_or_else( + || { + { + let mut cache = lock!(cache); + let senders = lock!(senders); + cache.insert(item.clone(), senders.len()); + } + let senders = lock!(senders); + let iter = senders.iter(); + for (tx, sender_cache_size) in iter { + if cache_size == *sender_cache_size { + let mut cache = lock!(cache); + let removed_id = cache + .remove_ref_first() + .expect("Clipboard cache unexpectedly empty"); + try_send!(tx, ClipboardEvent::Remove(removed_id)); + } + try_send!(tx, ClipboardEvent::Add(item.clone())); + } + }, + |existing_id| { + let senders = lock!(senders); + let iter = senders.iter(); + for (tx, _) in iter { + try_send!(tx, ClipboardEvent::Activate(existing_id)); + } + }, + ); + } + }); + } + + Self { senders, cache } + } + + pub async fn subscribe(&self, cache_size: usize) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(16); + + let wl = wayland::get_client().await; + wl.roundtrip(); + + { + let mut cache = lock!(self.cache); + + if let Some(item) = wl.get_clipboard() { + cache.insert_or_inc_ref(item); + } + + let iter = cache.iter(); + for (id, (item, _)) in iter { + println!("Initialising value with id {id}"); + try_send!(tx, ClipboardEvent::Add(item.clone())); + } + } + + { + let mut senders = lock!(self.senders); + senders.push((tx, cache_size)); + } + + rx + } + + pub async fn copy(&self, id: usize) { + debug!("Copying item with id {id}"); + + let item = { + let cache = lock!(self.cache); + cache.get(id) + }; + + if let Some(item) = item { + let wl = wayland::get_client().await; + wl.copy_to_clipboard(item); + } + + let senders = lock!(self.senders); + let iter = senders.iter(); + for (tx, _) in iter { + try_send!(tx, ClipboardEvent::Activate(id)); + } + } + + pub fn remove(&self, id: usize) { + let mut cache = lock!(self.cache); + cache.remove(id); + + let senders = lock!(self.senders); + let iter = senders.iter(); + for (tx, _) in iter { + try_send!(tx, ClipboardEvent::Remove(id)); + } + } +} + +/// Shared clipboard item cache. +/// +/// Items are stored with a number of references, +/// allowing different consumers to 'remove' cached items +/// at different times. +#[derive(Debug)] +struct ClipboardCache { + cache: IndexMap, usize)>, +} + +impl ClipboardCache { + /// Creates a new empty cache. + fn new() -> Self { + Self { + cache: IndexMap::new(), + } + } + + /// Gets the entry with key `id` from the cache. + fn get(&self, id: usize) -> Option> { + self.cache.get(&id).map(|(item, _)| item).cloned() + } + + /// Inserts an entry with `ref_count` initial references. + fn insert(&mut self, item: Arc, ref_count: usize) -> Option> { + self.cache + .insert(item.id, (item, ref_count)) + .map(|(item, _)| item) + } + + /// Inserts an entry with `ref_count` initial references, + /// or increments the `ref_count` by 1 if it already exists. + fn insert_or_inc_ref(&mut self, item: Arc) { + let mut item = self.cache.entry(item.id).or_insert((item, 0)); + item.1 += 1; + } + + /// Removes the entry with key `id`. + /// This ignores references. + fn remove(&mut self, id: usize) -> Option> { + self.cache.shift_remove(&id).map(|(item, _)| item) + } + + /// Removes a reference to the entry with key `id`. + /// + /// If the reference count reaches zero, the entry + /// is removed from the cache. + fn remove_ref(&mut self, id: usize) { + if let Some(entry) = self.cache.get_mut(&id) { + entry.1 -= 1; + + if entry.1 == 0 { + self.cache.shift_remove(&id); + } + } + } + + /// Removes a reference to the first entry. + /// + /// If the reference count reaches zero, the entry + /// is removed from the cache. + fn remove_ref_first(&mut self) -> Option { + if let Some((id, _)) = self.cache.first() { + let id = *id; + self.remove_ref(id); + Some(id) + } else { + None + } + } + + /// Checks if an item with matching mime type and value + /// already exists in the cache. + fn contains(&self, item: &ClipboardItem) -> Option { + self.cache.values().find_map(|(it, _)| { + if it.mime_type == item.mime_type && it.value == item.value { + Some(it.id) + } else { + None + } + }) + } + + /// Gets the current number of items in the cache. + fn len(&self) -> usize { + self.cache.len() + } + + fn iter(&self) -> Iter<'_, usize, (Arc, usize)> { + self.cache.iter() + } +} + +lazy_static! { + static ref CLIENT: ClipboardClient = ClipboardClient::new(); +} + +pub fn get_client() -> &'static ClipboardClient { + &CLIENT +} diff --git a/src/clients/mod.rs b/src/clients/mod.rs index 31d72ba..d14fd0a 100644 --- a/src/clients/mod.rs +++ b/src/clients/mod.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "clipboard")] +pub mod clipboard; #[cfg(feature = "workspaces")] pub mod compositor; #[cfg(feature = "music")] diff --git a/src/clients/wayland/client.rs b/src/clients/wayland/client.rs index b0eb716..11adc02 100644 --- a/src/clients/wayland/client.rs +++ b/src/clients/wayland/client.rs @@ -1,31 +1,61 @@ -use super::toplevel::{ToplevelEvent, ToplevelInfo}; -use super::toplevel_manager::listen_for_toplevels; -use super::ToplevelChange; -use super::{Env, ToplevelHandler}; -use crate::{error as err, send, write_lock}; +use super::wlr_foreign_toplevel::{ + handle::{ToplevelEvent, ToplevelInfo}, + manager::listen_for_toplevels, +}; +use super::{DData, Env, ToplevelHandler}; +use crate::{error as err, send}; +use cfg_if::cfg_if; use color_eyre::Report; use indexmap::IndexMap; use smithay_client_toolkit::environment::Environment; use smithay_client_toolkit::output::{with_output_info, OutputInfo}; -use smithay_client_toolkit::reexports::calloop; -use smithay_client_toolkit::{new_default_environment, WaylandSource}; +use smithay_client_toolkit::reexports::calloop::channel::{channel, Event, Sender}; +use smithay_client_toolkit::reexports::calloop::EventLoop; +use smithay_client_toolkit::WaylandSource; +use std::collections::HashMap; use std::sync::{Arc, RwLock}; -use std::time::Duration; use tokio::sync::{broadcast, oneshot}; use tokio::task::spawn_blocking; -use tracing::{error, trace}; +use tracing::{debug, error}; use wayland_client::protocol::wl_seat::WlSeat; +use wayland_client::{ConnectError, Display, EventQueue}; use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{ zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1, zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1, }; +cfg_if! { + if #[cfg(feature = "clipboard")] { + use super::{ClipboardItem}; + use super::wlr_data_control::manager::{listen_to_devices, DataControlDeviceHandler}; + use crate::{read_lock, write_lock}; + use tokio::spawn; + } +} + +#[derive(Debug)] +pub enum Request { + /// Copies the value to the clipboard + #[cfg(feature = "clipboard")] + CopyToClipboard(Arc), + /// Forces a dispatch, flushing any currently queued events + Refresh, +} + pub struct WaylandClient { pub outputs: Vec, pub seats: Vec, + pub toplevels: Arc>>, toplevel_tx: broadcast::Sender, _toplevel_rx: broadcast::Receiver, + + #[cfg(feature = "clipboard")] + clipboard_tx: broadcast::Sender>, + #[cfg(feature = "clipboard")] + clipboard: Arc>>>, + + request_tx: Sender, } impl WaylandClient { @@ -35,21 +65,44 @@ impl WaylandClient { let (toplevel_tx, toplevel_rx) = broadcast::channel(32); - let toplevel_tx2 = toplevel_tx.clone(); - let toplevels = Arc::new(RwLock::new(IndexMap::new())); let toplevels2 = toplevels.clone(); - // `queue` is not send so we need to handle everything inside the task + let toplevel_tx2 = toplevel_tx.clone(); + + cfg_if! { + if #[cfg(feature = "clipboard")] { + let (clipboard_tx, mut clipboard_rx) = broadcast::channel(32); + let clipboard = Arc::new(RwLock::new(None)); + let clipboard_tx2 = clipboard_tx.clone(); + } + } + + let (ev_tx, ev_rx) = channel::(); + + // `queue` is not `Send` so we need to handle everything inside the task spawn_blocking(move || { + let toplevels = toplevels2; + let toplevel_tx = toplevel_tx2; + let (env, _display, queue) = - new_default_environment!(Env, fields = [toplevel: ToplevelHandler::init()]) - .expect("Failed to connect to Wayland compositor"); + Self::new_environment().expect("Failed to connect to Wayland compositor"); + + let mut event_loop = + EventLoop::::try_new().expect("Failed to create new event loop"); + WaylandSource::new(queue) + .quick_insert(event_loop.handle()) + .expect("Failed to insert Wayland event queue into event loop"); let outputs = Self::get_outputs(&env); send!(output_tx, outputs); let seats = env.get_all_seats(); + + // TODO: Actually handle seats properly + #[cfg(feature = "clipboard")] + let default_seat = seats[0].detach(); + send!( seat_tx, seats @@ -58,30 +111,56 @@ impl WaylandClient { .collect::>() ); + let handle = event_loop.handle(); + handle + .insert_source(ev_rx, move |event, _metadata, ddata| { + // let env = &ddata.env; + match event { + Event::Msg(Request::Refresh) => debug!("Received refresh event"), + #[cfg(feature = "clipboard")] + Event::Msg(Request::CopyToClipboard(value)) => { + super::wlr_data_control::copy_to_clipboard( + &ddata.env, + &default_seat, + &value, + ) + .expect("Failed to copy to clipboard"); + } + Event::Closed => panic!("Channel unexpectedly closed"), + } + }) + .expect("Failed to insert channel into event queue"); + let _toplevel_manager = env.require_global::(); - let _listener = listen_for_toplevels(env, move |handle, event, _ddata| { - trace!("Received toplevel event: {:?}", event); - - if event.change == ToplevelChange::Close { - write_lock!(toplevels2).remove(&event.toplevel.id); - } else { - write_lock!(toplevels2) - .insert(event.toplevel.id, (event.toplevel.clone(), handle)); - } - - send!(toplevel_tx2, event); + let _toplevel_listener = listen_for_toplevels(&env, move |handle, event, _ddata| { + super::wlr_foreign_toplevel::update_toplevels( + &toplevels, + handle, + event, + &toplevel_tx, + ); }); - let mut event_loop = - calloop::EventLoop::<()>::try_new().expect("Failed to create new event loop"); - WaylandSource::new(queue) - .quick_insert(event_loop.handle()) - .expect("Failed to insert event loop into wayland event queue"); + cfg_if! { + if #[cfg(feature = "clipboard")] { + let clipboard_tx = clipboard_tx2; + let handle = event_loop.handle(); + + let _offer_listener = listen_to_devices(&env, move |_seat, event, ddata| { + debug!("Received clipboard event"); + super::wlr_data_control::receive_offer(event, &handle, clipboard_tx.clone(), ddata); + }); + } + } + + let mut data = DData { + env, + offer_tokens: HashMap::new(), + }; loop { - // TODO: Avoid need for duration here - can we force some event when sending requests? - if let Err(err) = event_loop.dispatch(Duration::from_millis(50), &mut ()) { + if let Err(err) = event_loop.dispatch(None, &mut data) { error!( "{:?}", Report::new(err).wrap_err("Failed to dispatch pending wayland events") @@ -90,6 +169,18 @@ impl WaylandClient { } }); + // keep track of current clipboard item + #[cfg(feature = "clipboard")] + { + let clipboard = clipboard.clone(); + spawn(async move { + while let Ok(item) = clipboard_rx.recv().await { + let mut clipboard = write_lock!(clipboard); + clipboard.replace(item); + } + }); + } + let outputs = output_rx.await.expect(err::ERR_CHANNEL_RECV); let seats = seat_rx.await.expect(err::ERR_CHANNEL_RECV); @@ -97,9 +188,14 @@ impl WaylandClient { Self { outputs, seats, + #[cfg(feature = "clipboard")] + clipboard, toplevels, toplevel_tx, _toplevel_rx: toplevel_rx, + #[cfg(feature = "clipboard")] + clipboard_tx, + request_tx: ev_tx, } } @@ -107,6 +203,26 @@ impl WaylandClient { self.toplevel_tx.subscribe() } + #[cfg(feature = "clipboard")] + pub fn subscribe_clipboard(&self) -> broadcast::Receiver> { + self.clipboard_tx.subscribe() + } + + pub fn roundtrip(&self) { + send!(self.request_tx, Request::Refresh); + } + + #[cfg(feature = "clipboard")] + pub fn get_clipboard(&self) -> Option> { + let clipboard = read_lock!(self.clipboard); + clipboard.as_ref().cloned() + } + + #[cfg(feature = "clipboard")] + pub fn copy_to_clipboard(&self, item: Arc) { + send!(self.request_tx, Request::CopyToClipboard(item)); + } + fn get_outputs(env: &Environment) -> Vec { let outputs = env.get_all_outputs(); @@ -115,4 +231,57 @@ impl WaylandClient { .filter_map(|output| with_output_info(output, Clone::clone)) .collect() } + + fn new_environment() -> Result<(Environment, Display, EventQueue), ConnectError> { + Display::connect_to_env().and_then(|display| { + let mut queue = display.create_event_queue(); + let ret = { + let mut sctk_seats = smithay_client_toolkit::seat::SeatHandler::new(); + let sctk_data_device_manager = + smithay_client_toolkit::data_device::DataDeviceHandler::init(&mut sctk_seats); + + #[cfg(feature = "clipboard")] + let data_control_device = DataControlDeviceHandler::init(&mut sctk_seats); + + let sctk_primary_selection_manager = + smithay_client_toolkit::primary_selection::PrimarySelectionHandler::init( + &mut sctk_seats, + ); + + let display = ::smithay_client_toolkit::reexports::client::Proxy::clone(&display); + let env = Environment::new( + &display.attach(queue.token()), + &mut queue, + Env { + sctk_compositor: smithay_client_toolkit::environment::SimpleGlobal::new(), + sctk_subcompositor: smithay_client_toolkit::environment::SimpleGlobal::new( + ), + sctk_shm: smithay_client_toolkit::shm::ShmHandler::new(), + sctk_outputs: smithay_client_toolkit::output::OutputHandler::new(), + sctk_seats, + sctk_data_device_manager, + sctk_primary_selection_manager, + toplevel: ToplevelHandler::init(), + #[cfg(feature = "clipboard")] + data_control_device, + }, + ); + + if let Ok(env) = env.as_ref() { + let _psm = env.get_primary_selection_manager(); + } + + env + }; + match ret { + Ok(env) => Ok((env, display, queue)), + Err(_e) => display.protocol_error().map_or_else( + || Err(ConnectError::NoCompositorListening), + |perr| { + panic!("[SCTK] A protocol error occured during initial setup: {perr}"); + }, + ), + } + }) + } } diff --git a/src/clients/wayland/mod.rs b/src/clients/wayland/mod.rs index 54f4fc5..0f567d9 100644 --- a/src/clients/wayland/mod.rs +++ b/src/clients/wayland/mod.rs @@ -1,21 +1,32 @@ mod client; -mod toplevel; -mod toplevel_manager; -extern crate smithay_client_toolkit as sctk; +mod wlr_foreign_toplevel; +use std::collections::HashMap; use async_once::AsyncOnce; use lazy_static::lazy_static; -pub use toplevel::{ToplevelChange, ToplevelEvent, ToplevelInfo}; -use toplevel_manager::{ToplevelHandler, ToplevelHandling, ToplevelStatusListener}; -use wayland_client::{Attached, DispatchData, Interface}; -use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{ - zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1, - zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1, -}; +use std::fmt::Debug; +use cfg_if::cfg_if; +use smithay_client_toolkit::default_environment; +use smithay_client_toolkit::environment::Environment; +use smithay_client_toolkit::reexports::calloop::RegistrationToken; +use wayland_client::{Attached, Interface}; +use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1; +pub use wlr_foreign_toplevel::handle::{ToplevelChange, ToplevelEvent, ToplevelInfo}; +use wlr_foreign_toplevel::manager::{ToplevelHandler}; pub use client::WaylandClient; +cfg_if! { + if #[cfg(feature = "clipboard")] { + mod wlr_data_control; + + use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1; + use wlr_data_control::manager::DataControlDeviceHandler; + pub use wlr_data_control::{ClipboardItem, ClipboardValue}; + } +} + /// A utility for lazy-loading globals. /// Taken from `smithay_client_toolkit` where it's not exposed #[derive(Debug)] @@ -25,21 +36,32 @@ enum LazyGlobal { Bound(Attached), } -sctk::default_environment!(Env, - fields = [ - toplevel: ToplevelHandler - ], - singles = [ - ZwlrForeignToplevelManagerV1 => toplevel - ], -); +pub struct DData { + env: Environment, + offer_tokens: HashMap, +} -impl ToplevelHandling for Env { - fn listen(&mut self, f: F) -> ToplevelStatusListener - where - F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static, - { - self.toplevel.listen(f) +cfg_if! { + if #[cfg(feature = "clipboard")] { + default_environment!(Env, + fields = [ + toplevel: ToplevelHandler, + data_control_device: DataControlDeviceHandler + ], + singles = [ + ZwlrForeignToplevelManagerV1 => toplevel, + ZwlrDataControlManagerV1 => data_control_device + ], + ); + } else { + default_environment!(Env, + fields = [ + toplevel: ToplevelHandler, + ], + singles = [ + ZwlrForeignToplevelManagerV1 => toplevel, + ], + ); } } diff --git a/src/clients/wayland/wlr_data_control/device.rs b/src/clients/wayland/wlr_data_control/device.rs new file mode 100644 index 0000000..493eec6 --- /dev/null +++ b/src/clients/wayland/wlr_data_control/device.rs @@ -0,0 +1,88 @@ +use super::offer::DataControlOffer; +use super::source::DataControlSource; +use crate::lock; +use std::sync::{Arc, Mutex}; +use wayland_client::protocol::wl_seat::WlSeat; +use wayland_client::{Attached, DispatchData, Main}; +use wayland_protocols::wlr::unstable::data_control::v1::client::{ + zwlr_data_control_device_v1::{Event, ZwlrDataControlDeviceV1}, + zwlr_data_control_manager_v1::ZwlrDataControlManagerV1, + zwlr_data_control_offer_v1::ZwlrDataControlOfferV1, +}; + +#[derive(Debug)] +struct Inner { + offer: Option>, +} + +impl Inner { + fn new_offer(&mut self, offer: &Main) { + self.offer.replace(Arc::new(DataControlOffer::new(offer))); + } +} + +#[derive(Debug, Clone)] +pub struct DataControlDeviceEvent(pub Arc); + +fn data_control_device_implem( + event: Event, + inner: &mut Inner, + implem: &mut F, + ddata: DispatchData, +) where + F: FnMut(DataControlDeviceEvent, DispatchData), +{ + match event { + Event::DataOffer { id } => { + inner.new_offer(&id); + } + Event::Selection { id: Some(offer) } => { + let inner_offer = inner + .offer + .clone() + .expect("Offer should exist at this stage"); + if offer == inner_offer.offer { + implem(DataControlDeviceEvent(inner_offer), ddata); + } + } + _ => {} + } +} + +pub struct DataControlDevice { + device: ZwlrDataControlDeviceV1, + _inner: Arc>, +} + +impl DataControlDevice { + pub fn init_for_seat( + manager: &Attached, + seat: &WlSeat, + mut callback: F, + ) -> Self + where + F: FnMut(DataControlDeviceEvent, DispatchData) + 'static, + { + let inner = Arc::new(Mutex::new(Inner { offer: None })); + + let device = manager.get_data_device(seat); + + { + let inner = inner.clone(); + device.quick_assign(move |_handle, event, ddata| { + let mut inner = lock!(inner); + data_control_device_implem(event, &mut inner, &mut callback, ddata); + }); + } + + Self { + device: device.detach(), + _inner: inner, + } + } + + pub fn set_selection(&self, source: &Option) { + self.device + .set_selection(source.as_ref().map(|s| &s.source)); + } +} diff --git a/src/clients/wayland/wlr_data_control/manager.rs b/src/clients/wayland/wlr_data_control/manager.rs new file mode 100644 index 0000000..fcd239a --- /dev/null +++ b/src/clients/wayland/wlr_data_control/manager.rs @@ -0,0 +1,253 @@ +use super::device::{DataControlDevice, DataControlDeviceEvent}; +use super::source::DataControlSource; +use smithay_client_toolkit::data_device::WritePipe; +use smithay_client_toolkit::environment::{Environment, GlobalHandler}; +use smithay_client_toolkit::seat::{SeatHandling, SeatListener}; +use smithay_client_toolkit::MissingGlobal; +use std::cell::RefCell; +use std::rc::{self, Rc}; +use tracing::warn; +use wayland_client::protocol::wl_registry::WlRegistry; +use wayland_client::protocol::wl_seat::WlSeat; +use wayland_client::{Attached, DispatchData}; +use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1; + +enum DataControlDeviceHandlerInner { + Ready { + manager: Attached, + devices: Vec<(WlSeat, DataControlDevice)>, + status_listeners: Rc>>>>, + }, + Pending { + seats: Vec, + status_listeners: Rc>>>>, + }, +} + +impl DataControlDeviceHandlerInner { + fn init_manager(&mut self, manager: Attached) { + let (seats, status_listeners) = if let Self::Pending { + seats, + status_listeners, + } = self + { + (std::mem::take(seats), status_listeners.clone()) + } else { + warn!("Ignoring second zwlr_data_control_manager_v1"); + return; + }; + + let mut devices = Vec::new(); + + for seat in seats { + let my_seat = seat.clone(); + let status_listeners = status_listeners.clone(); + let device = + DataControlDevice::init_for_seat(&manager, &seat, move |event, dispatch_data| { + notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners); + }); + devices.push((seat.clone(), device)); + } + + *self = Self::Ready { + manager, + devices, + status_listeners, + }; + } + + fn get_manager(&self) -> Option> { + match self { + Self::Ready { manager, .. } => Some(manager.clone()), + Self::Pending { .. } => None, + } + } + + fn new_seat(&mut self, seat: &WlSeat) { + match self { + Self::Ready { + manager, + devices, + status_listeners, + } => { + if devices.iter().any(|(s, _)| s == seat) { + // the seat already exists, nothing to do + return; + } + let my_seat = seat.clone(); + let status_listeners = status_listeners.clone(); + let device = + DataControlDevice::init_for_seat(manager, seat, move |event, dispatch_data| { + notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners); + }); + devices.push((seat.clone(), device)); + } + Self::Pending { seats, .. } => { + seats.push(seat.clone()); + } + } + } + + fn remove_seat(&mut self, seat: &WlSeat) { + match self { + Self::Ready { devices, .. } => devices.retain(|(s, _)| s != seat), + Self::Pending { seats, .. } => seats.retain(|s| s != seat), + } + } + + fn create_source(&self, mime_types: Vec, callback: F) -> Option + where + F: FnMut(String, WritePipe, DispatchData) + 'static, + { + match self { + Self::Ready { manager, .. } => { + let source = DataControlSource::new(manager, mime_types, callback); + Some(source) + } + Self::Pending { .. } => None, + } + } + + fn with_device(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal> + where + F: FnOnce(&DataControlDevice), + { + match self { + Self::Ready { devices, .. } => { + let device = devices + .iter() + .find_map(|(s, device)| if s == seat { Some(device) } else { None }); + + device.map_or(Err(MissingGlobal), |device| { + f(device); + Ok(()) + }) + } + Self::Pending { .. } => Err(MissingGlobal), + } + } +} + +pub struct DataControlDeviceHandler { + inner: Rc>, + status_listeners: Rc>>>>, + _seat_listener: SeatListener, +} + +impl DataControlDeviceHandler { + pub fn init(seat_handler: &mut S) -> Self + where + S: SeatHandling, + { + let status_listeners = Rc::new(RefCell::new(Vec::new())); + + let inner = Rc::new(RefCell::new(DataControlDeviceHandlerInner::Pending { + seats: Vec::new(), + status_listeners: status_listeners.clone(), + })); + + let seat_inner = inner.clone(); + let seat_listener = seat_handler.listen(move |seat, seat_data, _| { + if seat_data.defunct { + seat_inner.borrow_mut().remove_seat(&seat); + } else { + seat_inner.borrow_mut().new_seat(&seat); + } + }); + + Self { + inner, + _seat_listener: seat_listener, + status_listeners, + } + } +} + +impl GlobalHandler for DataControlDeviceHandler { + fn created( + &mut self, + registry: Attached, + id: u32, + version: u32, + _ddata: DispatchData, + ) { + // data control manager is supported until version 2 + let version = std::cmp::min(version, 2); + + let manager = registry.bind::(version, id); + self.inner.borrow_mut().init_manager((*manager).clone()); + } + + fn get(&self) -> Option> { + RefCell::borrow(&self.inner).get_manager() + } +} + +type DataControlDeviceStatusCallback = + dyn FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static; + +/// Notifies the callbacks of an event on the data device +fn notify_status_listeners( + seat: &WlSeat, + event: &DataControlDeviceEvent, + mut ddata: DispatchData, + listeners: &RefCell>>>, +) { + listeners.borrow_mut().retain(|lst| { + rc::Weak::upgrade(lst).map_or(false, |cb| { + (cb.borrow_mut())(seat.clone(), event.clone(), ddata.reborrow()); + true + }) + }); +} + +pub struct DataControlDeviceStatusListener { + _cb: Rc>, +} + +pub trait DataControlDeviceHandling { + fn listen(&mut self, f: F) -> DataControlDeviceStatusListener + where + F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static; + + fn with_data_control_device(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal> + where + F: FnOnce(&DataControlDevice); + + fn create_source(&self, mime_types: Vec, callback: F) -> Option + where + F: FnMut(String, WritePipe, DispatchData) + 'static; +} + +impl DataControlDeviceHandling for DataControlDeviceHandler { + fn listen(&mut self, f: F) -> DataControlDeviceStatusListener + where + F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static, + { + let rc = Rc::new(RefCell::new(f)) as Rc<_>; + self.status_listeners.borrow_mut().push(Rc::downgrade(&rc)); + DataControlDeviceStatusListener { _cb: rc } + } + + fn with_data_control_device(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal> + where + F: FnOnce(&DataControlDevice), + { + RefCell::borrow(&self.inner).with_device(seat, f) + } + + fn create_source(&self, mime_types: Vec, callback: F) -> Option + where + F: FnMut(String, WritePipe, DispatchData) + 'static, + { + RefCell::borrow(&self.inner).create_source(mime_types, callback) + } +} + +pub fn listen_to_devices(env: &Environment, f: F) -> DataControlDeviceStatusListener +where + E: DataControlDeviceHandling, + F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static, +{ + env.with_inner(move |inner| DataControlDeviceHandling::listen(inner, f)) +} diff --git a/src/clients/wayland/wlr_data_control/mod.rs b/src/clients/wayland/wlr_data_control/mod.rs new file mode 100644 index 0000000..2a5f99a --- /dev/null +++ b/src/clients/wayland/wlr_data_control/mod.rs @@ -0,0 +1,259 @@ +pub mod device; +pub mod manager; +pub mod offer; +pub mod source; + +use super::Env; +use crate::clients::wayland::DData; +use crate::send; +use color_eyre::Report; +use device::{DataControlDevice, DataControlDeviceEvent}; +use glib::Bytes; +use manager::{DataControlDeviceHandling, DataControlDeviceStatusListener}; +use smithay_client_toolkit::data_device::WritePipe; +use smithay_client_toolkit::environment::Environment; +use smithay_client_toolkit::reexports::calloop::LoopHandle; +use smithay_client_toolkit::MissingGlobal; +use source::DataControlSource; +use std::fs::File; +use std::io; +use std::io::{Read, Write}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::UNIX_EPOCH; +use tokio::sync::broadcast; +use tracing::{debug, error, trace}; +use wayland_client::protocol::wl_seat::WlSeat; +use wayland_client::DispatchData; + +static COUNTER: AtomicUsize = AtomicUsize::new(1); + +const INTERNAL_MIME_TYPE: &str = "x-ironbar-internal"; + +fn get_id() -> usize { + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +#[derive(Debug, Clone, Eq)] +pub struct ClipboardItem { + pub id: usize, + pub value: ClipboardValue, + pub mime_type: String, +} + +impl PartialEq for ClipboardItem { + fn eq(&self, other: &Self) -> bool { + self.id == other.id + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClipboardValue { + Text(String), + Image(Bytes), + Other, +} + +impl DataControlDeviceHandling for Env { + fn listen(&mut self, f: F) -> DataControlDeviceStatusListener + where + F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static, + { + self.data_control_device.listen(f) + } + + fn with_data_control_device(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal> + where + F: FnOnce(&DataControlDevice), + { + self.data_control_device.with_data_control_device(seat, f) + } + + fn create_source(&self, mime_types: Vec, callback: F) -> Option + where + F: FnMut(String, WritePipe, DispatchData) + 'static, + { + self.data_control_device.create_source(mime_types, callback) + } +} + +pub fn copy_to_clipboard( + env: &Environment, + seat: &WlSeat, + item: &ClipboardItem, +) -> Result<(), MissingGlobal> +where + E: DataControlDeviceHandling, +{ + debug!("Copying item with id {} [{}]", item.id, item.mime_type); + trace!("Copying: {item:?}"); + + let item = item.clone(); + + env.with_inner(|env| { + let mime_types = vec![INTERNAL_MIME_TYPE.to_string(), item.mime_type]; + let source = env.create_source(mime_types, move |mime_type, mut pipe, _ddata| { + debug!( + "Triggering source callback for item with id {} [{}]", + item.id, mime_type + ); + + // FIXME: Not working for large (buffered) values in xwayland + let bytes = match &item.value { + ClipboardValue::Text(text) => text.as_bytes(), + ClipboardValue::Image(bytes) => bytes.as_ref(), + ClipboardValue::Other => panic!( + "{:?}", + io::Error::new( + io::ErrorKind::Other, + "Attempted to copy unsupported mime type", + ) + ), + }; + + if let Err(err) = pipe.write_all(bytes) { + error!("{err:?}"); + } + }); + + env.with_data_control_device(seat, |device| device.set_selection(&source)) + }) +} + +#[derive(Debug)] +struct MimeType { + value: String, + category: MimeTypeCategory, +} + +#[derive(Debug)] +enum MimeTypeCategory { + Text, + Image, +} + +impl MimeType { + fn parse(mime_types: &[String]) -> Option { + mime_types + .iter() + .map(|s| s.to_lowercase()) + .find_map(|mime_type| match mime_type.as_str() { + "text" + | "string" + | "utf8_string" + | "text/plain" + | "text/plain;charset=utf-8" + | "text/plain;charset=iso-8859-1" + | "text/plain;charset=us-ascii" + | "text/plain;charset=unicode" => Some(Self { + value: mime_type, + category: MimeTypeCategory::Text, + }), + "image/png" | "image/jpg" | "image/jpeg" | "image/tiff" | "image/bmp" + | "image/x-bmp" | "image/icon" => Some(Self { + value: mime_type, + category: MimeTypeCategory::Image, + }), + _ => None, + }) + } +} + +pub fn receive_offer( + event: DataControlDeviceEvent, + handle: &LoopHandle, + tx: broadcast::Sender>, + mut ddata: DispatchData, +) { + let timestamp = std::time::SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("Could not get epoch, system time is probably very wrong") + .as_nanos(); + + let offer = event.0; + + let ddata = ddata + .get::() + .expect("Expected dispatch data to exist"); + + let handle2 = handle.clone(); + + let res = offer.with_mime_types(|mime_types| { + debug!("Offer mime types: {mime_types:?}"); + + if mime_types.contains(&INTERNAL_MIME_TYPE.to_string()) { + debug!("Skipping value provided by bar"); + return Ok(()); + } + + let mime_type = MimeType::parse(mime_types); + debug!("Detected mime type: {mime_type:?}"); + + match mime_type { + Some(mime_type) => { + debug!("[{timestamp}] Sending clipboard read request ({mime_type:?})"); + let read_pipe = offer.receive(mime_type.value.clone())?; + let source = handle.insert_source(read_pipe, move |(), file, ddata| { + debug!( + "[{timestamp}] Reading clipboard contents ({:?})", + &mime_type.category + ); + match read_file(&mime_type, file) { + Ok(item) => { + send!(tx, Arc::new(item)); + } + Err(err) => error!("{err:?}"), + } + + if let Some(src) = ddata.offer_tokens.remove(×tamp) { + handle2.remove(src); + } + })?; + + ddata.offer_tokens.insert(timestamp, source); + } + None => { + // send an event so the clipboard module is aware it's changed + send!( + tx, + Arc::new(ClipboardItem { + id: usize::MAX, + mime_type: String::new(), + value: ClipboardValue::Other + }) + ); + } + } + + Ok::<(), Report>(()) + }); + + if let Err(err) = res { + error!("{err:?}"); + } +} + +fn read_file(mime_type: &MimeType, file: &mut File) -> io::Result { + let value = match mime_type.category { + MimeTypeCategory::Text => { + let mut txt = String::new(); + file.read_to_string(&mut txt)?; + + ClipboardValue::Text(txt) + } + MimeTypeCategory::Image => { + let mut bytes = vec![]; + file.read_to_end(&mut bytes)?; + let bytes = Bytes::from(&bytes); + + println!("Num bytes: {}", bytes.len()); + ClipboardValue::Image(bytes) + } + }; + + Ok(ClipboardItem { + id: get_id(), + value, + mime_type: mime_type.value.clone(), + }) +} diff --git a/src/clients/wayland/wlr_data_control/offer.rs b/src/clients/wayland/wlr_data_control/offer.rs new file mode 100644 index 0000000..ba9dd2b --- /dev/null +++ b/src/clients/wayland/wlr_data_control/offer.rs @@ -0,0 +1,74 @@ +use crate::lock; +use nix::fcntl::OFlag; +use nix::unistd::{close, pipe2}; +use smithay_client_toolkit::data_device::ReadPipe; +use std::io; +use std::os::fd::FromRawFd; +use std::sync::{Arc, Mutex}; +use tracing::warn; +use wayland_client::Main; +use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_offer_v1::{ + Event, ZwlrDataControlOfferV1, +}; + +#[derive(Debug, Clone)] +struct Inner { + mime_types: Vec, +} + +#[derive(Debug, Clone)] +pub struct DataControlOffer { + inner: Arc>, + pub(crate) offer: ZwlrDataControlOfferV1, +} + +impl DataControlOffer { + pub(crate) fn new(offer: &Main) -> Self { + let inner = Arc::new(Mutex::new(Inner { + mime_types: Vec::new(), + })); + + { + let inner = inner.clone(); + + offer.quick_assign(move |_, event, _| { + let mut inner = lock!(inner); + if let Event::Offer { mime_type } = event { + inner.mime_types.push(mime_type); + } + }); + } + + Self { + offer: offer.detach(), + inner, + } + } + + pub fn with_mime_types(&self, f: F) -> T + where + F: FnOnce(&[String]) -> T, + { + let inner = lock!(self.inner); + f(&inner.mime_types) + } + + pub fn receive(&self, mime_type: String) -> io::Result { + // create a pipe + let (readfd, writefd) = pipe2(OFlag::O_CLOEXEC)?; + + self.offer.receive(mime_type, writefd); + + if let Err(err) = close(writefd) { + warn!("Failed to close write pipe: {}", err); + } + + Ok(unsafe { FromRawFd::from_raw_fd(readfd) }) + } +} + +impl Drop for DataControlOffer { + fn drop(&mut self) { + self.offer.destroy(); + } +} diff --git a/src/clients/wayland/wlr_data_control/source.rs b/src/clients/wayland/wlr_data_control/source.rs new file mode 100644 index 0000000..305b46a --- /dev/null +++ b/src/clients/wayland/wlr_data_control/source.rs @@ -0,0 +1,54 @@ +use smithay_client_toolkit::data_device::WritePipe; +use std::os::fd::FromRawFd; +use wayland_client::{Attached, DispatchData}; +use wayland_protocols::wlr::unstable::data_control::v1::client::{ + zwlr_data_control_manager_v1::ZwlrDataControlManagerV1, + zwlr_data_control_source_v1::{Event, ZwlrDataControlSourceV1}, +}; + +fn data_control_source_impl( + source: &ZwlrDataControlSourceV1, + event: Event, + implem: &mut F, + ddata: DispatchData, +) where + F: FnMut(String, WritePipe, DispatchData), +{ + match event { + Event::Send { mime_type, fd } => { + let pipe = unsafe { FromRawFd::from_raw_fd(fd) }; + implem(mime_type, pipe, ddata); + } + Event::Cancelled => source.destroy(), + _ => unreachable!(), + } +} + +pub struct DataControlSource { + pub(crate) source: ZwlrDataControlSourceV1, +} + +impl DataControlSource { + pub fn new( + manager: &Attached, + mime_types: Vec, + mut callback: F, + ) -> Self + where + F: FnMut(String, WritePipe, DispatchData) + 'static, + { + let source = manager.create_data_source(); + + source.quick_assign(move |source, evt, ddata| { + data_control_source_impl(&source, evt, &mut callback, ddata); + }); + + for mime_type in mime_types { + source.offer(mime_type); + } + + Self { + source: source.detach(), + } + } +} diff --git a/src/clients/wayland/toplevel.rs b/src/clients/wayland/wlr_foreign_toplevel/handle.rs similarity index 100% rename from src/clients/wayland/toplevel.rs rename to src/clients/wayland/wlr_foreign_toplevel/handle.rs diff --git a/src/clients/wayland/toplevel_manager.rs b/src/clients/wayland/wlr_foreign_toplevel/manager.rs similarity index 96% rename from src/clients/wayland/toplevel_manager.rs rename to src/clients/wayland/wlr_foreign_toplevel/manager.rs index 4eafb17..af136fa 100644 --- a/src/clients/wayland/toplevel_manager.rs +++ b/src/clients/wayland/wlr_foreign_toplevel/manager.rs @@ -1,9 +1,8 @@ -use super::toplevel::{Toplevel, ToplevelEvent}; -use super::LazyGlobal; +use super::handle::{Toplevel, ToplevelEvent}; +use crate::wayland::LazyGlobal; use smithay_client_toolkit::environment::{Environment, GlobalHandler}; use std::cell::RefCell; -use std::rc; -use std::rc::Rc; +use std::rc::{self, Rc}; use tracing::warn; use wayland_client::protocol::wl_registry::WlRegistry; use wayland_client::{Attached, DispatchData}; @@ -155,7 +154,7 @@ impl ToplevelHandling for ToplevelHandler { } } -pub fn listen_for_toplevels(env: Environment, f: F) -> ToplevelStatusListener +pub fn listen_for_toplevels(env: &Environment, f: F) -> ToplevelStatusListener where E: ToplevelHandling, F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static, diff --git a/src/clients/wayland/wlr_foreign_toplevel/mod.rs b/src/clients/wayland/wlr_foreign_toplevel/mod.rs new file mode 100644 index 0000000..edb9691 --- /dev/null +++ b/src/clients/wayland/wlr_foreign_toplevel/mod.rs @@ -0,0 +1,39 @@ +use std::sync::RwLock; +use indexmap::IndexMap; +use tokio::sync::broadcast::Sender; +use tracing::trace; +use super::Env; +use handle::{ToplevelEvent, ToplevelChange, ToplevelInfo}; +use manager::{ToplevelHandling, ToplevelStatusListener}; +use wayland_client::DispatchData; +use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1; +use crate::{send, write_lock}; + +pub mod handle; +pub mod manager; + +impl ToplevelHandling for Env { + fn listen(&mut self, f: F) -> ToplevelStatusListener + where + F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static, + { + self.toplevel.listen(f) + } +} + +pub fn update_toplevels( + toplevels: &RwLock>, + handle: ZwlrForeignToplevelHandleV1, + event: ToplevelEvent, + tx: &Sender, +) { + trace!("Received toplevel event: {:?}", event); + + if event.change == ToplevelChange::Close { + write_lock!(toplevels).remove(&event.toplevel.id); + } else { + write_lock!(toplevels).insert(event.toplevel.id, (event.toplevel.clone(), handle)); + } + + send!(tx, event); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 395d194..12373eb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,8 @@ mod r#impl; mod truncate; +#[cfg(feature = "clipboard")] +use crate::modules::clipboard::ClipboardModule; #[cfg(feature = "clock")] use crate::modules::clock::ClockModule; use crate::modules::custom::CustomModule; @@ -38,19 +40,21 @@ pub struct CommonConfig { #[serde(tag = "type", rename_all = "snake_case")] pub enum ModuleConfig { #[cfg(feature = "clock")] - Clock(ClockModule), - Custom(CustomModule), - Focused(FocusedModule), - Launcher(LauncherModule), + Clipboard(Box), + #[cfg(feature = "clock")] + Clock(Box), + Custom(Box), + Focused(Box), + Launcher(Box), #[cfg(feature = "music")] - Music(MusicModule), - Script(ScriptModule), + Music(Box), + Script(Box), #[cfg(feature = "sys_info")] - SysInfo(SysInfoModule), + SysInfo(Box), #[cfg(feature = "tray")] - Tray(TrayModule), + Tray(Box), #[cfg(feature = "workspaces")] - Workspaces(WorkspacesModule), + Workspaces(Box), } #[derive(Debug, Clone)] diff --git a/src/image/gtk.rs b/src/image/gtk.rs index 678cb0b..06c62ed 100644 --- a/src/image/gtk.rs +++ b/src/image/gtk.rs @@ -3,7 +3,7 @@ use gtk::prelude::*; use gtk::{Button, IconTheme, Image, Label, Orientation}; use tracing::error; -#[cfg(any(feature = "music", feature = "workspaces"))] +#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))] pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button { let button = Button::new(); diff --git a/src/image/mod.rs b/src/image/mod.rs index c7e41aa..b7dac37 100644 --- a/src/image/mod.rs +++ b/src/image/mod.rs @@ -1,4 +1,4 @@ -#[cfg(any(feature = "music", feature = "workspaces"))] +#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))] mod gtk; mod provider; diff --git a/src/modules/clipboard.rs b/src/modules/clipboard.rs new file mode 100644 index 0000000..0357228 --- /dev/null +++ b/src/modules/clipboard.rs @@ -0,0 +1,315 @@ +use crate::clients::clipboard::{self, ClipboardEvent}; +use crate::clients::wayland::{ClipboardItem, ClipboardValue}; +use crate::config::{CommonConfig, TruncateMode}; +use crate::image::new_icon_button; +use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext}; +use crate::popup::Popup; +use crate::try_send; +use gtk::gdk_pixbuf::Pixbuf; +use gtk::gio::{Cancellable, MemoryInputStream}; +use gtk::prelude::*; +use gtk::{Button, EventBox, Image, Label, Orientation, RadioButton, Widget}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::spawn; +use tokio::sync::mpsc::{Receiver, Sender}; +use tracing::{debug, error}; + +#[derive(Debug, Deserialize, Clone)] +pub struct ClipboardModule { + #[serde(default = "default_icon")] + icon: String, + + #[serde(default = "default_max_items")] + max_items: usize, + + // -- Common -- + truncate: Option, + + #[serde(flatten)] + pub common: Option, +} + +fn default_icon() -> String { + String::from("󰨸") +} + +const fn default_max_items() -> usize { + 10 +} + +#[derive(Debug, Clone)] +pub enum ControllerEvent { + Add(usize, Arc), + Remove(usize), + Activate(usize), + Deactivate, +} + +#[derive(Debug, Clone)] +pub enum UIEvent { + Copy(usize), + Remove(usize), +} + +impl Module