Skip to content

feat(app): a menu bar built from the commands hideGit already has - #89

Merged
youhide merged 4 commits into
mainfrom
c6-native-menu
Sep 17, 2026
Merged

youhide merged 4 commits into
mainfrom
c6-native-menu

Conversation

@youhide

@youhide youhide commented Sep 17, 2026

Copy link
Copy Markdown
Owner

Stacked on #88 → #87 → #86.

macOS puts a menu bar on screen whether an application asks for one or not. hideGit never asked, so what was up there was winit's fallback: one menu — About, Services, Hide, Show All, Quit — every item naming the executable rather than the application ("About hidegit", lowercase), and nothing else. No File, no Edit, therefore no Copy or Paste in a menu, and no way to discover that Cmd+O opens a repository except by already knowing it.

hideGit   File   Edit   View   Repository   Pull Requests   Window   Help

It is the command palette's table in a different shape

widget::palette::COMMANDS already holds every named action: id, title, chord, message. The palette reads it, the shortcut reference is tested against it, [shortcuts] remaps by its ids. The menu is assembled from the same table, so a menu item whose title, action or shortcut disagreed with the palette's would have to disagree with itself.

Three things fall out of that, and they are the reason for doing it this way:

Greyed out exactly when the command has nothing to do Push with no repository open already produced no message; that is now also what disables the item. It cannot be enabled and inert.
The shortcut shown is the user's own Keymap first, built-in chord second. A menu accelerator is taken by the OS before the window sees the key, so advertising the default chord of a moved command would resurrect the binding they replaced — not merely describe it wrongly.
Cut/Copy/Paste/Undo/Minimise/Quit are the platform's Not hideGit messages wearing their names. Editing belongs to the OS's machinery, and reimplementing it gets it subtly wrong in every text field at once.

Where it is installed

From update, in answer to window::open_events(). Both halves matter: iced runs update on the main thread, and the window having opened is what proves NSApp exists to hang a menu from. Failure is logged and ignored — losing a menu bar costs discoverability, refusing to start costs everything.

Clicks arrive on muda's own channel, which is not a future, so one thread parks on it and forwards what it gets. The alternative is a timer waking thirty times a second to find nothing.

What it does not do, said plainly

Windows and Linux keep no menu bar. Attaching one needs a native window handle iced 0.14 does not expose, and on Linux a GTK window hideGit does not have. This is in the module, in UI_SPEC.md and in the roadmap rather than left to be discovered.

Nothing lives only in the menu. Every command in it is still reachable by chord and from the palette, which is what makes its absence on two platforms survivable.

muda is macOS-only with default-features = false — the defaults are gtk3 and libxdo, forty packages in the lockfile for a platform this does not run on. The lockfile grows by two crates.

ADR-0009

Including why the objc2 already sitting under winit is not an option: the workspace sets unsafe_code = "forbid", and forbid cannot be lifted for a module. Weakening a lint that protects four crates is worth more than a menu.

Tests

Six, all on the table rather than on an NSMenu: every id in the bar names a command that exists, no command is in two menus, a repository command is dead until a repository is open, the Help entry opens the page it names, an entry shows the chord the user remapped it to and falls back to the one it ships with — and, on macOS, that every chord in the bar is one the platform can actually take. Cmd+,, Cmd+/, Cmd+Enter and Cmd+Backspace are all in that table, and all four are where an accelerator parser gives up.

Not verified in a window by me

It builds, installs without error on a real run against this repository, and the suite is green — but I could not take a screenshot of the menu itself. Worth opening once before merging.

Gate

cargo fmt --all -- --check, cargo clippy --workspace --all-targets --all-features -- -D warnings, cargo test --workspace — 963 tests, all green.

Three numbers decided how the main window was divided: a 230px sidebar, a
6:4 graph against the detail pane, and a 280px file list inside the working
directory. Each was a guess about a window nobody had seen, and no window
could argue with it — a long commit message on a laptop screen had four
tenths of the height and no way to ask for more.

Every divider between those panes is draggable now, the file list included.

## Two units, and the reason for each

The sidebar and the file list are pixels: what they need is set by the
length of a branch name or a path, and that does not grow because the
window did. The graph and the detail pane are a fraction of the height they
share, because they are two halves of one view — a taller window should
keep the graph the subject and the commit under it the footnote.

Nothing can be dragged out of existence. The pixel splits stop at a minimum
and at half the space they divide; the fraction keeps 15% for each side. A
pane with no height has no divider left to drag it back with.

## How a drag arrives

`mouse_area` reports that a press happened, not where, so the first pointer
movement after a grab is the anchor and every size is a delta from it. That
is what makes `layout.rs` testable without a window: nothing in it knows
where anything is on screen. The pointer subscription exists only while a
divider is held — following every mouse movement for a session to serve a
drag nobody is doing is the most expensive subscription this could have had.

`extent` — the size of the space being divided — is the one thing the
arithmetic cannot derive, so the view measures it with `responsive` and the
grab carries it.

## Where it is written

`state.toml`, beside the window's own geometry, on release rather than per
frame. It is state, not a setting: it changes by dragging, and nobody is
going to open a file to choose a number of pixels. It is clamped again on
the way in, so a hand-edited file cannot open hideGit with a sidebar wider
than the window. A state file written before this exists loads unchanged.

Double-clicking a divider restores it, which is why none of this needs a
settings entry: the control that moved it is the control that undoes it.

