From b7ee794bfc86730e7921c8a930cf8d8bb44474ad Mon Sep 17 00:00:00 2001 From: Jake Stanger Date: Sun, 16 Jul 2023 18:57:00 +0100 Subject: [PATCH] feat(ipc): commands for opening/closing popups Also includes some refactoring around related GTK helper code --- docs/Controlling Ironbar.md | 43 ++++++++ src/bar.rs | 33 ++++-- src/global_state.rs | 43 ++++++++ src/gtk_helpers.rs | 77 +++++++++++++- src/image/gtk.rs | 14 +-- src/ipc/commands.rs | 26 ++++- src/ipc/mod.rs | 13 ++- src/ipc/server.rs | 105 ++++++++++++++++--- src/main.rs | 99 ++++++++++-------- src/modules/clipboard.rs | 24 +++-- src/modules/clock.rs | 33 +++--- src/modules/custom/box.rs | 2 +- src/modules/custom/button.rs | 14 +-- src/modules/custom/image.rs | 10 +- src/modules/custom/label.rs | 8 +- src/modules/custom/mod.rs | 36 ++++--- src/modules/custom/progress.rs | 12 ++- src/modules/custom/slider.rs | 20 ++-- src/modules/focused.rs | 12 +-- src/modules/label.rs | 6 +- src/modules/launcher/item.rs | 4 +- src/modules/launcher/mod.rs | 13 ++- src/modules/mod.rs | 178 ++++++++++++++++++++++++--------- src/modules/music/mod.rs | 84 ++++++++-------- src/modules/script.rs | 6 +- src/modules/sysinfo.rs | 10 +- src/modules/tray.rs | 6 +- src/modules/upower.rs | 41 ++++---- src/modules/workspaces.rs | 6 +- src/popup.rs | 114 ++++++++++----------- 30 files changed, 747 insertions(+), 345 deletions(-) create mode 100644 src/global_state.rs diff --git a/docs/Controlling Ironbar.md b/docs/Controlling Ironbar.md index c9f5b54..b43d06b 100644 --- a/docs/Controlling Ironbar.md +++ b/docs/Controlling Ironbar.md @@ -138,6 +138,49 @@ Responds with `ok_value` and the visibility (`true`/`false`) if the bar exists, } ``` +### `toggle_popup` + +Toggles the open/closed state for a module's popup. +Since each bar only has a single popup, any open popup on the bar is closed. + +Responds with `ok` if the popup exists, otherwise `error`. + +```json +{ + "type": "toggle_popup", + "bar_name": "DP-2-13", + "name": "clock" +} +``` + +### `open_popup` + +Sets a module's popup open, regardless of its current state. +Since each bar only has a single popup, any open popup on the bar is closed. + +Responds with `ok` if the popup exists, otherwise `error`. + +```json +{ + "type": "open_popup", + "bar_name": "DP-2-13", + "name": "clock" +} +``` + +### `close_popup` + +Sets the popup on a bar closed, regardless of which module it is open for. + +Responds with `ok` if the popup exists, otherwise `error`. + +```json +{ + "type": "toggle_popup", + "bar_name": "DP-2-13" +} +``` + ## Responses ### `ok` diff --git a/src/bar.rs b/src/bar.rs index 6012651..a2e91bb 100644 --- a/src/bar.rs +++ b/src/bar.rs @@ -4,11 +4,13 @@ use crate::modules::{ }; use crate::popup::Popup; use crate::unique_id::get_unique_usize; -use crate::{arc_rw, Config}; +use crate::{arc_rw, Config, GlobalState}; use color_eyre::Result; use gtk::gdk::Monitor; use gtk::prelude::*; use gtk::{Application, ApplicationWindow, IconTheme, Orientation}; +use std::cell::RefCell; +use std::rc::Rc; use std::sync::{Arc, RwLock}; use tracing::{debug, info}; @@ -19,6 +21,7 @@ pub fn create_bar( monitor: &Monitor, monitor_name: &str, config: Config, + global_state: &Rc>, ) -> Result<()> { let win = ApplicationWindow::builder().application(app).build(); let bar_name = config @@ -62,7 +65,12 @@ pub fn create_bar( content.set_center_widget(Some(¢er)); content.pack_end(&end, false, false, 0); - load_modules(&start, ¢er, &end, app, config, monitor, monitor_name)?; + let load_result = load_modules(&start, ¢er, &end, app, config, monitor, monitor_name)?; + global_state + .borrow_mut() + .popups_mut() + .insert(bar_name.into(), load_result.popup); + win.add(&content); win.connect_destroy_event(|_, _| { @@ -143,6 +151,11 @@ fn create_container(name: &str, orientation: Orientation) -> gtk::Box { container } +#[derive(Debug)] +struct BarLoadResult { + popup: Arc>, +} + /// Loads the configured modules onto a bar. fn load_modules( left: >k::Box, @@ -152,7 +165,7 @@ fn load_modules( config: Config, monitor: &Monitor, output_name: &str, -) -> Result<()> { +) -> Result { let icon_theme = IconTheme::new(); if let Some(ref theme) = config.icon_theme { icon_theme.set_custom_theme(Some(theme)); @@ -190,7 +203,9 @@ fn load_modules( add_modules(right, modules, &info, &popup)?; } - Ok(()) + let result = BarLoadResult { popup }; + + Ok(result) } /// Adds modules into a provided GTK box, @@ -205,8 +220,14 @@ fn add_modules( macro_rules! add_module { ($module:expr, $id:expr) => {{ - let common = $module.common.take().expect("Common config did not exist"); - let widget_parts = create_module(*$module, $id, &info, &Arc::clone(&popup))?; + let common = $module.common.take().expect("common config to exist"); + let widget_parts = create_module( + *$module, + $id, + common.name.clone(), + &info, + &Arc::clone(&popup), + )?; set_widget_identifiers(&widget_parts, &common); let container = wrap_widget(&widget_parts.widget, common, orientation); diff --git a/src/global_state.rs b/src/global_state.rs new file mode 100644 index 0000000..a43f365 --- /dev/null +++ b/src/global_state.rs @@ -0,0 +1,43 @@ +use crate::popup::Popup; +use crate::write_lock; +use std::collections::HashMap; +use std::sync::{Arc, RwLock, RwLockWriteGuard}; + +/// Global application state shared across all bars. +/// +/// Data that needs to be accessed from anywhere +/// that is not otherwise accessible should be placed on here. +#[derive(Debug)] +pub struct GlobalState { + popups: HashMap, Arc>>, +} + +impl GlobalState { + pub(crate) fn new() -> Self { + Self { + popups: HashMap::new(), + } + } + + pub fn popups(&self) -> &HashMap, Arc>> { + &self.popups + } + + pub fn popups_mut(&mut self) -> &mut HashMap, Arc>> { + &mut self.popups + } + + pub fn with_popup_mut(&self, monitor_name: &str, f: F) -> Option + where + F: FnOnce(RwLockWriteGuard) -> T, + { + let popup = self.popups().get(monitor_name); + + if let Some(popup) = popup { + let popup = write_lock!(popup); + Some(f(popup)) + } else { + None + } + } +} diff --git a/src/gtk_helpers.rs b/src/gtk_helpers.rs index 6da9b28..cc88822 100644 --- a/src/gtk_helpers.rs +++ b/src/gtk_helpers.rs @@ -1,8 +1,77 @@ use glib::IsA; use gtk::prelude::*; -use gtk::Widget; +use gtk::{Orientation, Widget}; -/// Adds a new CSS class to a widget. -pub fn add_class>(widget: &W, class: &str) { - widget.style_context().add_class(class); +/// Represents a widget's size +/// and location relative to the bar's start edge. +#[derive(Debug, Copy, Clone)] +pub struct WidgetGeometry { + /// Position of the start edge of the widget + /// from the start edge of the bar. + pub position: i32, + /// The length of the widget. + pub size: i32, + /// The length of the bar. + pub bar_size: i32, +} + +pub trait IronbarGtkExt { + /// Adds a new CSS class to the widget. + fn add_class(&self, class: &str); + /// Gets the geometry for the widget + fn geometry(&self, orientation: Orientation) -> WidgetGeometry; + + /// Gets a data tag on a widget, if it exists. + fn get_tag(&self, key: &str) -> Option<&V>; + /// Sets a data tag on a widget. + fn set_tag(&self, key: &str, value: V); +} + +impl> IronbarGtkExt for W { + fn add_class(&self, class: &str) { + self.style_context().add_class(class); + } + + fn geometry(&self, orientation: Orientation) -> WidgetGeometry { + let allocation = self.allocation(); + + let widget_size = if orientation == Orientation::Horizontal { + allocation.width() + } else { + allocation.height() + }; + + let top_level = self.toplevel().expect("Failed to get top-level widget"); + let top_level_allocation = top_level.allocation(); + + let bar_size = if orientation == Orientation::Horizontal { + top_level_allocation.width() + } else { + top_level_allocation.height() + }; + + let (widget_x, widget_y) = self + .translate_coordinates(&top_level, 0, 0) + .unwrap_or((0, 0)); + + let widget_pos = if orientation == Orientation::Horizontal { + widget_x + } else { + widget_y + }; + + WidgetGeometry { + position: widget_pos, + size: widget_size, + bar_size, + } + } + + fn get_tag(&self, key: &str) -> Option<&V> { + unsafe { self.data(key).map(|val| val.as_ref()) } + } + + fn set_tag(&self, key: &str, value: V) { + unsafe { self.set_data(key, value) } + } } diff --git a/src/image/gtk.rs b/src/image/gtk.rs index 79b0da2..6d17dda 100644 --- a/src/image/gtk.rs +++ b/src/image/gtk.rs @@ -1,5 +1,5 @@ use super::ImageProvider; -use crate::gtk_helpers::add_class; +use crate::gtk_helpers::IronbarGtkExt; use gtk::prelude::*; use gtk::{Button, IconTheme, Image, Label, Orientation}; @@ -9,8 +9,8 @@ pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button if ImageProvider::is_definitely_image_input(input) { let image = Image::new(); - add_class(&image, "image"); - add_class(&image, "icon"); + image.add_class("image"); + image.add_class("icon"); match ImageProvider::parse(input, icon_theme, size) .map(|provider| provider.load_into_image(image.clone())) @@ -36,8 +36,8 @@ pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Bo if ImageProvider::is_definitely_image_input(input) { let image = Image::new(); - add_class(&image, "icon"); - add_class(&image, "image"); + image.add_class("icon"); + image.add_class("image"); container.add(&image); @@ -45,8 +45,8 @@ pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Bo .map(|provider| provider.load_into_image(image)); } else { let label = Label::new(Some(input)); - add_class(&label, "icon"); - add_class(&label, "text-icon"); + label.add_class("icon"); + label.add_class("text-icon"); container.add(&label); } diff --git a/src/ipc/commands.rs b/src/ipc/commands.rs index af2c9cb..372ef92 100644 --- a/src/ipc/commands.rs +++ b/src/ipc/commands.rs @@ -1,6 +1,7 @@ +use std::path::PathBuf; + use clap::Subcommand; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; #[derive(Subcommand, Debug, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -52,4 +53,27 @@ pub enum Command { /// Bar name to target. bar_name: String, }, + + /// Toggle a popup open/closed. + /// If opening this popup, and a different popup on the same bar is already open, the other is closed. + TogglePopup { + /// The name of the monitor the bar is located on. + bar_name: String, + /// The name of the widget. + name: String, + }, + + /// Open a popup, regardless of current state. + OpenPopup { + /// The name of the monitor the bar is located on. + bar_name: String, + /// The name of the widget. + name: String, + }, + + /// Close a popup, regardless of current state. + ClosePopup { + /// The name of the monitor the bar is located on. + bar_name: String, + }, } diff --git a/src/ipc/mod.rs b/src/ipc/mod.rs index 87aab89..d5d707c 100644 --- a/src/ipc/mod.rs +++ b/src/ipc/mod.rs @@ -3,21 +3,25 @@ pub mod commands; pub mod responses; mod server; -use std::path::PathBuf; +use std::cell::RefCell; +use std::path::{Path, PathBuf}; +use std::rc::Rc; use tracing::warn; +use crate::GlobalState; pub use commands::Command; pub use responses::Response; #[derive(Debug)] pub struct Ipc { path: PathBuf, + global_state: Rc>, } impl Ipc { /// Creates a new IPC instance. /// This can be used as both a server and client. - pub fn new() -> Self { + pub fn new(global_state: Rc>) -> Self { let ipc_socket_file = std::env::var("XDG_RUNTIME_DIR") .map_or_else(|_| PathBuf::from("/tmp"), PathBuf::from) .join("ironbar-ipc.sock"); @@ -28,6 +32,11 @@ impl Ipc { Self { path: ipc_socket_file, + global_state, } } + + pub fn path(&self) -> &Path { + self.path.as_path() + } } diff --git a/src/ipc/server.rs b/src/ipc/server.rs index d938ec6..5878030 100644 --- a/src/ipc/server.rs +++ b/src/ipc/server.rs @@ -1,20 +1,27 @@ -use super::Ipc; -use crate::bridge_channel::BridgeChannel; -use crate::ipc::{Command, Response}; -use crate::ironvar::get_variable_manager; -use crate::style::load_css; -use crate::{read_lock, send_async, try_send, write_lock}; +use std::cell::RefCell; +use std::fs; +use std::path::Path; +use std::rc::Rc; + use color_eyre::{Report, Result}; use glib::Continue; use gtk::prelude::*; use gtk::Application; -use std::fs; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{UnixListener, UnixStream}; use tokio::spawn; use tokio::sync::mpsc::{self, Receiver, Sender}; use tracing::{debug, error, info, warn}; +use crate::bridge_channel::BridgeChannel; +use crate::ipc::{Command, Response}; +use crate::ironvar::get_variable_manager; +use crate::modules::PopupButton; +use crate::style::load_css; +use crate::{read_lock, send_async, try_send, write_lock, GlobalState}; + +use super::Ipc; + impl Ipc { /// Starts the IPC server on its socket. /// @@ -29,7 +36,7 @@ impl Ipc { if path.exists() { warn!("Socket already exists. Did Ironbar exit abruptly?"); warn!("Attempting IPC shutdown to allow binding to address"); - self.shutdown(); + Self::shutdown(&path); } spawn(async move { @@ -63,8 +70,9 @@ impl Ipc { }); let application = application.clone(); + let global_state = self.global_state.clone(); bridge.recv(move |command| { - let res = Self::handle_command(command, &application); + let res = Self::handle_command(command, &application, &global_state); try_send!(res_tx, res); Continue(true) }); @@ -104,7 +112,11 @@ impl Ipc { /// Takes an input command, runs it and returns with the appropriate response. /// /// This runs on the main thread, allowing commands to interact with GTK. - fn handle_command(command: Command, application: &Application) -> Response { + fn handle_command( + command: Command, + application: &Application, + global_state: &Rc>, + ) -> Response { match command { Command::Inspect => { gtk::Window::set_interactive_debugging(true); @@ -117,7 +129,7 @@ impl Ipc { window.close(); } - crate::load_interface(application); + crate::load_interface(application, global_state); Response::Ok } @@ -145,6 +157,71 @@ impl Ipc { Response::error("File not found") } } + Command::TogglePopup { bar_name, name } => { + let global_state = global_state.borrow(); + let response = global_state.with_popup_mut(&bar_name, |mut popup| { + let current_widget = popup.current_widget(); + popup.hide(); + + let data = popup + .cache + .iter() + .find(|(_, (module_name, _))| module_name == &name) + .map(|module| (module, module.1 .1.buttons.first())); + + match data { + Some(((&id, _), Some(button))) if current_widget != Some(id) => { + let button_id = button.popup_id(); + popup.show(id, button_id); + + Response::Ok + } + Some((_, None)) => Response::error("Module has no popup functionality"), + Some(_) => Response::Ok, + None => Response::error("Invalid module name"), + } + }); + + response.unwrap_or_else(|| Response::error("Invalid monitor name")) + } + Command::OpenPopup { bar_name, name } => { + let global_state = global_state.borrow(); + let response = global_state.with_popup_mut(&bar_name, |mut popup| { + // only one popup per bar, so hide if open for another widget + popup.hide(); + + let data = popup + .cache + .iter() + .find(|(_, (module_name, _))| module_name == &name) + .map(|module| (module, module.1 .1.buttons.first())); + + match data { + Some(((&id, _), Some(button))) => { + let button_id = button.popup_id(); + popup.show(id, button_id); + + Response::Ok + } + Some((_, None)) => Response::error("Module has no popup functionality"), + None => Response::error("Invalid module name"), + } + }); + + response.unwrap_or_else(|| Response::error("Invalid monitor name")) + } + Command::ClosePopup { bar_name } => { + let global_state = global_state.borrow(); + let popup_found = global_state + .with_popup_mut(&bar_name, |mut popup| popup.hide()) + .is_some(); + + if popup_found { + Response::Ok + } else { + Response::error("Invalid monitor name") + } + } Command::Ping => Response::Ok, Command::SetVisible { bar_name, visible } => { let windows = application.windows(); @@ -178,7 +255,9 @@ impl Ipc { /// Shuts down the IPC server, /// removing the socket file in the process. - pub fn shutdown(&self) { - fs::remove_file(&self.path).ok(); + /// + /// Note this is static as the `Ipc` struct is not `Send`. + pub fn shutdown>(path: P) { + fs::remove_file(&path).ok(); } } diff --git a/src/main.rs b/src/main.rs index 23d11cd..cb509c9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,35 @@ #![doc = include_str!("../README.md")] +use std::cell::{Cell, RefCell}; +use std::env; +use std::future::Future; +use std::path::PathBuf; +use std::process::exit; +use std::rc::Rc; +use std::sync::mpsc; + +use cfg_if::cfg_if; +#[cfg(feature = "cli")] +use clap::Parser; +use color_eyre::eyre::Result; +use color_eyre::Report; +use dirs::config_dir; +use gtk::gdk::Display; +use gtk::prelude::*; +use gtk::Application; +use tokio::runtime::Handle; +use tokio::task::{block_in_place, spawn_blocking}; +use tracing::{debug, error, info, warn}; +use universal_config::ConfigLoader; + +use clients::wayland; + +use crate::bar::create_bar; +use crate::config::{Config, MonitorConfig}; +use crate::error::ExitCode; +use crate::global_state::GlobalState; +use crate::style::load_css; + mod bar; mod bridge_channel; #[cfg(feature = "cli")] @@ -9,6 +39,7 @@ mod config; mod desktop_file; mod dynamic_value; mod error; +mod global_state; mod gtk_helpers; mod image; #[cfg(feature = "ipc")] @@ -23,33 +54,6 @@ mod script; mod style; mod unique_id; -use crate::bar::create_bar; -use crate::config::{Config, MonitorConfig}; -use crate::style::load_css; -use cfg_if::cfg_if; -#[cfg(feature = "cli")] -use clap::Parser; -use color_eyre::eyre::Result; -use color_eyre::Report; -use dirs::config_dir; -use gtk::gdk::Display; -use gtk::prelude::*; -use gtk::Application; -use std::cell::Cell; -use std::env; -use std::future::Future; -use std::path::PathBuf; -use std::process::exit; -use std::rc::Rc; -use std::sync::mpsc; -use tokio::runtime::Handle; -use tokio::task::{block_in_place, spawn_blocking}; - -use crate::error::ExitCode; -use clients::wayland; -use tracing::{debug, error, info, warn}; -use universal_config::ConfigLoader; - const GTK_APP_ID: &str = "dev.jstanger.ironbar"; const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -57,32 +61,34 @@ const VERSION: &str = env!("CARGO_PKG_VERSION"); async fn main() { let _guard = logging::install_logging(); + let global_state = Rc::new(RefCell::new(GlobalState::new())); + cfg_if! { if #[cfg(feature = "cli")] { - run_with_args().await; + run_with_args(global_state).await; } else { - start_ironbar(); + start_ironbar(global_state); } } } #[cfg(feature = "cli")] -async fn run_with_args() { +async fn run_with_args(global_state: Rc>) { let args = cli::Args::parse(); match args.command { Some(command) => { - let ipc = ipc::Ipc::new(); + let ipc = ipc::Ipc::new(global_state); match ipc.send(command).await { Ok(res) => cli::handle_response(res), Err(err) => error!("{err:?}"), }; } - None => start_ironbar(), + None => start_ironbar(global_state), } } -fn start_ironbar() { +fn start_ironbar(global_state: Rc>) { info!("Ironbar version {}", VERSION); info!("Starting application"); @@ -101,12 +107,12 @@ fn start_ironbar() { cfg_if! { if #[cfg(feature = "ipc")] { - let ipc = ipc::Ipc::new(); + let ipc = ipc::Ipc::new(global_state.clone()); ipc.start(app); } } - load_interface(app); + load_interface(app, &global_state); let style_path = env::var("IRONBAR_CSS").ok().map_or_else( || { @@ -128,13 +134,15 @@ fn start_ironbar() { let (tx, rx) = mpsc::channel(); + #[cfg(feature = "ipc")] + let ipc_path = ipc.path().to_path_buf(); spawn_blocking(move || { rx.recv().expect("to receive from channel"); info!("Shutting down"); #[cfg(feature = "ipc")] - ipc.shutdown(); + ipc::Ipc::shutdown(ipc_path); exit(0); }); @@ -149,7 +157,7 @@ fn start_ironbar() { } /// Loads the Ironbar config and interface. -pub fn load_interface(app: &Application) { +pub fn load_interface(app: &Application, global_state: &Rc>) { let display = Display::default().map_or_else( || { let report = Report::msg("Failed to get default GTK display"); @@ -180,12 +188,12 @@ pub fn load_interface(app: &Application) { let variable_manager = ironvar::get_variable_manager(); for (k, v) in ironvars { if write_lock!(variable_manager).set(k.clone(), v).is_err() { - tracing::warn!("Ignoring invalid ironvar: '{k}'"); + warn!("Ignoring invalid ironvar: '{k}'"); } } } - if let Err(err) = create_bars(app, &display, &config) { + if let Err(err) = create_bars(app, &display, &config, global_state) { error!("{:?}", err); exit(ExitCode::CreateBars as i32); } @@ -194,7 +202,12 @@ pub fn load_interface(app: &Application) { } /// Creates each of the bars across each of the (configured) outputs. -fn create_bars(app: &Application, display: &Display, config: &Config) -> Result<()> { +fn create_bars( + app: &Application, + display: &Display, + config: &Config, + global_state: &Rc>, +) -> Result<()> { let wl = wayland::get_client(); let outputs = lock!(wl).get_outputs(); @@ -216,19 +229,19 @@ fn create_bars(app: &Application, display: &Display, config: &Config) -> Result< config.monitors.as_ref().map_or_else( || { info!("Creating bar on '{}'", monitor_name); - create_bar(app, &monitor, monitor_name, config.clone()) + create_bar(app, &monitor, monitor_name, config.clone(), global_state) }, |config| { let config = config.get(monitor_name); match &config { Some(MonitorConfig::Single(config)) => { info!("Creating bar on '{}'", monitor_name); - create_bar(app, &monitor, monitor_name, config.clone()) + create_bar(app, &monitor, monitor_name, config.clone(), global_state) } Some(MonitorConfig::Multiple(configs)) => { for config in configs { info!("Creating bar on '{}'", monitor_name); - create_bar(app, &monitor, monitor_name, config.clone())?; + create_bar(app, &monitor, monitor_name, config.clone(), global_state)?; } Ok(()) diff --git a/src/modules/clipboard.rs b/src/modules/clipboard.rs index 1ac2966..e52afa5 100644 --- a/src/modules/clipboard.rs +++ b/src/modules/clipboard.rs @@ -2,8 +2,9 @@ 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::modules::{ + Module, ModuleInfo, ModuleParts, ModulePopup, ModuleUpdateEvent, PopupButton, WidgetContext, +}; use crate::try_send; use gtk::gdk_pixbuf::Pixbuf; use gtk::gio::{Cancellable, MemoryInputStream}; @@ -124,25 +125,26 @@ impl Module