Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5,672 changes: 5,197 additions & 475 deletions Cargo.lock

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions libwaysip/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,22 @@ pangocairo = "0.21.1"

memmap2 = "0.9.7"
thiserror = "2.0.14"

iced = { version = "0.14", features = ["image"], optional = true }
iced_runtime = { version = "0.14", optional = true }
iced_layershell = { version = "0.15", optional = true }
libwayshot = { version = "0.7.2", optional = true }
image = { version = "0.25", default-features = false, optional = true }
futures-channel = { version = "0.3.31", optional = true }
i18n-embed = { version = "0.16", features = ["fluent-system", "desktop-requester"], optional = true }
# rust-embed resolves path from the location of execution
# in debug mode, add feature debug-embed to avoid it during development
# https://crates.io/crates/rust-embed
rust-embed = { version = "8.11.0", optional = true }
i18n-embed-fl = { version = "0.10", optional = true }
tracing = { version = "0.1", optional = true }
clap = { version = "4.5.44", optional = true }

[features]
gui = ["dep:iced", "dep:iced_runtime", "dep:iced_layershell", "dep:libwayshot", "dep:image", "dep:futures-channel", "dep:i18n-embed", "dep:rust-embed", "dep:i18n-embed-fl", "dep:tracing", "dep:clap"]
debug = ["rust-embed?/debug-embed"]
3 changes: 3 additions & 0 deletions libwaysip/assets/locales/en-US/libwaysip.ftl
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
screens = Screens
windows = Windows

21 changes: 21 additions & 0 deletions libwaysip/examples/gui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
use libwayshot::WayshotConnection;
use libwaysip::gui_selector::{AreaSelectorGUI, GUISelection};
use wayland_client::Connection;

fn main() {
let connection =
WayshotConnection::from_connection(Connection::connect_to_env().unwrap()).unwrap();

let selector = AreaSelectorGUI::new().with_connection(connection);
match selector.launch() {
GUISelection::Output(output) => println!(
"Selected output with title {} and positioned in {}",
output.name, output.logical_region
),
GUISelection::Toplevel(toplevel) => println!(
"Selected toplevel with title {} and app_id {}",
toplevel.title, toplevel.app_id
),
GUISelection::Failed => println!("GUI selection failed!"),
}
}
4 changes: 4 additions & 0 deletions libwaysip/i18n.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
fallback_language = "en-US"

[fluent]
assets_dir = "assets/locales"
130 changes: 130 additions & 0 deletions libwaysip/src/gui_selector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
use std::sync::{Arc, mpsc};
use std::time::Duration;

use clap::ValueEnum;
use iced::Theme;
use iced_layershell::application;
use iced_layershell::reexport::{Anchor, KeyboardInteractivity};
use iced_layershell::settings::{LayerShellSettings, Settings};
use libwayshot::WayshotConnection;
use libwayshot::output::OutputInfo;
use libwayshot::region::TopLevel;

use crate::iced_selector::IcedSelector;

// ValueEnum allows client code to use clap for the theme selection
#[derive(ValueEnum, Clone, Default)]
pub enum WaySipTheme {
Light,
#[default]
Dark,
Dracula,
Nord,
SolarizedLight,
SolarizedDark,
GruvboxLight,
GruvboxDark,
CatppuccinLatte,
CatppuccinFrappe,
CatppuccinMacchiato,
CatppuccinMocha,
TokyoNight,
TokyoNightStorm,
TokyoNightLight,
KanagawaWave,
KanagawaDragon,
KanagawaLotus,
Moonfly,
Oxocarbon,
Ferra,
}

