[WIP] Adaptive libadwaita redesign (sidebar + all features, for UX testing) - #223
Draft
kacperpaczos wants to merge 107 commits into
Draft
[WIP] Adaptive libadwaita redesign (sidebar + all features, for UX testing)#223kacperpaczos wants to merge 107 commits into
kacperpaczos wants to merge 107 commits into
Conversation
…logo make_box_tab used to render a unicode dot coloured via Pango markup as the distro marker in the box header. The dot is fine for the narrow notebook tab on the left but reads weakly in the wider header where the box name, status and stop button already live. This adds two helpers to utils: - get_distro_color returns the brand colour as a CSS string, sharing the same lookup table get_distro_img was already using so the two markers can never disagree. - get_distro_icon_name maps distro short names to freedesktop icon names that the major icon themes ship (ubuntu-logo, fedora-logo, archlinux-logo, opensuse-logo, manjaro-logo, linuxmint-logo, ...). Returns None for distros we have no logo for. In make_box_tab, the old page_img label is replaced with a horizontal Box holding a 4-px rounded coloured bar (CSS background-color set via an inline CssProvider so no stylesheet file is needed) and, if the icon theme has it, a 32-px distro logo next to it. Either piece can be absent without breaking the layout - the bar is always rendered, the logo is best-effort and silently skipped if the theme does not ship it.
Every box tab registered its own CssProvider, and every provider defined the same .distro-color-bar class, so each newly built tab repainted every existing bar with its own colour - with a Fedora box and an Ubuntu box open, the Fedora bar came out Ubuntu orange. The providers were also never removed, and load_boxes runs on every refresh, so they piled up on the display for as long as the app ran. One provider now carries a class per distro, generated from the same colour table as the tab dot - which get_distro_img now genuinely shares, instead of keeping a second copy of - and is loaded into the display once. Each bar just picks its class, and an unknown distro falls back to the base black. load_from_string replaces the deprecated load_from_data while in the area.
BoxBuddy previously only exposed Stop: a stopped container could not be brought back from the UI. This adds the two missing counterparts. start_box shells out to 'podman start NAME' via the same run_command helper that stop_box uses. distrobox start would also work, but it shells out to podman/docker itself; calling podman directly skips a layer of subprocess when the user just wants to wake a container up. The start button takes the place of stop in the titlebar trailing slot whenever the box is not running. reboot_box runs 'distrobox-stop NAME -Y; podman start NAME' in a terminal so the user sees both halves of the cycle. We pipe through bash -c the same way upgrade_box does, so the host's preferred terminal emulator is honoured and the user can interrupt. A new 'Reboot Box' row in the actions list invokes it. Always present, regardless of box state - rebooting a stopped box is equivalent to starting it, which is harmless. Both actions re-render the box list after the call so the new state (running/exited) shows up immediately in the notebook tab.
…yntax start_box called podman by name, which breaks on docker-only hosts even though get_container_runtime exists for exactly this. reboot_box had the same hardcoded podman, and also spelled the stop as distrobox-stop - the split-binary form that distrobox v2 no longer installs; the stop subcommand works on both, and is what stop_box already uses. The Start button also asked for media-playback-start, which only exists in themes that kept the legacy names - Adwaita has dropped it, so the button was a broken-image placeholder on stock GNOME. The symbolic variant is bundled inside GTK itself and cannot go missing.
Adds a pill 'Uninstall' button next to 'Run' on every row in the View Applications popup. The handler shells to a terminal running 'distrobox enter NAME -- sudo <pkg-manager> remove EXEC', so the user sees the sudo prompt and can confirm before the package is removed. The package manager is picked from the box's image (apt/dnf/zypper/ pacman/apk/xbps-install/emerge/installpkg) by pick_pkg_manager_for_ uninstall - a small heuristic that mirrors what Kontainer's packageinstallcommand.cpp does for install flows. The default is apt because apt fails loudly on non-apt distros instead of partially succeeding. Resolves 'Uninstall application from box' from the Roadmap. The .desktop export on the host is deliberately left alone - that is a separate, reversible 'Remove From Menu' action already available on the same row.
Uninstall passed the desktop file's Exec= value straight to the package manager as if it were a package name. The two rarely agree - gimp lives in gimp-2.10 - so the happy path mostly failed. Worse, the value was interpolated unquoted into a bash -c line, and a desktop file comes from the container image, not from the user: an Exec= with a semicolon in it would have run whatever it liked with the host's home mounted. The manager now gets asked who owns the binary (dpkg -S, rpm -qf or pacman -Qqo, after resolving the name through command -v with /usr/games widening the search, since desktop files may point outside the login PATH), with the bare executable name kept as the fallback guess. Everything that reaches the terminal command is shell-quoted, and the untrusted values travel to the resolution queries as positional parameters rather than shell text. Removal is also spelled per manager now - pacman takes -R, apk takes del, and slackware removes with removepkg despite installing with installpkg. The pure pieces - token splitting, quoting, ownership-output parsing and the removal table - come with unit tests.
Adds 'Create Assemble INI…' to the application menu (between 'Set Preferred Terminal' and 'About BoxBuddy'). The dialog exposes the most common keys - image, additional_packages, home, init, nvidia - with a live monospace preview of the file that will be written. Save opens a FileDialog in save mode with the *.ini filter pre-applied, defaulting to ~/Documents/<section>.ini. Resolves the 'Create Assemble ini files via GUI' item on the project's Roadmap. The shape of distrobox.ini is flat: one section per box, key=value per option. Anything beyond the six exposed fields has to be edited by hand; documented in the function-level comment.
The .ini body was assembled twice by identical push_str blocks - once for the live preview, once at save - so the two could drift. Both now call a single build_assemble_ini in the handler, which comes with unit tests pinning the minimal, all-set and blank-optional shapes. The save itself was `let _ = std::fs::write(...)`, which threw away any error and destroyed the dialog as if it had worked. A failed write now raises a dialog naming the error and leaves the form open so the work isn't lost.
Adds an IniBoxSection struct plus a small parse_assemble_ini helper that turns distrobox.ini-style files into a Vec<IniBoxSection>. Recognised keys (image, additional_packages, home, init, nvidia) are surfaced as typed fields; anything else is preserved verbatim in extra_keys so the user sees what is being passed to 'distrobox assemble create'. When the user picks a .ini file from the Assemble button, the parser runs first. If sections were found, show_assemble_preview_dialog lists them in a scrollable MessageDialog with one ActionRow per box, so the user can sanity-check before applying. Apply continues into the existing assemble_new_distrobox flow; Cancel closes the dialog and aborts. An empty file or unreadable path falls back to the old no-preview flow so the user is never blocked. Resolves 'Parse assemble .ini files and show confirmation pop-up with details' from the Roadmap. The parser is intentionally minimal - no regex crate, no multiline support. distrobox.ini is a flat INI with key=value per line, which the simple state machine in parse_assemble_ini covers in full.
…rser The preview dialog listed each box's known fields and folded everything else into '+N more'. That is the one thing a confirmation dialog must not do: a distrobox.ini can mount a host path, run as root, or fire an init_hook that fetches and runs a script, and none of it was on screen - so Apply meant approving things you could not see. Every key the file sets is now shown verbatim, with the subtitle wrapping so a long hook command or volume list is readable in full rather than ellipsized. parse_assemble_ini, the pure core, gains unit tests: single and multiple sections, unknown keys kept verbatim, comments and junk skipped, the truthy/falsy spellings of init/nvidia, and keys before any header ignored.
The box list was a gtk::Notebook with left-positioned tabs, which GTK draws in its own unstyled way and looked out of place next to the libadwaita rows. Replace it with the HIG list/detail pattern: an adw::NavigationSplitView whose sidebar holds the boxes (a .navigation-sidebar list) and whose content shows the selected box. The window is now an adw::ApplicationWindow with the global actions in the sidebar header, and a breakpoint folds the split view into a single pane on narrow widths, so it works on a phone-sized window too. All actions, toasts and the refresh/keep-selection behaviour are unchanged.
# Conflicts: # src/main.rs
…redesign # Conflicts: # src/distrobox_handler.rs # src/main.rs
…design # Conflicts: # src/distrobox_handler.rs # src/main.rs
The Upgrade All button in the header stayed clickable even with no boxes, where it can only run distrobox upgrade over an empty list and do nothing. Gate it on the box count as well as the dependency check, and re-evaluate it whenever the list is (re)loaded so it follows create and delete.
Delete Box removes the container with --force, so it would happily tear down a box that is still running and whatever is working inside it. Disable the Delete row while the box is up, with a subtitle pointing at the Stop button that is already on the header, and enable it again once the box is down.
distrobox create only writes the container config; the container is built on its first enter, which is where "Starting container" / "Installing basic packages" come from. That setup used to run in the terminal opened after creating. Trigger it with a no-op `distrobox enter -- true` right after create and stream it through the same channel, so the dialog shows the setup too and the box is genuinely ready by the time it closes. Factor the spawn/stream/wait into one stream_distrobox helper shared by both passes. The enter arguments are a small pure function with a unit test, and the e2e test now also checks the box runs a command once the stream is done.
Add To Menu passed the application's display name to distrobox-export --app.
--app matches against the desktop file, so a display name that is blank,
shared between apps, or matches several files could export the wrong app or
more than one. The host side is detected and removed by desktop-file id
({box}-{id}.desktop), so export was keyed on something else entirely.
Identify the app by its desktop-file id everywhere: build the exact in-box
path (/usr/share/applications/{id}.desktop) and hand that to --app for both
export and delete, so one click exports exactly one app and it lines up with
detection and removal. The path builder is a small pure function with a test.
# Conflicts: # src/main.rs
Upgrade Box and Upgrade All Boxes spawned an external terminal to run `distrobox upgrade`. That command drives the package manager non-interactively (the container has passwordless sudo), so it can be streamed into the app the same way container creation already is. Add streaming variants (upgrade_box_streaming, upgrade_all_boxes_streaming) built on the shared stream_distrobox helper, generalise the create-output dialog into show_streamed_output_dialog with a heading/status, and route both upgrade actions through a small run_streamed_action helper. Also drop the terminal that create opened afterwards - the first-run setup already streams, so nothing needs an external terminal here anymore. Install .deb/.rpm still use a terminal on purpose: those wait on an interactive sudo/apt confirmation, which a non-interactive stream cannot answer.
A stopped box has nothing to reboot - it is brought up with the Start button instead - so showing Reboot next to it is misleading. Add the Reboot row only when the box is running, matching how Start and Stop already appear by state.
…-redesign # Conflicts: # src/distrobox_handler.rs # src/main.rs
distrobox tags exported apps with "(on <box>)" and has no way to rename a
container in place, so this adds a per-box label the user controls instead.
A new "Menu Label" row shows the current label and opens a dialog to change it;
applying stores the alias in GSettings and re-exports the apps already on the
menu so they pick up the new label too. Empty falls back to distrobox's default,
so a box nobody touched behaves exactly as before.
export_app_from_box gains an optional --export-label, the alias is persisted in
a new a{ss} GSettings key, and the (on …) wrapping is a small tested function.
# Conflicts: # src/main.rs # src/utils.rs
…esign # Conflicts: # src/utils.rs
# Conflicts: # src/main.rs
… place The Run / Add To Menu / Remove buttons in the applications window carried the pill style plus fixed width requests and a separator, so they were stretched to the row's height and to column widths - the list read as a table of oversized buttons, and the app name was squeezed into the space left over. They now keep their natural size, centred in the row, like any other suffix button in a boxed list. Add To Menu and Remove From Menu were two separate buttons chosen once, from whether the exported desktop file existed when the window opened, so after a click the heading said "App Exported!" while the row still offered Add To Menu until the window was reopened. One button now does both: after each click it re-reads the host's applications directory and is relabelled from what the menu actually holds, and the confirmation is only written when that changed. The host file name (<box>-<desktop file>.desktop, what distrobox-export writes) is factored into one helper shared by the list and the toggle, with a unit test pinning it.
# Conflicts: # src/main.rs
Natural-size buttons in the row still came out ragged: Remove From Menu is wider than Add To Menu, so neighbouring rows' Run buttons stopped lining up and the suffix read as a jagged table. Instead of trying to align buttons, the row stops holding them: each application is an AdwExpanderRow that collapsed shows only the icon, the name and the arrow - every row identical, the name gets the full width - and its actions are activatable rows inside: Run, and the Add To Menu / Remove From Menu row, which keeps being retitled from what the host menu actually holds after each activation. The binaries' Remove stays a suffix button: it is the row's only action and is the same width in every row.
# Conflicts: # src/main.rs
BoxBuddy could export an application to the host menu, but not a command to the host terminal, and a command that exists both on the host and in a box could only shadow one with the other. The Exported Binaries section gains an "Add Command…" button. The command is resolved inside the box first; if nothing on the host answers to that name it is a plain `distrobox-export --bin`, exactly as before. When the host already has one - its own binary, an earlier distrobox export, or a chooser BoxBuddy wrote before - the dialog offers to replace it with a chooser: a small self-contained bash script in ~/.local/bin that asks, at invocation time, which one to run. The chooser prints a numbered menu on stderr and reads the answer from /dev/tty, so it never eats the program's own stdin. Scripts and pipes must not block on a prompt, so with no terminal it runs the first target silently, and BOXBUDDY_DISPATCH=host|<box> picks one outright. Adding the same command from another box folds that box into the existing chooser rather than replacing it. The file carries distrobox's own `# distrobox_binary` and `# name: <box>` markers next to BoxBuddy's, so `distrobox-export --list-binaries` still finds the command and `--delete` still removes it; without them distrobox would quietly lose sight of a command it had exported. The binaries list skips entries that are choosers so they are not listed twice. Nothing else knows about the chooser: the file is the whole state, and it keeps working if BoxBuddy is uninstalled.
…sign # Conflicts: # src/main.rs
A tool installed inside a box was invisible in BoxBuddy: the applications list only ever enumerates `.desktop` files, and a command line tool has none. The Applications page now has a Commands section listing what the box itself holds, each with an "Add to Terminal" button that opens the existing add-command dialog with the name filled in. The scan covers `/usr/local/bin` and `/opt/*/bin`, and `$HOME/.local/bin` only when the box has a home of its own - distrobox shares the host's home by default, so scanning it there would list the host's own tools as though they lived in the box. `/usr/bin` is left out: between the base image and distrobox's first-run setup it holds hundreds of entries with nothing to tell a user install apart from them. The add-command dialog gains a "Name on host" field. `distrobox-export` always keeps the command's own name, so a different one can only be a chooser, and a chooser is now written even with nothing in the way - with a single target it runs it without asking. This is what lets the same tool from several boxes sit on the host as separate profiles: `claude` from two boxes becomes `claude-work` and `claude-personal`, each entering its own box. The generated script therefore separates the host-facing name from the command run inside the box, and the marker records both. docs/tips.md explains using a box's own home directory to keep an application's settings and logins separate while the host's files stay reachable, and the new-box form now says the same thing where the choice is actually made.
# Conflicts: # src/main.rs
Creating a box already accepts a custom home directory, but it is a bare path field: nothing says what it changes, and anyone using it for more than one box has to remember the paths and retype them. A profile is that directory with a name on it. The primary menu gains a Profiles window listing them, each with the directory it points at, a Browse button that opens it in the file manager, and Remove. Adding one takes a name and puts the directory under ~/boxes; the directory itself is created when it is first needed rather than up front, since distrobox makes it when a box is built and Browse makes it when someone looks. The new-box form gets a Profile combo listing "Host (shared home)" first, then the profiles. Picking one fills in the home path; picking Host clears it, which is distrobox's own default and what every existing box uses. The folder picker still works for a one-off path, and the value handed to distrobox is unchanged - so this is a way of choosing the existing option, not a new mechanism. Profiles live in GSettings as a name-to-path map, the way exported-app labels do. Removing one forgets the setting only: the directory and any box already built on it are left alone.
# Conflicts: # io.github.dvlv.boxbuddyrs.gschema.xml # src/main.rs # src/utils.rs
A container's home directory is fixed when it is created, so an existing box cannot be moved onto a profile. Cloning is the way round it, and it is the safe one: the copy is built with the new home and the original is left exactly as it was, so nothing is lost if the copy turns out wrong. The clone dialog gains the same Profile combo the new-box form has, and a line saying what it does. `clone_box` takes the chosen home and passes it to distrobox as `--home`; with "Host (shared home)" the arguments are byte-for-byte what they were before, which a test pins. Building those arguments is factored into `build_clone_args` so the shape can be tested without running distrobox.
# Conflicts: # src/distrobox_handler.rs # src/main.rs
The first version of this left the Home Directory entry and its folder button in place beside the new Profile combo, with the combo writing into the entry. Two controls for one setting, and nothing said which of them won - the field even kept its "Leave blank for default" label while the combo was the thing actually deciding. The row is now the only control. Its choices are Host (shared home), the profiles, and Custom folder…, which opens the same folder chooser as before; the chosen directory shows as the row's subtitle, so the path is still visible. Cancelling the chooser falls back to Host rather than leaving the row claiming a folder nobody picked. What is lost is typing a path by hand, which the entry allowed. In a graphical form a folder chooser is the better way to name a directory, and the alternative was keeping an ambiguity in the interface to preserve it.
# Conflicts: # src/main.rs
A box gave no sign of which home it was on, so the one thing a profile decides was invisible once the box existed. Its page now opens with a Profile row - a fact about the box, not a button - naming the profile whose directory the box uses, or the host, or the bare path when it belongs to no profile. The home comes from the container's own metadata, so nothing has to be started to read it. The new-box form's Profile row gains a "New profile…" entry, so a profile can be made at the moment it is wanted rather than in a separate window first. It asks for a name, stores the profile under ~/boxes, and selects it. Two things that needed care: putting the selection back from inside the row's own notify handler does not stick, because GTK is still applying the change being reacted to - it is deferred to the main loop instead, so a cancelled dialog never leaves the row sitting on "New profile…". And the label logic is a pure function taking the host home and the profile list, so it can be tested without touching GSettings.
A section of the applications page with rows in it draws as a card. An empty one called set_description instead, which puts the message as bare text under the heading with no card at all - so on a box with, say, commands but no applications, one section sat in an island between two that looked like they had failed to load. The message now goes in a row inside the group, dimmed and not activatable, so the section keeps its shape whether or not it has anything in it. Same three strings as before, so translations carry over.
# Conflicts: # src/main.rs # src/utils.rs
distrobox offers 122 images here. They all went into one dropdown, which cannot be read: you cannot tell an Ubuntu from an Alpine without going through every line, and nothing says which are already on the machine. The Image row now opens a chooser: a search box, one filter per package manager, and a "Downloaded only" switch, with a count of what is showing. Each row carries the image URL and who publishes it - Docker Official, Fedora Project, Red Hat, the GitHub or Quay organisation, or failing all that the registry itself - worked out from the URL alone, so opening the window costs nothing and touches no network. The package-manager filter reuses detect_pkg_manager rather than a second list of distro names. The Create button was enabled by the name field alone, though creating also needs an image, so clicking it with no image chosen did nothing and explained nothing - a trap a user escapes by filling in unrelated fields. It now needs both.
# Conflicts: # src/main.rs
`make lint` runs clippy, and the strings added here tripped needless_borrows_for_generic_args - gettext takes anything that converts into a String, so the format! result can go in as it is. Only the lines this branch introduced are touched; the same pattern elsewhere in the file predates it and is left alone.
`make lint` runs clippy, and the strings added here tripped needless_borrows_for_generic_args - gettext takes anything that converts into a String, so the format! result can go in as it is. Only the lines this branch introduced are touched; the same pattern elsewhere in the file predates it and is left alone.
"Adds systemd support - ignore if you're not sure" leaves out the half that catches people: `--init` also unshares the process namespace, so the host's processes stop being visible inside the box. The toggle makes a box more isolated, and it reads like it does the opposite - which is how it gets switched on by someone trying to reach more of the host. The subtitle now names both effects, and a tooltip says when the option is wanted (services that run inside the box) and when it is not (using the host's tools). The old string is not translated in any shipped .po - only in the .pot - so no translation is lost by rewording it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Relates to #216 (the libadwaita-patterns question).
[WIP] — this is for trying out the new UX, not for merging as-is. It stacks all the individual feature PRs and puts the adaptive-sidebar redesign on top, so the new layout can be seen with everything in place. Each feature has its own focused PR (listed below); this branch is just the integrated redesign, for feedback on the direction.
The redesign
The box list was a
gtk::Notebookwith side-positioned tabs, which GTK draws in its own unstyled way — it looked out of place next to the libadwaita rows. This replaces it with the HIG list/detail pattern: anadw::NavigationSplitView(a sidebar of boxes plus a content pane), on anadw::ApplicationWindowwith anadw::Breakpointthat folds it into a single pane on narrow widths, so it works on a phone-sized window too. All existing actions, toasts and the keep-selection-on-refresh behaviour are preserved.Why: BoxBuddy is written against libadwaita but read as plain GTK - side tabs, a flat list of every action, dialogs as bare windows. The HIG has a pattern for exactly this shape of app (a list of things on the left, the selected thing on the right, one level of browsing underneath), and following it gets the adaptive behaviour, the back buttons and the visual consistency for free.
Applications as a page, not a window
The content pane is an
AdwNavigationViewwith the box page as its root. The box page puts Applications first, in a boxed list of its own (a link row with the go-next arrow), and the container actions - Open Terminal, Upgrade, Reboot, Clone, Delete - below it. Activating Applications pushes an Applications page (box name as subtitle, back button in the header): at its top an island with Install .deb/.rpm File (a way of getting apps into the box) and the per-box Menu Label (#222), then the app list and exported binaries. So the structure is the flat box list plus one level of browsing under it, which is what the HIG asks for, and it keeps working when the split view collapses on a narrow window.Every row keeps its place and height in both states and is enabled only when it applies. The rows that run inside the container — Applications, Open Terminal, Upgrade — are disabled while the box is stopped, because
distrobox enterwould quietly start it and the page would contradict its own header; starting is what the Start button is for. Clone and Delete are disabled while the box runs (distrobox refuses to clone a running container, and deleting one would pull it out from under whatever uses it).Header and primary menu
One "+" menu button holds the ways of creating boxes - New Box…, Assemble from File… (a
distrobox.inimanifest, what the hammer button did) and Write Assemble File… (#213) - so the header is down to "+", Upgrade All and the menu. The primary menu follows the HIG's shape: Refresh, then Preferences / About BoxBuddy, and no Quit (Ctrl+Q and the close button do that). Preferences is anAdwPreferencesWindowwith the terminal as a combo row that saves on selection, replacing the Save/Cancel "Set Preferred Terminal" popup.Before (master, same stub boxes)
After — box page, running box
After — box page, stopped box
After — Applications page
After — "+" menu, primary menu, Preferences
Every capture is from a virtual display with a stub
distrobox list(two fake boxes), so before and after show the same data.Feature PRs stacked in here (each reviewable on its own)
Happy to split anything further or land the pieces first and rebase this on top.