Skip to content

[WIP] Adaptive libadwaita redesign (sidebar + all features, for UX testing) - #223

Draft
kacperpaczos wants to merge 107 commits into
Dvlv:masterfrom
kacperpaczos:integration/all-plus-redesign
Draft

[WIP] Adaptive libadwaita redesign (sidebar + all features, for UX testing)#223
kacperpaczos wants to merge 107 commits into
Dvlv:masterfrom
kacperpaczos:integration/all-plus-redesign

Conversation

@kacperpaczos

@kacperpaczos kacperpaczos commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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::Notebook with 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: an adw::NavigationSplitView (a sidebar of boxes plus a content pane), on an adw::ApplicationWindow with an adw::Breakpoint that 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 AdwNavigationView with 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 enter would 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.ini manifest, 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 an AdwPreferencesWindow with the terminal as a combo row that saves on selection, replacing the Save/Cancel "Set Preferred Terminal" popup.

Before (master, same stub boxes)

before — gtk::Notebook side tabs, every action in one flat list

After — box page, running box

after — sidebar of boxes, box page with the Applications island on top and the container actions below; on a running box Clone and Delete are greyed out, everything else live

After — box page, stopped box

after — the same page with the box stopped: Applications, Open Terminal, Upgrade and Reboot greyed out, Start live, Clone and Delete enabled; every row in the same place as above

After — Applications page

after — the Applications page pushed over the box page: install-package and Menu Label island, then the app list as expander rows - Run / Uninstall / Add To Menu live inside each app

After — "+" menu, primary menu, Preferences

after — the "+" menu with the three ways of creating boxes, the primary menu (Refresh / Preferences / About), and the Preferences window

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.

BoxBuddy Contributor and others added 30 commits August 22, 2026 20:12
…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.
…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.
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.
BoxBuddy Contributor and others added 30 commits August 24, 2026 13:26
… 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.
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.
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.
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.
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.
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.
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.
`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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant