Skip to content

refactor(gui): restructure apic-gui into feature slices with a design-system UI layer - #71

Merged
rizukirr merged 7 commits into
mainfrom
refactor/gui-modular-restructure
Jul 31, 2026
Merged

refactor(gui): restructure apic-gui into feature slices with a design-system UI layer#71
rizukirr merged 7 commits into
mainfrom
refactor/gui-modular-restructure

Conversation

@rizukirr

Copy link
Copy Markdown
Owner

Restructures apic-gui into feature slices with a design-system UI layer, so that adding a Git GUI (and whatever comes after) is an addition rather than another untangling. Behavior preserving throughout: no visual change, no dependency change, no new features.

Why

main.rs was 1756 lines holding the App struct, every project operation, every modal dialog, the contract tree, and the whole central view. The ui/ layer documented an invariant it did not keep, all 9 exported functions in ui/sections.rs took apic_core's EditModel, and ui/theme.rs exported method_color/method_badge, which switch on HTTP verbs. A second feature would have had to add fields to the same flat struct with no clean place for its view code.

What changed

main.rs goes from 1756 lines to 60, startup only.

App goes from 24 fields to 3:

struct App {
    shell: ShellState,          // 5 fields, project location and window chrome
    contracts: ContractsState,  // 18 fields, the contracts feature
    pending_dialog: Option<(DialogKind, Receiver<Option<PathBuf>>)>,
}

New layout:

apic-gui/src/
├── main.rs                  # main() + load_icon()
├── app/                     # mod, state, actions, project
├── features/contracts/      # mod, state, view
└── ui/
    ├── theme/               # colors, spacing, typography
    ├── components/          # button, chip, input, table, text
    ├── focus.rs
    └── syntax_highlighting.rs

Layering invariant, stated in ui/mod.rs and features/mod.rs: components/ may import theme/, never the reverse; ui/ imports nothing from features/ and names no domain type; feature slices do not import each other.

The success test: adding git: GitState now touches app/mod.rs and the dispatch match, nothing else.

Commits

Seven commits, each behavior preserving and independently revertable, with the full gate green between each.

  1. 1c5ffe3 split ui/theme.rs into a theme token module
  2. f947407 split ui/widgets.rs into ui/components and ui/focus
  3. fe151d3 move contract sections into features/contracts/view
  4. d570bcf extract ShellState from App
  5. 9641244 extract ContractsState from App
  6. 0499848 split app modules out of main.rs
  7. 4a3782a narrow visibility to what callers actually need

Verification

Gate run after every commit:

cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo build -p apic-gui

All green, 258 tests across 6 suites. cargo run -p apic-gui was smoke tested by hand twice, after commit 3 and after commit 6, covering open project, browse the sidebar tree, load a contract, edit and cancel, save, create a request from a template, create a template, delete, toggle the sidebar, and the Import menu.

Two things the automated gate cannot cover, recorded for reviewers:

  • windows_defaults_to_wgpu_renderer is #[cfg(windows)] and moved to desktop.rs. The x86_64-pc-windows-msvc target is not installed locally, so this move is unverified on Linux and relies on CI.
  • The 14 apic-gui tests assert "renders without panicking", never pixels. The open_blocked_dialog and enter_repair paths, the invalid contract flows, were not exercised in either smoke test.

Deliberate deviations

  • The unused weighted_columns helper (#[allow(unused)], zero call sites) was deleted rather than moved into a new ui/components/layout.rs.
  • TreeNode lives in features/contracts/view.rs rather than state.rs, because it renders and needs SidebarAction.
  • app/actions.rs holds only the SidebarAction enum. The dispatch match stayed inside ui(), which also does panel layout, so extracting it would not have been a pure move.
  • Two App tests stayed in app/mod.rs rather than moving to the contracts feature, since both construct App::new().

Follow-ups, deliberately not in this PR

  • features/contracts/view.rs is 1373 lines, now the largest file in the crate. Splitting it is the natural next step.
  • Contract view methods are still impl App rather than fn view(&mut ContractsState) -> Option<ContractsAction>, because they read self.shell.status alongside self.contracts. A vibekit: comment in view.rs names the upgrade path. Best done when the Git feature supplies a second consumer to generalize from.
  • No background task abstraction was added. pending_dialog is its only consumer today; Git will be the second, and that is the point to build it.
  • No test asserts ShellState::default().sidebar_open == true, the one hand written default in the crate, which would fail silently if flipped.

rizukirr added 7 commits July 31, 2026 20:52
Colors and spacing become separate files under ui/theme/, with
TABLE_HEADER_H and TABLE_ROW_H moved in from widgets.rs and their two
consumers repointed. method_color and method_badge stay put until
features/contracts/ exists; typography lands in the next commit, when its
tokens gain call sites.

No visual change.
Button, input, text, chip, and table primitives become separate modules.
Focus plumbing moves to ui/focus.rs since it renders nothing. The unused
weighted_columns helper is dropped rather than carried into a new file.
ui/theme/typography.rs lands here, where its size tokens gain call sites.

No visual change.
All nine section functions take apic_core's EditModel, so they were never
shared UI. method_color and method_badge move with them. ui/ now names no
domain type.

No visual change.
root, project_root, apic_dir, status, and sidebar_open move into a
ShellState struct with a hand-written Default so sidebar_open keeps
starting true. DialogKind moves to app/state.rs alongside it. App drops
from 24 fields to 20.

No behavior change.
The remaining 18 contract fields move into ContractsState, alongside
Entry, Repair, DeleteTarget, MainTab, and RespTab. App is now three
fields: shell, contracts, and the in-flight file dialog. TreeNode stays
in main.rs until the next commit moves it with the sidebar it renders.

No behavior change.
main.rs is now startup only, 60 lines down from 1756. App, the eframe
impl, and the bars move to app/mod.rs; project operations to
app/project.rs; SidebarAction to app/actions.rs; the contract sidebar,
central view, dialogs, and TreeNode to features/contracts/view.rs; the
Windows renderer test to desktop.rs.

TreeNode lands in view.rs rather than state.rs because it renders and
needs SidebarAction. The dispatch match stays inside ui(), which also
does panel layout, so extracting it would not have been a pure move.

No behavior change.
The module split widened pub(crate) across every method at impl-block
indentation, including 8 that were private before and a #[test] fn.
Reverted all method visibility to private, then granted pub(crate) back
to only the 19 the compiler named as cross-module callers.

Also drops now-stale pub(crate) from the nine section functions plus
method_color and method_badge: every caller moved into view.rs when
central() did, so they are file-local.

No behavior change.
@rizukirr
rizukirr merged commit dde7ddb into main Jul 31, 2026
4 checks passed
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