impl From<WaySipTheme> for Theme {
fn from(theme: WaySipTheme) -> Theme {
match theme {
WaySipTheme::Light => Theme::Light,
WaySipTheme::Dark => Theme::Dark,
WaySipTheme::Dracula => Theme::Dracula,
WaySipTheme::Nord => Theme::Nord,
WaySipTheme::SolarizedLight => Theme::SolarizedLight,
WaySipTheme::SolarizedDark => Theme::SolarizedDark,
WaySipTheme::GruvboxLight => Theme::GruvboxLight,
WaySipTheme::GruvboxDark => Theme::GruvboxDark,
WaySipTheme::CatppuccinLatte => Theme::CatppuccinLatte,
WaySipTheme::CatppuccinFrappe => Theme::CatppuccinFrappe,
WaySipTheme::CatppuccinMacchiato => Theme::CatppuccinMacchiato,
WaySipTheme::CatppuccinMocha => Theme::CatppuccinMocha,
WaySipTheme::TokyoNight => Theme::TokyoNight,
WaySipTheme::TokyoNightStorm => Theme::TokyoNightStorm,
WaySipTheme::TokyoNightLight => Theme::TokyoNightLight,
WaySipTheme::KanagawaWave => Theme::KanagawaWave,
WaySipTheme::KanagawaDragon => Theme::KanagawaDragon,
WaySipTheme::KanagawaLotus => Theme::KanagawaLotus,
WaySipTheme::Moonfly => Theme::Moonfly,
WaySipTheme::Oxocarbon => Theme::Oxocarbon,
WaySipTheme::Ferra => Theme::Ferra,
}
}
}

/// Interface struct to start a GUI area selector and retrieve its result
#[derive(Default)]
pub struct AreaSelectorGUI {
conn: Option<WayshotConnection>,
theme: WaySipTheme,
}

/// Represents the user's selection made through interaction with the GUI area selector
pub enum GUISelection {
Toplevel(TopLevel),
Output(OutputInfo),
Failed,
}

impl AreaSelectorGUI {
pub fn new() -> Self {
Self::default()
}

pub fn with_connection(mut self, conn: WayshotConnection) -> Self {
self.conn = Some(conn);
self
}

pub fn with_theme(mut self, theme: WaySipTheme) -> Self {
self.theme = theme;
self
}

/// Launches a GUI area selector
pub fn launch(self) -> GUISelection {
let (tx, rx) = mpsc::channel::<GUISelection>();
let conn = Arc::new(match self.conn {
Some(conn) => conn,
None => WayshotConnection::new().expect("Couldn't establish a Wayshot connection"),
});

let _ = application(
move || IcedSelector::new(tx.clone(), conn.clone()),
IcedSelector::namespace,
IcedSelector::update,
IcedSelector::view,
)
.settings(Settings {
layer_settings: LayerShellSettings {
size: Some((400, 400)),
exclusive_zone: 0,
anchor: Anchor::Bottom | Anchor::Left | Anchor::Right | Anchor::Top,
keyboard_interactivity: KeyboardInteractivity::None,
..Default::default()
Comment thread
nicolo-mn marked this conversation as resolved.
},
..Default::default()
})
.theme(Into::<Theme>::into(self.theme))
.run();

// Gets the selection from the GUI
rx.recv_timeout(Duration::from_secs(1))
.unwrap_or(GUISelection::Failed)
}
}
206 changes: 206 additions & 0 deletions libwaysip/src/iced_selector.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use std::sync::mpsc::Sender;

use iced::widget::{Button, Column, button, column, image as iced_image, row, scrollable, text};
use iced::{Alignment, Element, Length, Task};
use iced_image::Handle;
use iced_layershell::to_layer_message;
use iced_runtime::task;
use libwayshot::WayshotConnection;
use libwayshot::output::OutputInfo;
use libwayshot::region::TopLevel;
use std::sync::Arc;

use crate::fl;
use crate::gui_selector::GUISelection;

#[derive(Debug, Clone, Copy, PartialEq)]
enum ViewMode {
Screens,
Windows,
}

pub(crate) struct IcedSelector {
mode: ViewMode,
toplevels: Vec<(TopLevel, Option<Handle>)>,
outputs: Vec<(OutputInfo, Option<Handle>)>,
sender: Sender<GUISelection>,
}

#[to_layer_message(multi)]
#[derive(Debug, Clone)]
pub(crate) enum Message {
ShowScreens,
ShowWindows,
ScreenSelected(usize),
WindowSelected(usize),
OutputScreenshot(usize, Handle),
ToplevelScreenshot(usize, Handle),
}

