feat: add custom plugins

This commit is contained in:
Gabriele Musco 2024-12-30 14:28:07 +01:00
parent 4a7ba9c0c2
commit b6d8b0e6c8
9 changed files with 491 additions and 146 deletions

View file

@ -22,7 +22,6 @@ use std::{
pub struct PluginConfig {
pub plugin: Plugin,
pub enabled: bool,
pub exec_path: PathBuf,
}
impl From<&Plugin> for PluginConfig {
@ -30,7 +29,6 @@ impl From<&Plugin> for PluginConfig {
Self {
plugin: p.clone(),
enabled: true,
exec_path: p.exec_path(),
}
}
}

View file

@ -266,15 +266,20 @@ impl App {
.plugins
.values()
.filter_map(|cp| {
if cp.enabled {
if let Err(e) = mark_as_executable(&cp.exec_path) {
eprintln!(
"Failed to mark plugin {} as executable: {e}",
cp.plugin.appid
);
None
if cp.enabled && cp.plugin.validate() {
if let Some(exec) = cp.plugin.executable() {
if let Err(e) = mark_as_executable(&exec) {
error!(
"failed to mark plugin {} as executable: {e}",
cp.plugin.appid
);
None
} else {
Some(format!("'{}'", exec.to_string_lossy()))
}
} else {
Some(format!("'{}'", cp.exec_path.to_string_lossy()))
error!("no executable for plugin {}", cp.plugin.appid);
None
}
} else {
None

View file

@ -148,7 +148,7 @@ impl AsyncComponent for MainView {
menu! {
app_menu: {
section! {
"Plugin _store" => PluginStoreAction,
"Plugin_s" => PluginStoreAction,
// value inside action is ignored
"_Debug View" => DebugViewToggleAction,
"_Build Profile" => BuildProfileAction,

View file

@ -0,0 +1,169 @@
use std::path::PathBuf;
use crate::{
constants::APP_ID,
ui::{
preference_rows::{entry_row, file_row},
SENDER_IO_ERR_MSG,
},
};
use super::Plugin;
use adw::prelude::*;
use gtk::glib::clone;
use relm4::prelude::*;
#[tracker::track]
pub struct AddCustomPluginWin {
#[tracker::do_not_track]
parent: gtk::Window,
#[tracker::do_not_track]
win: Option<adw::Dialog>,
/// this is true when enough fields are populated, allowing the creation
/// of the plugin object to add
can_add: bool,
#[tracker::do_not_track]
plugin: Plugin,
}
#[derive(Debug)]
pub enum AddCustomPluginWinMsg {
Present,
Close,
OnNameChange(String),
OnExecPathChange(Option<String>),
Add,
}
#[derive(Debug)]
pub enum AddCustomPluginWinOutMsg {
Add(Plugin),
}
#[derive(Debug)]
pub struct AddCustomPluginWinInit {
pub parent: gtk::Window,
}
#[relm4::component(pub)]
impl SimpleComponent for AddCustomPluginWin {
type Init = AddCustomPluginWinInit;
type Input = AddCustomPluginWinMsg;
type Output = AddCustomPluginWinOutMsg;
view! {
#[name(win)]
adw::Dialog {
set_can_close: true,
#[wrap(Some)]
set_child: inner = &adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat,
set_bottom_bar_style: adw::ToolbarStyle::Flat,
set_vexpand: true,
set_hexpand: true,
add_top_bar: top_bar = &adw::HeaderBar {
set_show_end_title_buttons: false,
set_show_start_title_buttons: false,
pack_start: cancel_btn = &gtk::Button {
set_label: "Cancel",
add_css_class: "destructive-action",
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Close)
},
},
pack_end: add_btn = &gtk::Button {
set_label: "Add",
add_css_class: "suggested-action",
#[track = "model.changed(AddCustomPluginWin::can_add())"]
set_sensitive: model.can_add,
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Add)
},
},
#[wrap(Some)]
set_title_widget: title_label = &adw::WindowTitle {
set_title: "Add Custom Plugin",
},
},
#[wrap(Some)]
set_content: content = &adw::PreferencesPage {
set_hexpand: true,
set_vexpand: true,
add: grp = &adw::PreferencesGroup {
add: &entry_row(
"Plugin Name",
"",
clone!(
#[strong] sender,
move |row| sender.input(Self::Input::OnNameChange(row.text().to_string()))
)
),
add: &file_row(
"Plugin Executable",
None,
None,
Some(model.parent.clone()),
clone!(
#[strong] sender,
move |path_s| sender.input(Self::Input::OnExecPathChange(path_s))
)
)
},
},
},
}
}
fn update(&mut self, message: Self::Input, sender: ComponentSender<Self>) {
self.reset();
match message {
Self::Input::Present => self.win.as_ref().unwrap().present(Some(&self.parent)),
Self::Input::Close => {
self.win.as_ref().unwrap().close();
}
Self::Input::Add => {
if self.plugin.validate() {
sender
.output(Self::Output::Add(self.plugin.clone()))
.expect(SENDER_IO_ERR_MSG);
self.win.as_ref().unwrap().close();
}
}
Self::Input::OnNameChange(name) => {
self.plugin.appid = if !name.is_empty() {
format!("{APP_ID}.customPlugin.{name}")
} else {
String::default()
};
self.plugin.name = name;
self.set_can_add(self.plugin.validate());
}
Self::Input::OnExecPathChange(ep) => {
self.plugin.exec_path = ep.map(PathBuf::from);
self.set_can_add(self.plugin.validate());
}
}
}
fn init(
init: Self::Init,
root: Self::Root,
sender: ComponentSender<Self>,
) -> ComponentParts<Self> {
let mut model = Self {
tracker: 0,
win: None,
parent: init.parent,
can_add: false,
plugin: Plugin {
short_description: Some("Custom Plugin".into()),
..Default::default()
},
};
let widgets = view_output!();
model.win = Some(widgets.win.clone());
ComponentParts { model, widgets }
}
}

View file

@ -1,34 +1,63 @@
pub mod add_custom_plugin_win;
pub mod store;
mod store_detail;
mod store_row_factory;
use crate::{paths::get_plugins_dir, util::file_utils::mark_as_executable};
use anyhow::bail;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
pub struct Plugin {
pub appid: String,
pub name: String,
pub icon_url: Option<String>,
pub version: String,
pub version: Option<String>,
pub short_description: Option<String>,
pub description: Option<String>,
pub hompage_url: String,
pub hompage_url: Option<String>,
pub screenshots: Vec<String>,
pub exec_url: String,
/// either one of exec_url or exec_path must be provided
pub exec_url: Option<String>,
/// either one of exec_url or exec_path must be provided
pub exec_path: Option<PathBuf>,
}
impl Plugin {
pub fn exec_path(&self) -> PathBuf {
get_plugins_dir().join(format!("{}.AppImage", self.appid))
pub fn executable(&self) -> Option<PathBuf> {
if self.exec_path.is_some() {
self.exec_path.clone()
} else {
let canonical = self.canonical_exec_path();
if canonical.is_file() {
Some(canonical)
} else {
None
}
}
}
pub fn canonical_exec_path(&self) -> PathBuf {
get_plugins_dir().join(&self.appid)
}
pub fn is_installed(&self) -> bool {
self.exec_path().exists()
self.executable().as_ref().is_some_and(|p| p.is_file())
}
pub fn mark_as_executable(&self) -> anyhow::Result<()> {
mark_as_executable(&self.exec_path())
if let Some(p) = self.executable().as_ref() {
mark_as_executable(p)
} else {
bail!("no exec_path for plugin")
}
}
/// validate if the plugin can be displayed correctly and run
pub fn validate(&self) -> bool {
!self.appid.is_empty()
&& !self.name.is_empty()
&& self.executable().as_ref().is_some_and(|p| p.is_file())
}
}

View file

@ -1,4 +1,7 @@
use super::{
add_custom_plugin_win::{
AddCustomPluginWin, AddCustomPluginWinInit, AddCustomPluginWinMsg, AddCustomPluginWinOutMsg,
},
store_detail::{StoreDetail, StoreDetailMsg, StoreDetailOutMsg},
store_row_factory::{StoreRowModel, StoreRowModelInit, StoreRowModelMsg, StoreRowModelOutMsg},
Plugin,
@ -11,6 +14,7 @@ use crate::{
use adw::prelude::*;
use relm4::{factory::AsyncFactoryVecDeque, prelude::*};
use std::{collections::HashMap, fs::remove_file};
use tracing::{debug, error};
#[tracker::track]
pub struct PluginStore {
@ -27,6 +31,14 @@ pub struct PluginStore {
refreshing: bool,
locked: bool,
plugins: Vec<Plugin>,
#[tracker::do_not_track]
add_custom_plugin_win: Option<Controller<AddCustomPluginWin>>,
}
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum PluginStoreSignalSource {
Row,
Detail,
}
#[derive(Debug)]
@ -36,11 +48,13 @@ pub enum PluginStoreMsg {
Install(Plugin, relm4::Sender<StoreRowModelMsg>),
InstallFromDetails(Plugin),
InstallDownload(Plugin, relm4::Sender<StoreRowModelMsg>),
RemoveFromDetails(Plugin),
Remove(Plugin, relm4::Sender<StoreRowModelMsg>),
SetEnabled(Plugin, bool),
Remove(Plugin),
SetEnabled(PluginStoreSignalSource, Plugin, bool),
ShowDetails(usize),
ShowPluginList,
PresentAddCustomPluginWin,
AddPluginToConfig(Plugin),
AddCustomPlugin(Plugin),
}
#[derive(Debug)]
@ -53,6 +67,26 @@ pub enum PluginStoreOutMsg {
UpdateConfigPlugins(HashMap<String, PluginConfig>),
}
impl PluginStore {
fn refresh_plugin_rows(&mut self) {
let mut guard = self.plugin_rows.as_mut().unwrap().guard();
guard.clear();
self.plugins.iter().for_each(|plugin| {
guard.push_back(StoreRowModelInit {
plugin: plugin.clone(),
enabled: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.enabled),
needs_update: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.plugin.version != plugin.version),
});
});
}
}
#[relm4::component(pub async)]
impl AsyncComponent for PluginStore {
type Init = PluginStoreInit;
@ -63,12 +97,21 @@ impl AsyncComponent for PluginStore {
view! {
#[name(win)]
adw::Window {
set_title: Some("Plugin Store"),
set_title: Some("Plugins"),
#[name(main_stack)]
gtk::Stack {
add_child = &adw::ToolbarView {
set_top_bar_style: adw::ToolbarStyle::Flat,
add_top_bar: headerbar = &adw::HeaderBar {
pack_start: add_custom_plugin_btn = &gtk::Button {
set_icon_name: "list-add-symbolic",
set_tooltip_text: Some("Add custom plugin"),
#[track = "model.changed(PluginStore::refreshing()) || model.changed(PluginStore::locked())"]
set_sensitive: !(model.refreshing || model.locked),
connect_clicked[sender] => move |_| {
sender.input(Self::Input::PresentAddCustomPluginWin)
},
},
pack_end: refreshbtn = &gtk::Button {
set_icon_name: "view-refresh-symbolic",
set_tooltip_text: Some("Refresh"),
@ -76,8 +119,8 @@ impl AsyncComponent for PluginStore {
set_sensitive: !(model.refreshing || model.locked),
connect_clicked[sender] => move |_| {
sender.input(Self::Input::Refresh);
}
}
},
},
},
#[wrap(Some)]
set_content: inner = &gtk::Box {
@ -169,6 +212,40 @@ impl AsyncComponent for PluginStore {
self.win.as_ref().unwrap().present();
sender.input(Self::Input::Refresh);
}
Self::Input::AddCustomPlugin(plugin) => {
if self.config_plugins.contains_key(&plugin.appid) {
alert(
"Failed to add custom plugin",
Some("A plugin with the same name already exists"),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
return;
}
sender.input(Self::Input::AddPluginToConfig(plugin));
sender.input(Self::Input::Refresh);
}
Self::Input::AddPluginToConfig(plugin) => {
self.config_plugins
.insert(plugin.appid.clone(), PluginConfig::from(&plugin));
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
}
Self::Input::PresentAddCustomPluginWin => {
let add_win = AddCustomPluginWin::builder()
.launch(AddCustomPluginWinInit {
parent: self.win.as_ref().unwrap().clone().upcast(),
})
.forward(sender.input_sender(), |msg| match msg {
AddCustomPluginWinOutMsg::Add(plugin) => {
Self::Input::AddCustomPlugin(plugin)
}
});
add_win.sender().emit(AddCustomPluginWinMsg::Present);
self.add_custom_plugin_win = Some(add_win);
}
Self::Input::Refresh => {
self.set_refreshing(true);
// TODO: populate from web
@ -176,15 +253,16 @@ impl AsyncComponent for PluginStore {
Plugin {
appid: "com.github.galiser.wlx-overlay-s".into(),
name: "WLX Overlay S".into(),
version: "0.4.4".into(),
hompage_url: "https://github.com/galister/wlx-overlay-s".into(),
version: Some("0.6.0".into()),
hompage_url: Some("https://github.com/galister/wlx-overlay-s".into()),
icon_url: Some("https://github.com/galister/wlx-overlay-s/raw/main/wlx-overlay-s.svg".into()),
screenshots: vec![
"https://github.com/galister/wlx-overlay-s/raw/guide/wlx-s.png".into(),
],
description: Some("A lightweight OpenXR/OpenVR overlay for Wayland and X11 desktops, inspired by XSOverlay.\n\nWlxOverlay-S allows you to access your desktop screens while in VR.\n\nIn comparison to similar overlays, WlxOverlay-S aims to run alongside VR games and experiences while having as little performance impact as possible. The UI appearance and rendering techniques are kept as simple and efficient as possible, while still allowing a high degree of customizability.".into()),
short_description: Some("Access your Wayland/X11 desktop".into()),
exec_url: "https://github.com/galister/wlx-overlay-s/releases/download/v0.4.4/WlxOverlay-S-v0.4.4-x86_64.AppImage".into()
exec_url: Some("https://github.com/galister/wlx-overlay-s/releases/download/v0.6/WlxOverlay-S-v0.6-x86_64.AppImage".into()),
exec_path: None,
},
];
{
@ -202,23 +280,7 @@ impl AsyncComponent for PluginStore {
}));
}
self.set_plugins(plugins);
{
let mut guard = self.plugin_rows.as_mut().unwrap().guard();
guard.clear();
self.plugins.iter().for_each(|plugin| {
guard.push_back(StoreRowModelInit {
plugin: plugin.clone(),
enabled: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.enabled),
needs_update: self
.config_plugins
.get(&plugin.appid)
.is_some_and(|cp| cp.plugin.version != plugin.version),
});
});
}
self.refresh_plugin_rows();
self.set_refreshing(false);
}
Self::Input::InstallFromDetails(plugin) => {
@ -233,23 +295,7 @@ impl AsyncComponent for PluginStore {
{
sender.input(Self::Input::Install(plugin, row.input_sender.clone()))
} else {
eprintln!("could not find corresponding listbox row!")
}
}
// TODO: merge implementation with install
Self::Input::RemoveFromDetails(plugin) => {
if let Some(row) = self
.plugin_rows
.as_mut()
.unwrap()
.guard()
.iter()
.find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid))
.flatten()
{
sender.input(Self::Input::Remove(plugin, row.input_sender.clone()))
} else {
eprintln!("could not find corresponding listbox row!")
error!("could not find corresponding listbox row")
}
}
Self::Input::Install(plugin, row_sender) => {
@ -257,79 +303,110 @@ impl AsyncComponent for PluginStore {
sender.input(Self::Input::InstallDownload(plugin, row_sender))
}
Self::Input::InstallDownload(plugin, row_sender) => {
if let Err(e) = download_file_async(&plugin.exec_url, &plugin.exec_path()).await {
alert(
"Download failed",
Some(&format!(
"Downloading {} {} failed:\n\n{e}",
plugin.name, plugin.version
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
} else {
self.config_plugins
.insert(plugin.appid.clone(), PluginConfig::from(&plugin));
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
}
let mut plugin = plugin.clone();
match plugin.exec_url.as_ref() {
Some(url) => {
let exec_path = plugin.canonical_exec_path();
if let Err(e) = download_file_async(url, &exec_path).await {
alert(
"Download failed",
Some(&format!(
"Downloading {} {} failed:\n\n{e}",
plugin.name,
plugin
.version
.as_ref()
.unwrap_or(&"(no version)".to_string())
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
} else {
plugin.exec_path = Some(exec_path);
sender.input(Self::Input::AddPluginToConfig(plugin.clone()));
}
}
None => {
alert(
"Download failed",
Some(&format!(
"Downloading {} {} failed:\n\nNo executable url provided for this plugin, this is likely a bug!",
plugin.name,
plugin.version.as_ref().unwrap_or(&"(no version)".to_string()))
),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>())
);
}
};
row_sender.emit(StoreRowModelMsg::Refresh(true, false));
self.details.emit(StoreDetailMsg::Refresh(true, false));
self.details
.emit(StoreDetailMsg::Refresh(plugin.appid, true, false));
self.set_locked(false);
}
Self::Input::Remove(plugin, row_sender) => {
Self::Input::Remove(plugin) => {
self.set_locked(true);
let exec = plugin.exec_path();
if exec.is_file() {
if let Err(e) = remove_file(&exec) {
alert(
"Failed removing plugin",
Some(&format!(
"Could not remove plugin executable {}:\n\n{e}",
exec.to_string_lossy()
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
} else {
self.config_plugins.remove(&plugin.appid);
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
if let Some(exec) = plugin.executable() {
// delete executable only if it's not a custom plugin
if exec.is_file() && plugin.exec_url.is_some() {
if let Err(e) = remove_file(&exec) {
alert(
"Failed removing plugin",
Some(&format!(
"Could not remove plugin executable {}:\n\n{e}",
exec.to_string_lossy()
)),
Some(&self.win.as_ref().unwrap().clone().upcast::<gtk::Window>()),
);
}
}
}
row_sender.emit(StoreRowModelMsg::Refresh(false, false));
self.details.emit(StoreDetailMsg::Refresh(false, false));
self.config_plugins.remove(&plugin.appid);
self.set_plugins(
self.plugins
.clone()
.into_iter()
.filter(|p| !(p.appid == plugin.appid && p.exec_url.is_none()))
.collect(),
);
sender
.output(Self::Output::UpdateConfigPlugins(
self.config_plugins.clone(),
))
.expect(SENDER_IO_ERR_MSG);
self.refresh_plugin_rows();
self.details
.emit(StoreDetailMsg::Refresh(plugin.appid, false, false));
self.set_locked(false);
}
Self::Input::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(signal_sender, plugin, enabled) => {
if let Some(cp) = self.config_plugins.get_mut(&plugin.appid) {
cp.enabled = enabled;
if let Some(row) = self
.plugin_rows
.as_mut()
.unwrap()
.guard()
.iter()
.find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid))
.flatten()
{
row.input_sender.emit(StoreRowModelMsg::Refresh(
if signal_sender == PluginStoreSignalSource::Detail {
if let Some(row) = self
.plugin_rows
.as_mut()
.unwrap()
.guard()
.iter()
.find(|row| row.is_some_and(|row| row.plugin.appid == plugin.appid))
.flatten()
{
row.input_sender.emit(StoreRowModelMsg::Refresh(
enabled,
cp.plugin.version != plugin.version,
));
} else {
error!("could not find corresponding listbox row")
}
}
if signal_sender == PluginStoreSignalSource::Row {
self.details.emit(StoreDetailMsg::Refresh(
plugin.appid,
enabled,
cp.plugin.version != plugin.version,
));
} else {
eprintln!("could not find corresponding listbox row!")
}
self.details.emit(StoreDetailMsg::Refresh(
enabled,
cp.plugin.version != plugin.version,
));
} else {
eprintln!(
debug!(
"failed to set plugin {} enabled: could not find in hashmap",
plugin.appid
)
@ -358,7 +435,7 @@ impl AsyncComponent for PluginStore {
.unwrap()
.set_visible_child_name("detailsview");
} else {
eprintln!("plugins list index out of range!")
error!("plugins list index out of range!")
}
}
Self::Input::ShowPluginList => {
@ -387,13 +464,14 @@ impl AsyncComponent for PluginStore {
.forward(sender.input_sender(), move |msg| match msg {
StoreDetailOutMsg::GoBack => Self::Input::ShowPluginList,
StoreDetailOutMsg::Install(plugin) => Self::Input::InstallFromDetails(plugin),
StoreDetailOutMsg::Remove(plugin) => Self::Input::RemoveFromDetails(plugin),
StoreDetailOutMsg::Remove(plugin) => Self::Input::Remove(plugin),
StoreDetailOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(plugin, enabled)
Self::Input::SetEnabled(PluginStoreSignalSource::Detail, plugin, enabled)
}
}),
config_plugins: init.config_plugins,
main_stack: None,
add_custom_plugin_win: None,
};
let details_view = model.details.widget();
@ -408,11 +486,9 @@ impl AsyncComponent for PluginStore {
StoreRowModelOutMsg::Install(appid, row_sender) => {
Self::Input::Install(appid, row_sender)
}
StoreRowModelOutMsg::Remove(appid, row_sender) => {
Self::Input::Remove(appid, row_sender)
}
StoreRowModelOutMsg::Remove(appid) => Self::Input::Remove(appid),
StoreRowModelOutMsg::SetEnabled(plugin, enabled) => {
Self::Input::SetEnabled(plugin, enabled)
Self::Input::SetEnabled(PluginStoreSignalSource::Row, plugin, enabled)
}
}),
);

View file

@ -2,6 +2,7 @@ use super::Plugin;
use crate::{downloader::cache_file, ui::SENDER_IO_ERR_MSG};
use adw::prelude::*;
use relm4::prelude::*;
use tracing::warn;
#[tracker::track]
pub struct StoreDetail {
@ -20,7 +21,7 @@ pub enum StoreDetailMsg {
SetPlugin(Plugin, bool, bool),
SetIcon,
SetScreenshots,
Refresh(bool, bool),
Refresh(String, bool, bool),
Install,
Remove,
SetEnabled(bool),
@ -86,7 +87,8 @@ impl AsyncComponent for StoreDetail {
set_hexpand: true,
#[name(icon)]
gtk::Image {
set_icon_name: Some("image-missing-symbolic"),
set_icon_name: Some("application-x-addon-symbolic"),
set_margin_end: 12,
set_pixel_size: 96,
},
gtk::Label {
@ -224,9 +226,14 @@ impl AsyncComponent for StoreDetail {
self.icon.as_ref().unwrap().set_from_file(Some(dest));
}
Err(e) => {
eprintln!("Failed downloading icon '{url}': {e}");
warn!("Failed downloading icon '{url}': {e}");
}
};
} else {
self.icon
.as_ref()
.unwrap()
.set_icon_name(Some("application-x-addon-symbolic"));
}
}
}
@ -254,16 +261,18 @@ impl AsyncComponent for StoreDetail {
carousel.append(&clamp);
}
Err(e) => {
eprintln!("Failed downloading screenshot '{url}': {e}");
warn!("failed downloading screenshot '{url}': {e}");
}
};
}
}
}
Self::Input::Refresh(enabled, needs_update) => {
self.mark_all_changed();
self.set_enabled(enabled);
self.set_needs_update(needs_update);
Self::Input::Refresh(appid, enabled, needs_update) => {
if self.plugin.as_ref().is_some_and(|p| p.appid == appid) {
self.mark_all_changed();
self.set_enabled(enabled);
self.set_needs_update(needs_update);
}
}
Self::Input::Install => {
if let Some(plugin) = self.plugin.as_ref() {
@ -277,6 +286,11 @@ impl AsyncComponent for StoreDetail {
sender
.output(Self::Output::Remove(plugin.clone()))
.expect(SENDER_IO_ERR_MSG);
if plugin.exec_url.is_none() {
sender
.output(Self::Output::GoBack)
.expect(SENDER_IO_ERR_MSG);
}
}
}
Self::Input::SetEnabled(enabled) => {

View file

@ -4,6 +4,7 @@ use gtk::prelude::*;
use relm4::{
factory::AsyncFactoryComponent, prelude::DynamicIndex, AsyncFactorySender, RelmWidgetExt,
};
use tracing::error;
#[derive(Debug)]
#[tracker::track]
@ -28,6 +29,7 @@ pub struct StoreRowModelInit {
#[derive(Debug)]
pub enum StoreRowModelMsg {
LoadIcon,
/// params: enabled, needs_update
Refresh(bool, bool),
SetEnabled(bool),
}
@ -35,7 +37,7 @@ pub enum StoreRowModelMsg {
#[derive(Debug)]
pub enum StoreRowModelOutMsg {
Install(Plugin, relm4::Sender<StoreRowModelMsg>),
Remove(Plugin, relm4::Sender<StoreRowModelMsg>),
Remove(Plugin),
SetEnabled(Plugin, bool),
}
@ -57,7 +59,7 @@ impl AsyncFactoryComponent for StoreRowModel {
set_margin_all: 12,
#[name(icon)]
gtk::Image {
set_icon_name: Some("image-missing-symbolic"),
set_icon_name: Some("application-x-addon-symbolic"),
set_icon_size: gtk::IconSize::Large,
},
gtk::Box {
@ -124,7 +126,6 @@ impl AsyncFactoryComponent for StoreRowModel {
sender
.output(Self::Output::Remove(
plugin.clone(),
sender.input_sender().clone()
))
.expect(SENDER_IO_ERR_MSG);
}
@ -176,7 +177,7 @@ impl AsyncFactoryComponent for StoreRowModel {
self.icon.as_ref().unwrap().set_from_file(Some(dest));
}
Err(e) => {
eprintln!("Failed downloading icon '{url}': {e}");
error!("failed downloading icon '{url}': {e}");
}
};
}

View file

@ -156,13 +156,12 @@ pub fn spin_row<F: Fn(&gtk::Adjustment) + 'static>(
row
}
pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
fn filedialog_row_base<F: Fn(Option<String>) + 'static + Clone>(
title: &str,
description: Option<&str>,
value: Option<String>,
root_win: Option<gtk::Window>,
cb: F,
) -> adw::ActionRow {
) -> (adw::ActionRow, gtk::Label) {
let row = adw::ActionRow::builder()
.title(title)
.subtitle_lines(0)
@ -174,14 +173,14 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
row.set_subtitle(d);
}
let path_label = &gtk::Label::builder()
let path_label = gtk::Label::builder()
.label(match value.as_ref() {
None => "(None)",
Some(p) => p.as_str(),
})
.wrap(true)
.build();
row.add_suffix(path_label);
row.add_suffix(&path_label);
let clear_btn = gtk::Button::builder()
.icon_name("edit-clear-symbolic")
@ -200,6 +199,60 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
cb(None)
}
));
(row, path_label)
}
pub fn file_row<F: Fn(Option<String>) + 'static + Clone>(
title: &str,
description: Option<&str>,
value: Option<String>,
root_win: Option<gtk::Window>,
cb: F,
) -> adw::ActionRow {
let (row, path_label) = filedialog_row_base(title, description, value, cb.clone());
let filedialog = gtk::FileDialog::builder()
.modal(true)
.title(format!("Select {}", title))
.build();
row.connect_activated(clone!(
#[weak]
path_label,
move |_| {
filedialog.open(
root_win.as_ref(),
gio::Cancellable::NONE,
clone!(
#[weak]
path_label,
#[strong]
cb,
move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
let path_s = path.to_string_lossy().to_string();
path_label.set_text(&path_s);
cb(Some(path_s))
}
}
}
),
)
}
));
row
}
pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
title: &str,
description: Option<&str>,
value: Option<String>,
root_win: Option<gtk::Window>,
cb: F,
) -> adw::ActionRow {
let (row, path_label) = filedialog_row_base(title, description, value, cb.clone());
let filedialog = gtk::FileDialog::builder()
.modal(true)
.title(format!("Select Path for {}", title))
@ -220,8 +273,8 @@ pub fn path_row<F: Fn(Option<String>) + 'static + Clone>(
move |res| {
if let Ok(file) = res {
if let Some(path) = file.path() {
let path_s = path.to_str().unwrap().to_string();
path_label.set_text(path_s.as_str());
let path_s = path.to_string_lossy().to_string();
path_label.set_text(&path_s);
cb(Some(path_s))
}
}