## What it cost

`staging::view` reached fourteen arguments — already flagged in #86 as
wanting a struct, and still not this change. Its file list became its own
function, because the row it sits in is built inside a `responsive` closure
that may run more than once and therefore cannot consume anything.

## Tests

Thirteen on the arithmetic, three on the divider widget — including that a
press beside it grabs nothing — and five driving `update`, one of which
holds that a divider dragged inside the working directory resizes the
window's layout rather than the repository's. Two on the config file: a
round trip, and a `state.toml` from before the dividers existed.
… off

Three places drew text of unbounded length at whatever height it asked
for and let the window clip the rest: no scrollbar, no wheel, and no way
to read the end of it short of making the window bigger.

**A commit message.** The detail pane's header — title, author, body,
stats — grew with the body. A release note of four paragraphs pushed the
changed files and the diff off the bottom of the window, and the end of
the message was not reachable either way. It scrolls now, and is capped at
240px so that reading a long message does not cost the diff underneath it.

**Git's stderr in a failed pane.** A rejected push prints a paragraph and
a merge prints the conflicted paths. The pane showed the lines that fitted
and dropped the rest, which for an error message is the half that says
what to do about it.

**Git's stderr in a toast.** A toast is bottom-aligned, so its details grew
*upwards*: thirty lines carried the summary and both controls — dismiss and
Copy details — off the top of the window, on exactly the failures somebody
needs to read and report.

Both tests assert what is *visible* rather than what is in the tree. A
widget pushed out of the window is still in the widget tree, so the usual
`shows` helper passes against the bug, and the toast one lays out at
1000x500 rather than the harness's roomy default — the bug is about a toast
outgrowing its window, so the window has to be one a toast can outgrow.
Both were confirmed to fail with the cap removed.
macOS puts a menu bar on screen whether an application asks for one or
not. hideGit never asked, so what was up there was winit's fallback: one
menu holding About, Services, Hide and Quit, every item naming the
executable rather than the application — "About hidegit", lowercase — and
nothing else at all. No File, no Edit, therefore no Copy or Paste in a
menu, and no way to discover that Cmd+O opens a repository except by
already knowing it.

## It is the command palette's table, in a different shape

`widget::palette::COMMANDS` already holds every named action: an id, a
title, the chord that runs it and the message it sends. The palette reads
it, the shortcut reference is tested against it, and `[shortcuts]` in
`config.toml` remaps by its ids. The menu is now assembled from it too,
which is why this is a short file and why nothing in it can drift: a menu
item whose title, action or shortcut disagreed with the palette's would
have to disagree with itself.

Three things fall out of that:

- **An entry is greyed out exactly when its command has nothing to do.**
  The rule is the same one the palette already used — Push with no
  repository open produces no message — so an item cannot be enabled and
  inert.
- **The shortcut shown is the user's own**, from the keymap first and the
  built-in chord second. A menu accelerator is taken by the operating
  system before the window sees the key, so advertising the default chord
  of a moved command would resurrect the binding they replaced.
- **Cut, Copy, Paste, Undo, Minimise and Quit are the platform's own
  items**, not hideGit messages wearing their names.

## Where it is installed, and why there

From `update`, in answer to `window::open_events()`. Both halves matter:
iced runs `update` on the main thread, and the window having opened is
what proves `NSApp` exists to hang a menu from. A failure is logged and
otherwise ignored — losing a menu bar costs discoverability, refusing to
start costs everything.

Clicks arrive on muda's own channel, which is not a future, so one thread
parks on it and forwards what it gets. The alternative is a timer waking
thirty times a second to find nothing.

## What it does not do

**Windows and Linux keep no menu bar.** Attaching one needs a native
window handle iced 0.14 does not expose, and on Linux a GTK window hideGit
does not have. Said in the module, in UI_SPEC and in the roadmap rather
than left to be found. Nothing lives only in the menu — every command in
it is still reachable by chord and from the palette — which is what makes
its absence survivable.

`muda` is a macOS-only dependency with `default-features = false`: the
defaults are gtk3 and libxdo, forty packages in the lockfile for a
platform this does not run on.

ADR-0009 records the decision, including why the `objc2` already under
winit is not an option: the workspace forbids `unsafe`.
@youhide
youhide changed the base branch from c5-what-scrolls to main September 17, 2026 21:19
Linux and Windows failed to compile: `-D warnings` makes dead code an
error, and with no `Bar` to build, every one of `Entry`, `Standard`,
`Menu`, `BAR`, `enabled`, `label` and `ShellMessage::MenuChosen` was
unreachable. macOS passed, which is exactly the shape of failure a
platform-gated feature has.

The message that reports a menu click, the arms that handle it, and the
two subscriptions that raise them are now gated with the rest of the menu.
There are no menu clicks without a menu, and nothing to wait for a window
to be ready for either.

The table and the three functions that read it stay compiled everywhere,
behind an `allow` that says why: they are portable data, and their tests —
that every id still names a command, that a remap wins over the built-in
chord — are worth running on all three runners. On two of them the code is
dead by construction rather than by mistake, which is the distinction the
lint cannot make and the comment can.

Checked by compiling the crate with the gate flipped to a target this
machine is not, so the non-macOS path is built rather than assumed.
@youhide
youhide merged commit 7023210 into main Sep 17, 2026
7 checks passed
@youhide
youhide deleted the c6-native-menu branch September 17, 2026 21:35
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