impl IcedSelector {
pub(crate) fn new(
sender: Sender<GUISelection>,
conn: Arc<WayshotConnection>,
) -> (Self, Task<Message>) {
let toplevels_info = conn.get_all_toplevels().to_vec();
let outputs_info = conn.get_all_outputs().to_vec();

// Initialize IcedSelector instance with outputs and toplevels obtained
// through Wayshot, alongside their screenshot (obtained asynchronously)
let toplevels_tasks = toplevels_info.iter().enumerate().map(|(i, t)| {
let toplevel = t.clone();
let conn = conn.clone();
task::blocking(move |sender| {
// can fail if toplevel capture is not supported
parse_screenshot_and_send(conn.screenshot_toplevel(&toplevel, false), sender, i);
})
.map(|(i, s)| Message::ToplevelScreenshot(i, s))
});

let outputs_tasks = outputs_info.iter().enumerate().map(|(i, o)| {
let output = o.clone();
let conn = conn.clone();
task::blocking(move |sender| {
parse_screenshot_and_send(conn.screenshot_single_output(&output, false), sender, i);
})
.map(|(i, s)| Message::OutputScreenshot(i, s))
});

let all_tasks = Task::batch(toplevels_tasks.chain(outputs_tasks));

(
Self {
mode: ViewMode::Screens,
toplevels: toplevels_info.into_iter().map(|t| (t, None)).collect(),
outputs: outputs_info.into_iter().map(|o| (o, None)).collect(),
sender,
},
all_tasks,
)
}

pub(crate) fn namespace() -> String {
String::from("selection") // same as slurp
}

pub(crate) fn update(&mut self, message: Message) -> Task<Message> {
match message {
Message::ShowScreens => {
self.mode = ViewMode::Screens;
Task::none()
}
Message::ShowWindows => {
self.mode = ViewMode::Windows;
Task::none()
}
Message::ScreenSelected(index) => {
let _ = self
.sender
.send(GUISelection::Output(self.outputs[index].0.clone()));
iced::exit()
}
Message::WindowSelected(index) => {
let _ = self
.sender
.send(GUISelection::Toplevel(self.toplevels[index].0.clone()));
iced::exit()
}
Message::OutputScreenshot(index, screenshot_handle) => {
self.outputs[index].1 = Some(screenshot_handle);
Task::none()
}
Message::ToplevelScreenshot(index, screenshot_handle) => {
self.toplevels[index].1 = Some(screenshot_handle);
Task::none()
}
_ => Task::none(),
}
}

pub(crate) fn view(&self) -> Element<'_, Message> {
let selector = row![
button(text(fl!("screens")).center().width(Length::Fill))
.on_press(Message::ShowScreens)
.width(Length::Fill)
.style(if self.mode == ViewMode::Screens {
button::primary
} else {
button::secondary
}),
button(text(fl!("windows")).center().width(Length::Fill))
.on_press(Message::ShowWindows)
.width(Length::Fill)
.style(if self.mode == ViewMode::Windows {
button::primary
} else {
button::secondary
}),
]
.align_y(Alignment::Center)
.spacing(10)
.padding(20)
.width(Length::Fill);

let choices: Element<'_, Message> = match self.mode {
ViewMode::Screens => {
Column::with_children(self.outputs.iter().enumerate().map(|(i, e)| {
build_button(e.0.name.clone(), e.1.clone(), Message::ScreenSelected(i)).into()
}))
}
ViewMode::Windows => {
Column::with_children(self.toplevels.iter().enumerate().map(|(i, e)| {
build_button(e.0.title.clone(), e.1.clone(), Message::WindowSelected(i)).into()
}))
}
}
.spacing(10)
.into();

column![selector, scrollable(choices).height(Length::Fill)]
.padding(20)
.spacing(10)
.width(Length::Fill)
.height(Length::Fill)
.into()
}
}

fn build_button<'a>(
label: String,
screenshot: Option<Handle>,
message: Message,
) -> Button<'a, Message> {
let button_content: Element<'a, Message> = match screenshot {
Some(image_handle) => column![
text(label).center().width(Length::Fill),
iced_image(image_handle)
.width(Length::Fill)
.height(Length::Fixed(100.0))
]
.align_x(Alignment::Center)
.spacing(5)
.into(),
_ => text(label).center().width(Length::Fill).into(),
};
button(button_content)
.on_press(message)
.width(Length::Fill)
.style(button::subtle)
.padding(10)
}

fn parse_screenshot_and_send(
capture: Result<image::DynamicImage, libwayshot::Error>,
mut sender: futures_channel::mpsc::Sender<(usize, Handle)>,
elem_index: usize,
) {
if let Ok(screenshot) = capture {
let rgba_image = screenshot.to_rgba8();
let handle = Handle::from_rgba(
rgba_image.width(),
rgba_image.height(),
rgba_image.into_raw(),
);
let _ = sender.try_send((elem_index, handle));
}
}
Loading