Rewrite detail view copy for beginners, add tab transitions and lightbox fixes

- Replace technical jargon with plain language across all 4 detail tabs
- Friendly titles/subtitles with technical details available in tooltips
- Soften terminal commands ("Install with one command" instead of raw commands)
- Rename sections: Desktop Integration -> App Menu, Sandboxing -> App Isolation,
  Vulnerability Scanning -> Security Check, Capabilities -> Features
- Rewrite Wayland/FUSE explanations to avoid acronyms and dev terminology
- Update security report page with beginner-friendly descriptions
- Enable libadwaita 1.7 ViewStack crossfade transitions between detail tabs
- Rewrite screenshot lightbox as separate gtk::Window (fixes scroll jump on
  close, adds click-outside-to-close, rounded corners via CSS)
- Add prev/next navigation arrows and keyboard support to lightbox
This commit is contained in:
lashman
2026-02-27 19:48:59 +02:00
parent 65a1ea78fe
commit c9c9c0341b
5 changed files with 443 additions and 137 deletions

View File

@@ -23,8 +23,10 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// ViewStack for tabbed content
// ViewStack for tabbed content with crossfade transitions
let view_stack = adw::ViewStack::new();
view_stack.set_enable_transitions(true);
view_stack.set_transition_duration(200);
// Build tab pages
let overview_page = build_overview_tab(record, db);
@@ -324,14 +326,14 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let row = adw::ActionRow::builder()
.title("License")
.subtitle(lic)
.tooltip_text("SPDX license identifier for this application")
.tooltip_text("The license that governs how this app can be used and shared")
.build();
about_group.add(&row);
}
if let Some(ref pg) = record.project_group {
let row = adw::ActionRow::builder()
.title("Project group")
.title("Project")
.subtitle(pg)
.build();
about_group.add(&row);
@@ -411,16 +413,24 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
overlay.add_overlay(&spinner);
// Don't let overlays steal focus (prevents scroll jump on dialog close)
overlay.set_focusable(false);
overlay.set_focus_on_click(false);
// Click handler for lightbox
let textures_click = textures.clone();
let click = gtk::GestureClick::new();
click.connect_released(move |gesture, _, _, _| {
let textures_ref = textures_click.borrow();
if let Some(Some(ref texture)) = textures_ref.get(idx) {
if textures_ref.iter().any(|t| t.is_some()) {
if let Some(widget) = gesture.widget() {
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
if let Ok(window) = root.downcast::<gtk::Window>() {
show_screenshot_lightbox(&window, texture);
show_screenshot_lightbox(
&window,
&textures_click,
idx,
);
}
}
}
@@ -497,7 +507,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let link_entries: &[(&str, &str, &Option<String>)] = &[
("Homepage", "web-browser-symbolic", &record.homepage_url),
("Bug tracker", "bug-symbolic", &record.bugtracker_url),
("Report a problem", "bug-symbolic", &record.bugtracker_url),
("Source code", "code-symbolic", &record.vcs_url),
("Documentation", "help-browser-symbolic", &record.help_url),
("Donate", "emblem-favorite-symbolic", &record.donation_url),
@@ -560,9 +570,8 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
display_label
))
.tooltip_text(
"AppImages can include built-in update information that tells Driftwood \
where to check for newer versions. Common methods include GitHub releases, \
zsync (efficient delta updates), and direct download URLs."
"Driftwood can check for newer versions of this app automatically. \
The developer has included information about where updates are published."
)
.build();
updates_group.add(&row);
@@ -723,7 +732,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if has_capabilities {
let cap_group = adw::PreferencesGroup::builder()
.title("Capabilities")
.title("Features")
.build();
if let Some(ref kw) = record.keywords {
@@ -751,7 +760,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.title("Content rating")
.subtitle(cr)
.tooltip_text(
"Content rating based on the OARS (Open Age Ratings Service) system",
"An age rating for the app's content, similar to game ratings.",
)
.build();
cap_group.add(&row);
@@ -761,11 +770,11 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if let Ok(actions) = serde_json::from_str::<Vec<String>>(actions_json) {
if !actions.is_empty() {
let row = adw::ActionRow::builder()
.title("Desktop actions")
.title("Quick actions")
.subtitle(&actions.join(", "))
.tooltip_text(
"Additional actions available from the right-click menu \
when this app is integrated into the desktop",
when this app is added to your app menu.",
)
.build();
cap_group.add(&row);
@@ -784,29 +793,32 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1 (ISO 9660) - older format, still widely supported",
Some(2) => "Type 2 (SquashFS) - modern format, most common today",
Some(1) => "Type 1 - older format, still widely supported",
Some(2) => "Type 2 - modern, compressed format",
_ => "Unknown type",
};
let type_row = adw::ActionRow::builder()
.title("AppImage format")
.title("Package format")
.subtitle(type_str)
.tooltip_text(
"AppImages come in two formats. Type 1 uses an ISO 9660 filesystem \
(older, simpler). Type 2 uses SquashFS (modern, compressed, smaller \
files). Type 2 is the standard today and is what most AppImage tools \
produce."
"AppImages come in two formats. Type 1 is the older format. \
Type 2 is the current standard - it uses compression for smaller \
files and faster loading."
)
.build();
info_group.add(&type_row);
let exec_row = adw::ActionRow::builder()
.title("Executable")
.title("Ready to run")
.subtitle(if record.is_executable {
"Yes - this file has execute permission"
"Yes - this file is ready to launch"
} else {
"No - execute permission is missing. It will be set automatically when launched."
"No - will be fixed automatically when launched"
})
.tooltip_text(
"Whether the file has the permissions needed to run. \
If not, Driftwood sets this up automatically the first time you launch it."
)
.build();
info_group.add(&exec_row);
@@ -814,13 +826,13 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let sig_row = adw::ActionRow::builder()
.title("Digital signature")
.subtitle(if record.has_signature {
"This AppImage contains a GPG signature"
"Signed by the developer"
} else {
"Not signed"
})
.tooltip_text(
"AppImages can be digitally signed by their author using GPG. \
A signature helps verify that the file hasn't been tampered with."
"This app was signed by its developer, which helps verify \
it hasn't been tampered with since it was published."
)
.build();
let sig_badge = if record.has_signature {
@@ -839,7 +851,7 @@ fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
info_group.add(&seen_row);
let scanned_row = adw::ActionRow::builder()
.title("Last scanned")
.title("Last checked")
.subtitle(&record.last_scanned)
.build();
info_group.add(&scanned_row);
@@ -886,21 +898,21 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Desktop Integration group
let integration_group = adw::PreferencesGroup::builder()
.title("Desktop Integration")
.title("App Menu")
.description(
"Show this app in your Activities menu and app launcher, \
just like a regular installed application."
"Add this app to your launcher so you can find it \
like any other installed app."
)
.build();
let switch_row = adw::SwitchRow::builder()
.title("Add to application menu")
.subtitle("Creates a .desktop entry and installs the app icon")
.subtitle("Creates a shortcut and installs the app icon")
.active(record.integrated)
.tooltip_text(
"Desktop integration makes this AppImage appear in your Activities menu \
and app launcher, just like a regular installed app. It creates a .desktop \
file (a shortcut) and copies the app's icon to your system icon folder."
"This makes the app appear in your Activities menu and app launcher, \
just like a regular installed app. It creates a shortcut file and \
copies the app's icon to your system."
)
.build();
@@ -940,7 +952,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if record.integrated {
if let Some(ref desktop_file) = record.desktop_file {
let row = adw::ActionRow::builder()
.title("Desktop file")
.title("Shortcut file")
.subtitle(desktop_file)
.subtitle_selectable(true)
.build();
@@ -954,8 +966,8 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let compat_group = adw::PreferencesGroup::builder()
.title("Compatibility")
.description(
"How well this app works with your display server and filesystem. \
Most issues here can be resolved with a small package install."
"How well this app works with your system. \
Most issues can be fixed with a quick install."
)
.build();
@@ -966,13 +978,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder()
.title("Wayland display")
.title("Display compatibility")
.subtitle(wayland_user_explanation(&wayland_status))
.tooltip_text(
"Wayland is the modern display system used by GNOME and most Linux desktops. \
It replaced the older X11 system. Apps built for X11 still work through \
a compatibility layer called XWayland, but native Wayland apps look \
sharper and perform better, especially on high-resolution screens."
"Wayland is the modern display system on Linux. Apps built for the \
older system (X11) still work, but native Wayland apps look sharper, \
especially on high-resolution screens."
)
.build();
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
@@ -982,14 +993,13 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Analyze toolkit button
let analyze_row = adw::ActionRow::builder()
.title("Analyze toolkit")
.subtitle("Inspect bundled libraries to detect which UI toolkit this app uses")
.title("Detect app framework")
.subtitle("Check which technology this app is built with")
.activatable(true)
.tooltip_text(
"UI toolkits (like GTK, Qt, Electron) are the frameworks apps use to \
draw their windows and buttons. Knowing the toolkit helps predict Wayland \
compatibility - GTK4 apps are natively Wayland, while older Qt or Electron \
apps may need XWayland."
"Apps are built with different frameworks (like GTK, Qt, or Electron). \
Knowing the framework helps predict how well the app works with \
your display system."
)
.build();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
@@ -1017,12 +1027,12 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let toolkit_label = analysis.toolkit.label();
let lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!(
"Detected: {} ({} libraries scanned)",
toolkit_label, lib_count,
"Built with: {}",
toolkit_label,
));
}
Err(_) => {
row_clone.set_subtitle("Analysis failed - the AppImage may not be mountable");
row_clone.set_subtitle("Analysis failed - could not read the app's contents");
}
}
});
@@ -1032,11 +1042,14 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Runtime Wayland status (from post-launch analysis)
if let Some(ref runtime_status) = record.runtime_wayland_status {
let runtime_row = adw::ActionRow::builder()
.title("Last observed protocol")
.title("Last display mode")
.subtitle(&format!(
"When this app was last launched, it used: {}",
runtime_status
))
.tooltip_text(
"How the app connected to your display the last time it was launched."
)
.build();
if let Some(ref checked) = record.runtime_wayland_checked {
let info = gtk::Label::builder()
@@ -1058,13 +1071,11 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
.unwrap_or(fuse_system.status.clone());
let fuse_row = adw::ActionRow::builder()
.title("FUSE (filesystem)")
.title("App mounting")
.subtitle(fuse_user_explanation(&fuse_status))
.tooltip_text(
"FUSE (Filesystem in Userspace) lets AppImages mount themselves as \
virtual drives so they can run directly without extracting. Without it, \
AppImages still work but need to extract to a temp folder first, which \
is slower. Most systems have FUSE already, but some need libfuse2 installed."
"FUSE lets apps like AppImages run directly without unpacking first. \
Without it, apps still work but take a little longer to start."
)
.build();
let fuse_badge = widgets::status_badge_with_icon(
@@ -1077,7 +1088,7 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if let Some(cmd) = fuse_install_command(&fuse_status) {
let copy_btn = widgets::copy_button(cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", cmd)));
copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
fuse_row.add_suffix(&copy_btn);
}
compat_group.add(&fuse_row);
@@ -1086,16 +1097,15 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_row = adw::ActionRow::builder()
.title("Launch method")
.title("Startup method")
.subtitle(&format!(
"This app will launch using: {}",
app_fuse_status.label()
))
.tooltip_text(
"AppImages can launch two ways: 'FUSE mount' mounts the image as a \
virtual drive (fast, instant startup), or 'extract' unpacks to a temp \
folder first (slower, but works everywhere). The method is chosen \
automatically based on your system's FUSE support."
"AppImages can start two ways: mounting (fast, instant startup) or \
unpacking to a temporary folder first (slower, but works everywhere). \
The method is chosen automatically based on your system."
)
.build();
let launch_badge = widgets::status_badge(
@@ -1109,10 +1119,10 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.title("Sandboxing")
.title("App Isolation")
.description(
"Isolate this app for extra security. Sandboxing limits what \
the app can access on your system."
"Restrict what this app can access on your system \
for extra security."
)
.build();
@@ -1125,24 +1135,20 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
let firejail_available = launcher::has_firejail();
let sandbox_subtitle = if firejail_available {
format!(
"Isolate this app using Firejail. Current mode: {}",
current_mode.display_label()
)
format!("Currently: {}", current_mode.display_label())
} else {
"Firejail is not installed. Use the row below to copy the install command.".to_string()
"Not available yet. Install with one command using the button below.".to_string()
};
let firejail_row = adw::SwitchRow::builder()
.title("Firejail sandbox")
.title("Isolate this app")
.subtitle(&sandbox_subtitle)
.active(current_mode == SandboxMode::Firejail)
.sensitive(firejail_available)
.tooltip_text(
"Sandboxing restricts what an app can access on your system - files, \
network, devices, etc. This adds a security layer so that even if an \
app is compromised, it cannot freely access your personal data. Firejail \
is a lightweight Linux sandboxing tool."
"Sandboxing restricts what an app can access - files, network, \
devices, etc. Even if an app has a security issue, it can't \
freely access your personal data."
)
.build();
@@ -1163,15 +1169,15 @@ fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>, toast_overlay: &
if !firejail_available {
let firejail_cmd = "sudo apt install firejail";
let info_row = adw::ActionRow::builder()
.title("Install Firejail")
.subtitle(firejail_cmd)
.title("Install app isolation")
.subtitle("Install with one command")
.build();
let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center);
info_row.add_suffix(&badge);
let copy_btn = widgets::copy_button(firejail_cmd, Some(toast_overlay));
copy_btn.set_valign(gtk::Align::Center);
copy_btn.set_tooltip_text(Some(&format!("Copy '{}' to clipboard", firejail_cmd)));
copy_btn.set_tooltip_text(Some("Copy install command to clipboard"));
info_row.add_suffix(&copy_btn);
sandbox_group.add(&info_row);
}
@@ -1207,10 +1213,9 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
.build();
let group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.title("Security Check")
.description(
"Scan the libraries bundled inside this AppImage for known \
security vulnerabilities (CVEs)."
"Check this app for known security issues."
)
.build();
@@ -1231,18 +1236,22 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
group.add(&row);
} else {
let lib_row = adw::ActionRow::builder()
.title("Bundled libraries")
.title("Included components")
.subtitle(&format!(
"{} libraries detected inside this AppImage",
"{} components found inside this app",
libs.len()
))
.tooltip_text(
"Apps bundle their own copies of system components (libraries). \
These can sometimes contain known security issues if they're outdated."
)
.build();
group.add(&lib_row);
if summary.total() == 0 {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle("No known security issues found in the bundled libraries.")
.subtitle("No known security issues found.")
.build();
let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center);
@@ -1266,15 +1275,12 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
// Scan button
let scan_row = adw::ActionRow::builder()
.title("Run security scan")
.subtitle("Check bundled libraries against known CVE databases")
.title("Run security check")
.subtitle("Check for known security issues in this app")
.activatable(true)
.tooltip_text(
"CVE stands for Common Vulnerabilities and Exposures - a public list \
of known security bugs in software. AppImages bundle their own copies \
of system libraries, which may contain outdated versions with known \
vulnerabilities. This scan checks those bundled libraries against the \
OSV.dev database to find any known issues."
"This checks the components inside this app against a public database \
of known security issues to see if any are outdated or vulnerable."
)
.build();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
@@ -1313,7 +1319,7 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
}
}
Err(_) => {
row_clone.set_subtitle("Scan failed - the AppImage may not be mountable");
row_clone.set_subtitle("Check failed - could not read the app's contents");
}
}
});
@@ -1330,14 +1336,14 @@ fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
if let Some(ref hash) = record.sha256 {
let hash_row = adw::ActionRow::builder()
.title("SHA256 checksum")
.title("File fingerprint")
.subtitle(hash)
.subtitle_selectable(true)
.tooltip_text(
"A SHA256 checksum is a unique fingerprint of the file. If even one \
byte changes, the checksum changes completely. You can compare this \
against the developer's published checksum to verify the file hasn't \
been tampered with or corrupted during download."
"A unique code (SHA256 checksum) generated from the file's contents. \
If the file changes in any way, this code changes too. You can compare \
it against the developer's published fingerprint to verify nothing \
was altered."
)
.build();
hash_row.add_css_class("property");
@@ -1416,13 +1422,13 @@ fn build_storage_tab(
// Data paths group
let paths_group = adw::PreferencesGroup::builder()
.title("App Data")
.description("Config, cache, and data directories this app may have created.")
.description("Settings, cache, and data this app may have saved to your system.")
.build();
// Discover button
let discover_row = adw::ActionRow::builder()
.title("Find app data")
.subtitle("Search for config, cache, and data directories")
.subtitle("Search for files this app has saved")
.activatable(true)
.build();
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
@@ -1449,7 +1455,7 @@ fn build_storage_tab(
Ok(fp) => {
let count = fp.paths.len();
if count == 0 {
row_clone.set_subtitle("No associated data directories found");
row_clone.set_subtitle("No saved data found");
} else {
row_clone.set_subtitle(&format!(
"Found {} path{} using {}",
@@ -1501,7 +1507,7 @@ fn build_storage_tab(
.build();
let path_row = adw::ActionRow::builder()
.title("Path")
.title("File location")
.subtitle(&record.path)
.subtitle_selectable(true)
.build();
@@ -1548,35 +1554,36 @@ fn build_storage_tab(
fn wayland_user_explanation(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native =>
"Runs natively on Wayland - the best experience on modern Linux desktops.",
"Fully compatible - the best experience on your system.",
WaylandStatus::XWayland =>
"Uses XWayland for display. Works fine, but may appear slightly \
"Works through a compatibility layer. May appear slightly \
blurry on high-resolution screens.",
WaylandStatus::Possible =>
"Might work on Wayland with the right settings. Try launching it to find out.",
"Might work well. Try launching it to find out.",
WaylandStatus::X11Only =>
"Designed for X11 only. It will run through XWayland automatically, \
but you may notice minor display quirks.",
"Built for an older display system. It will run automatically, \
but you may notice minor visual quirks.",
WaylandStatus::Unknown =>
"Not yet determined. Launch the app or use 'Analyze toolkit' below to check.",
"Not yet determined. Launch the app or use 'Detect app framework' to check.",
}
}
fn fuse_user_explanation(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional =>
"FUSE is working - AppImages mount directly for fast startup.",
"Everything is set up - apps start instantly.",
FuseStatus::Fuse3Only =>
"Only FUSE 3 found. Some AppImages need FUSE 2. \
Click the copy button to get the install command.",
"A small system component is missing. Most apps will still work, \
but some may need it. Copy the install command to fix this.",
FuseStatus::NoFusermount =>
"FUSE tools not found. The app will still work by extracting to a \
temporary folder, but startup will be slower.",
"A system component is missing, so apps will take a little longer \
to start. They'll still work fine.",
FuseStatus::NoDevFuse =>
"/dev/fuse not available. FUSE may not be configured on your system. \
Apps will extract to a temp folder instead.",
"Your system doesn't support instant app mounting. Apps will unpack \
before starting, which takes a bit longer.",
FuseStatus::MissingLibfuse2 =>
"libfuse2 is missing. Click the copy button to get the install command.",
"A small system component is needed for fast startup. \
Copy the install command to fix this.",
}
}
@@ -1590,29 +1597,182 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
}
}
/// Show a screenshot in a fullscreen-ish lightbox dialog.
fn show_screenshot_lightbox(parent: &gtk::Window, texture: &gtk::gdk::Texture) {
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
/// Uses a separate gtk::Window to avoid parent scroll position interference.
fn show_screenshot_lightbox(
parent: &gtk::Window,
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
initial_index: usize,
) {
let current = Rc::new(std::cell::Cell::new(initial_index));
let textures = textures.clone();
let count = textures.borrow().len();
let has_multiple = count > 1;
// Fullscreen modal window with dark background
let win = gtk::Window::builder()
.transient_for(parent)
.modal(true)
.decorated(false)
.default_width(parent.width())
.default_height(parent.height())
.build();
win.add_css_class("lightbox");
// Image with generous margins so there's a dark border to click on
let picture = gtk::Picture::builder()
.paintable(texture)
.content_fit(gtk::ContentFit::Contain)
.hexpand(true)
.vexpand(true)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.margin_start(72)
.margin_end(72)
.margin_top(56)
.margin_bottom(56)
.build();
picture.set_can_shrink(true);
let dialog = adw::Dialog::builder()
.title("Screenshot")
.content_width(900)
.content_height(600)
// Set initial texture
{
let t = textures.borrow();
if let Some(Some(ref tex)) = t.get(initial_index) {
picture.set_paintable(Some(tex));
}
}
// Navigation buttons
let prev_btn = gtk::Button::builder()
.icon_name("go-previous-symbolic")
.css_classes(["circular", "osd", "lightbox-nav"])
.valign(gtk::Align::Center)
.halign(gtk::Align::Start)
.margin_start(16)
.build();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&picture));
dialog.set_child(Some(&toolbar));
let next_btn = gtk::Button::builder()
.icon_name("go-next-symbolic")
.css_classes(["circular", "osd", "lightbox-nav"])
.valign(gtk::Align::Center)
.halign(gtk::Align::End)
.margin_end(16)
.build();
dialog.present(Some(parent));
// Counter label (e.g. "2 / 5")
let counter = gtk::Label::builder()
.label(&format!("{} / {}", initial_index + 1, count))
.css_classes(["lightbox-counter"])
.halign(gtk::Align::Center)
.valign(gtk::Align::End)
.margin_bottom(16)
.build();
// Close button (top-right)
let close_btn = gtk::Button::builder()
.icon_name("window-close-symbolic")
.css_classes(["circular", "osd"])
.halign(gtk::Align::End)
.valign(gtk::Align::Start)
.margin_top(16)
.margin_end(16)
.build();
// Build overlay: picture as child, buttons + counter as overlays
let overlay = gtk::Overlay::builder()
.child(&picture)
.hexpand(true)
.vexpand(true)
.build();
overlay.add_overlay(&close_btn);
if has_multiple {
overlay.add_overlay(&prev_btn);
overlay.add_overlay(&next_btn);
overlay.add_overlay(&counter);
}
win.set_child(Some(&overlay));
// --- Click outside image to close ---
// Picture's gesture claims clicks on the image, preventing close.
let pic_gesture = gtk::GestureClick::new();
pic_gesture.connect_released(|_, _, _, _| {});
picture.add_controller(pic_gesture);
// Window gesture fires for clicks on the dark margin area.
{
let win_ref = win.clone();
let bg_gesture = gtk::GestureClick::new();
bg_gesture.connect_released(move |_, _, _, _| {
win_ref.close();
});
win.add_controller(bg_gesture);
}
// --- Close button ---
{
let win_ref = win.clone();
close_btn.connect_clicked(move |_| {
win_ref.close();
});
}
// --- Navigation ---
let make_navigate = |direction: i32| {
let textures_ref = textures.clone();
let current_ref = current.clone();
let picture_ref = picture.clone();
let counter_ref = counter.clone();
move || {
let idx = current_ref.get();
let new_idx = if direction < 0 {
if idx == 0 { count - 1 } else { idx - 1 }
} else {
if idx + 1 >= count { 0 } else { idx + 1 }
};
let t = textures_ref.borrow();
if let Some(Some(ref tex)) = t.get(new_idx) {
picture_ref.set_paintable(Some(tex));
current_ref.set(new_idx);
counter_ref.set_label(&format!("{} / {}", new_idx + 1, count));
}
}
};
if has_multiple {
let go_prev = make_navigate(-1);
prev_btn.connect_clicked(move |_| go_prev());
let go_next = make_navigate(1);
next_btn.connect_clicked(move |_| go_next());
}
// --- Keyboard: Escape to close, Left/Right to navigate ---
{
let win_ref = win.clone();
let go_prev_key = make_navigate(-1);
let go_next_key = make_navigate(1);
let key_ctl = gtk::EventControllerKey::new();
key_ctl.connect_key_pressed(move |_, key, _, _| {
match key {
gtk::gdk::Key::Escape => {
win_ref.close();
return glib::Propagation::Stop;
}
gtk::gdk::Key::Left if has_multiple => {
go_prev_key();
return glib::Propagation::Stop;
}
gtk::gdk::Key::Right if has_multiple => {
go_next_key();
return glib::Propagation::Stop;
}
_ => {}
}
glib::Propagation::Proceed
});
win.add_controller(key_ctl);
}
win.present();
}
/// Fetch a favicon for a URL and set it on an image widget.