315 lines
17 KiB
Markdown
315 lines
17 KiB
Markdown
# Nexvane Hub
|
||
|
||
An Avalonia UI (MVVM) scaffold for a project-launcher "hub" app, styled to match
|
||
the Nexvane editor's dark UI and the cards/buttons from the marketing site.
|
||
|
||
## Running it
|
||
|
||
Requires the .NET 8 SDK.
|
||
|
||
```
|
||
dotnet restore
|
||
dotnet run
|
||
```
|
||
|
||
## Structure
|
||
|
||
```
|
||
NexvaneHub/
|
||
├─ Models/
|
||
│ └─ ProjectModel.cs Plain data: name, directory, SDK version, etc.
|
||
├─ ViewModels/
|
||
│ ├─ ViewModelBase.cs
|
||
│ ├─ MainWindowViewModel.cs Nav state, project list, Open/Delete/New commands
|
||
│ └─ ProjectItemViewModel.cs
|
||
├─ Views/
|
||
│ └─ MainWindow.axaml(.cs) Top bar + sidebar nav + card list + detail panel
|
||
├─ Converters/
|
||
│ └─ EnumEqualsConverter.cs Drives nav selection & section switching
|
||
├─ Styles/
|
||
│ ├─ Theme.axaml Color palette (brushes) — edit this to re-theme
|
||
│ └─ Controls.axaml Card / button / nav-item / field styles
|
||
└─ App.axaml(.cs)
|
||
```
|
||
|
||
## What's wired up vs. stubbed
|
||
|
||
- The **Projects** section is fully built: card list, selection, detail panel
|
||
with collapsible "Project" / "SDK" groups, New / Open / Delete.
|
||
- Every field in the detail panel is a live, two-way-bound `TextBox` —
|
||
**Name** (the title), **Thumbnail**, **Version**, **SDK Version**,
|
||
**Default Compiler**, **Default Arch** — and edits write straight back onto
|
||
the underlying `ProjectModel` via the `On<X>Changed` partial methods in
|
||
`ProjectItemViewModel.cs`. **Directory** is the one exception: it's a plain
|
||
read-only field (not a `TextBox`), since it's tied to where the project
|
||
actually lives on disk rather than something you'd retype by hand.
|
||
- Default Compiler / Default Arch are plain text inputs here rather than
|
||
dropdowns of preset values (MSVC/Clang/GCC, x64/ARM64/Auto) — say the word
|
||
and I'll swap them for `ComboBox`es with those options.
|
||
- **SDK** and **Packages** nav items switch the middle pane but are placeholder
|
||
content — swap the placeholder `Border` in `MainWindow.axaml` for real views
|
||
when you're ready (a `SdkView.axaml` / `PackagesView.axaml` user control each,
|
||
same pattern as the project list).
|
||
- `OpenProjectCommand` and `DeleteProjectCommand` are stubbed in
|
||
`MainWindowViewModel` — `DeleteProject` already removes it from the in-memory
|
||
list, but neither touches disk. Hook `OpenProject` up to launching your editor
|
||
process against `SelectedProject.Directory`, and add a confirmation dialog to
|
||
`DeleteProject` before it goes near real files.
|
||
- Project thumbnails are plain placeholder tiles (no image loading yet) since
|
||
there's no asset pipeline in this scaffold — swap `Border.thumb` /
|
||
`Border.thumbLarge` for an `Image` bound to `ThumbnailPath` once you have
|
||
real screenshots to show.
|
||
|
||
## Custom title bar
|
||
|
||
The window uses a fully custom title bar (`ExtendClientAreaToDecorationsHint`
|
||
+ `ExtendClientAreaChromeHints="NoChrome"` in `MainWindow.axaml`), so the OS's
|
||
native title/caption buttons are gone and replaced with themed ones. Two
|
||
things make this work, both in `MainWindow.axaml.cs`:
|
||
|
||
- Dragging: with no native chrome, the OS no longer treats the top bar as
|
||
draggable, so `TitleBar_PointerPressed` calls `BeginMoveDrag` by hand. The
|
||
settings gear and the minimize/maximize/close buttons sit inside that same
|
||
bar but consume their own `PointerPressed` before it bubbles up, so
|
||
clicking them doesn't also start a drag - you don't need to carve out
|
||
separate "drag zones" around them.
|
||
- Minimize/maximize/close: plain `Click` handlers that flip `WindowState`
|
||
or call `Close()`.
|
||
|
||
This is the standard cross-platform-friendly approach (as opposed to
|
||
`SystemDecorations="None"`, which also removes OS-level resize/snap
|
||
behavior) but it's only been checked against the Windows screenshots you
|
||
sent — Linux/macOS may need small adjustments (e.g. macOS traffic-light
|
||
buttons behave differently under this hint).
|
||
|
||
## Design system
|
||
|
||
- **Rounded corners (6-8px) almost everywhere** — buttons, fields, cards, the
|
||
project list frame, the logo mark. The one exception is the **left sidebar
|
||
buttons** (Projects/SDK/Packages/Account), which stay square and flush
|
||
against each other, no radius, no margin — everything else softened.
|
||
- **Flush pairs where it matters:** sidebar nav items sit directly against
|
||
each other, and Open/Delete are one visual pair with no gap between them —
|
||
Open's own bottom border doubles as the divider, and only the outer
|
||
corners of the pair are rounded (`CornerRadius="6,6,0,0"` / `"0,0,6,6"`).
|
||
- **Accent blue appears only at the instant of interaction** — a button
|
||
being pressed, or a field gaining focus. Nothing stays accent-colored at
|
||
rest: the active sidebar tab is a neutral highlight + bold text, and the
|
||
Project/SDK section headers are neutral even while expanded. That second
|
||
one needed an explicit override — Avalonia's `ToggleButton` has a
|
||
built-in accent-colored `:checked` style from the Fluent theme, so
|
||
`Controls.axaml` overrides `ToggleButton.sectionHeader:checked` directly;
|
||
without that override the section headers would stay blue whenever
|
||
expanded regardless of anything else in the stylesheet. Delete keeps its
|
||
own red language on press rather than switching to blue, since a
|
||
destructive action turning "accent blue" would read as reassuring rather
|
||
than a warning.
|
||
- Palette pulled tighter to the editor screenshot: `#1B1B1D` background,
|
||
`#1F1F22` panels, `#2F6FED` accent. All of it lives in `Styles/Theme.axaml`
|
||
as named brushes — change values there and it propagates everywhere. Shapes
|
||
(radius, padding, borders) live in `Styles/Controls.axaml`.
|
||
|
||
## Layout fixes from the last round
|
||
|
||
- **Scrollbar gutter:** both scrollable areas (the project list, the detail
|
||
panel's fields) now have `Padding="0,0,14,0"` on their `ScrollViewer`, so
|
||
the scrollbar has its own space instead of drawing over the last ~14px of
|
||
content.
|
||
- **Divider vs. hover width:** `ListBoxItem` padding was moved off the item
|
||
itself and onto the content inside each row's `DataTemplate`. Previously
|
||
the item's own padding shrank the divider line but not the hover/selected
|
||
background, so the two didn't line up — now both span the row's full
|
||
width.
|
||
- **New folded into the Projects card:** the project list and the "New"
|
||
button now live inside one `Border` (rounded + `ClipToBounds="True"`),
|
||
with New as a bottom row separated only by a hairline — not a second,
|
||
separately-bordered element below the card.
|
||
- **Open/Delete got their divider back:** the fully-seamless version from
|
||
last round read as one blurred shape rather than two buttons. They're
|
||
still a single rounded, clipped block with zero margin between them, just
|
||
with a 1px hairline down the middle so the pairing is unambiguous.
|
||
- **Detail panel now belongs to the Projects tab:** the right column
|
||
(`Border.detailPanel`) is `IsVisible`-bound to `CurrentSection == Projects`
|
||
the same way the project list already was — it no longer stays on screen
|
||
showing stale project data while browsing SDK/Packages. Note the column
|
||
itself is a fixed `360` width in the outer `Grid.ColumnDefinitions`, so
|
||
when it's hidden that space goes blank rather than being reclaimed by the
|
||
middle column — say the word if you'd rather it collapse to `0` and let
|
||
the list expand to fill it.
|
||
- **Title bar menu row:** a second row (`File / Edit / View / Tools / Help`,
|
||
plain text buttons, no persistent fill) now sits below the title, per your
|
||
correction on the reference screenshot. Both rows live inside the same
|
||
draggable `Border.topBar`, so dragging still works from either row's blank
|
||
space, and the window's outer `Grid` row switched from a fixed `56` to
|
||
`Auto` to fit the taller two-row bar.
|
||
|
||
## Menu dropdowns
|
||
|
||
Each menu item opens a dropdown via `Button.Flyout` — Avalonia's built-in
|
||
mechanism handles positioning, click-away-to-close, and Escape-to-dismiss;
|
||
everything visible inside is our own styled content (`Button.dropdownItem`
|
||
rows + `Border.dropdownSeparator` dividers), not the default theme's `Menu`/
|
||
`MenuItem` look. This was a deliberate choice: `MenuItem`'s built-in submenu
|
||
popup pulls its background/border from deeper theme resources than a plain
|
||
`Button`, so styling `Button` + `Flyout` (only two things to override:
|
||
`FlyoutPresenter` for the panel chrome, `Button.dropdownItem` for the rows)
|
||
was the more reliable path without being able to compile-check the result.
|
||
|
||
- **Sections** are just `Border.dropdownSeparator` — a 1px divider — dropped
|
||
between groups of `Button.dropdownItem` rows in the `StackPanel`. Add or
|
||
remove them freely; there's no hidden logic tying them to anything.
|
||
- Every dropdown item is a placeholder with no `Click` handler, same as the
|
||
menu bar buttons themselves last round — clicking one closes the dropdown
|
||
(that part's free, from the Flyout) but doesn't do anything yet. Wiring
|
||
one up is a `Click="SomeHandler"` in the XAML plus a method in
|
||
`MainWindow.axaml.cs` (or a `Command` binding if it belongs on
|
||
`MainWindowViewModel` instead — e.g. "Toggle Sidebar" is a real candidate
|
||
for that once the sidebar is collapsible).
|
||
- The item lists (New Project, Undo, Toggle Sidebar, etc.) are my best guess
|
||
at what a project-hub menu would plausibly contain, not something you
|
||
asked for by name — trim, rename, or reorganize the sections however
|
||
actually fits.
|
||
|
||
## Logo mark
|
||
|
||
The title bar's logo badge now renders your actual `nexvane-logo.svg`
|
||
(kept in `Assets/nexvane-logo.svg` for reference) instead of a placeholder
|
||
"N" glyph, and spans the full height of both title-bar rows rather than
|
||
being confined to the top one — like the editor's title bar. Both were part
|
||
of the same request.
|
||
|
||
- **Why it's inline vector, not an `<Image>`:** Avalonia has no built-in SVG
|
||
support, and pulling in an SVG-rendering package would mean a new
|
||
dependency I couldn't verify actually resolves/compiles without a working
|
||
`dotnet restore` here. Instead, the exact path data from your SVG (all
|
||
three `<path>` elements, copied verbatim — the geometry itself isn't
|
||
something I redrew or approximated) is embedded directly as Avalonia
|
||
`Path` elements inside `MainWindow.axaml`, wrapped in a `Canvas` with the
|
||
same `scale(0.1,-0.1)` + `translate` your SVG applies, further shifted so
|
||
the mark's tight bounding box starts at `(0,0)` and a `Viewbox` scales it
|
||
cleanly to size. Net effect: what renders is your logo's real geometry,
|
||
using only built-in Avalonia controls — no new package, so nothing new
|
||
that can fail to restore.
|
||
- **The tall badge:** `Border.logoBadge` changed from a fixed `30x30` square
|
||
to a `64`-wide column that stretches to fill its `Grid` cell's height —
|
||
paired with moving it into its own `Grid.Column="0"` next to a
|
||
`Grid.Column="1"` holding both the title row and the menu row stacked
|
||
on top of each other, so the badge naturally spans both. `Border.topBar`'s
|
||
own padding moved from the whole bar onto just that second column
|
||
(`Margin="16,0,20,0"`), since the badge needs to sit flush in the corner,
|
||
not inset.
|
||
- If you'd rather use the actual `.svg` file directly (say, for reuse
|
||
elsewhere as an app icon) rather than this inlined copy, that's where
|
||
a package like `Avalonia.Svg.Skia` would come in — just flag it and I'll
|
||
wire it up, now that there's a known-working fallback already in place if
|
||
that package route hits a snag.
|
||
|
||
## Title bar height fix
|
||
|
||
The oversized bar (and the stray unstyled "Close" button floating below the
|
||
real controls) both trace back to the same thing: the bar's total height was
|
||
being **auto-measured** through several nested layers — `Viewbox` inside
|
||
`Border` inside `Grid` inside `StackPanel` inside `Border.topBar` — while
|
||
`ExtendClientAreaTitleBarHeightHint="-1"` told the OS to *auto-detect* that
|
||
same height from the content. Two auto-measurements that both depend on
|
||
several nested layers agreeing exactly are exactly the kind of thing that's
|
||
fragile to get wrong, and apparently did here.
|
||
|
||
Fixed by pinning the number everywhere instead of deriving it:
|
||
|
||
- `ExtendClientAreaTitleBarHeightHint="88"` — explicit, not `-1`.
|
||
- The window's outer `Grid.RowDefinitions` is `"88,*"`, not `"Auto,*"`.
|
||
- `Border.topBar` has an explicit `Height="88"` in `Controls.axaml`.
|
||
- The menu row is `Height="32"` directly, not implied by margin/padding.
|
||
|
||
56 (title row) + 32 (menu row) = 88 everywhere, top to bottom, instead of
|
||
being something four different elements had to happen to agree on through
|
||
measurement. I couldn't run the app to confirm this eliminates the stray
|
||
"Close" button specifically — that part reads as Windows/Avalonia injecting
|
||
a fallback once the declared and auto-detected heights disagreed, which is
|
||
a reasonable explanation but not one I can verify without a build. If it's
|
||
still there after this, the next thing to try is `SystemDecorations="None"`
|
||
instead of the `ExtendClientArea*` hints — a blunter tool that drops native
|
||
resize/snap behavior, but leaves nothing for the OS to guess about.
|
||
|
||
## Logo crop + menu alignment fix
|
||
|
||
Two more from the same area, addressed separately since the title bar
|
||
height/Close-button issue above is parked for now:
|
||
|
||
- **Logo was cropping.** The previous version scaled/positioned the mark via
|
||
`Canvas.RenderTransform` (a `ScaleTransform` + `TranslateTransform`) while
|
||
leaving the raw SVG coordinates untouched, relying on `Viewbox` to
|
||
correctly reconcile the `Canvas`'s *declared* size against a *separately
|
||
transformed* child. I re-verified the bounding-box math itself was
|
||
correct (recomputed it a second way, using a full path parser rather than
|
||
just tracking points, and got the same 800×899 result) — so the transform
|
||
numbers weren't wrong, but I couldn't rule out the reconciliation itself
|
||
as the failure point without being able to render it. Removed that
|
||
uncertainty entirely: the SVG's `scale(0.1,-0.1)` + `translate` is now
|
||
baked directly into every coordinate (computed once, offline), so each
|
||
`Path.Data` is already in its final coordinate space — no
|
||
`RenderTransform` left for `Viewbox` to reconcile against. There's also a
|
||
2% safety margin baked into the `Canvas` size now, so even a small
|
||
rounding difference can't clip anything.
|
||
- **Menu row wasn't aligned with the title.** `Button.menuBarItem` has
|
||
`Padding="7,3"` for a comfortable hit target, but the title `TextBlock`
|
||
above it has no padding — so "File" rendered 7px right of where "Nexvane"
|
||
starts. Fixed with `Margin="-7,0,0,0"` on the menu row's `StackPanel`,
|
||
canceling that offset out so the two rows' text lines up.
|
||
|
||
## Compacting the title bar
|
||
|
||
The 88px bar had both rows *centered inside* fixed heights bigger than
|
||
their actual content needed, which read as loose/padded rather than tight
|
||
like the editor reference. Shrunk everything to hug its content instead of
|
||
floating inside oversized rows, and brought the bar's total height down to
|
||
match:
|
||
|
||
- Title row: `56px → 38px`. Menu row: `32px → 24px`. Total: `88px → 62px`
|
||
(same pattern as before — pinned as an explicit number in four places:
|
||
`ExtendClientAreaTitleBarHeightHint`, the window's outer `Grid` row,
|
||
`Border.topBar`'s `Height`, and the two rows' own heights summing to it).
|
||
- Caption buttons (`38×26`, down from `44×32`), the settings gear
|
||
(`26×26`, down from `34×34`), and the logo `Viewbox` (`22×24.6`, down
|
||
from `26×29`) all shrank to match, so nothing looks oversized next to the
|
||
tighter text.
|
||
- `Button.menuBarItem`'s padding went from `7,3` to `6,2`, and the
|
||
alignment-compensating margin on the menu row updated from `-7` to `-6`
|
||
to match.
|
||
- `Border.logoBadge` narrowed from `64px` to `56px` wide, in proportion
|
||
with the shorter bar.
|
||
|
||
All of this is proportional scaling from the same values as before, not a
|
||
redesign — if 62px is still taller (or shorter) than you'd like, every
|
||
number above is a single line in `Controls.axaml` or `MainWindow.axaml`,
|
||
not something that requires touching the structure.
|
||
|
||
## Fixing the logo/text vertical balance
|
||
|
||
Two related issues after compacting: the text rows visually overshooting
|
||
the logo (top and bottom), and everything sitting too close to the bar's
|
||
edges. Same root cause for both — the logo's `Viewbox` (`22×24.6`) was
|
||
small relative to its `62px`-tall column, so it rendered as a small mark
|
||
centered in a lot of invisible padding, while the title/menu rows spanned
|
||
the *full* column height with only their own font-metric spacing. The
|
||
logo's visible ink and the text's visible extent weren't bounded by the
|
||
same space, even though they shared the same container.
|
||
|
||
Fixed by giving both the same physical space to live in:
|
||
|
||
- `Border.topBar` now has `Padding="0,3,0,3"` — a shared 3px inset from the
|
||
bar's top and bottom that both the logo *and* the text column sit inside
|
||
(it's on the shared parent, not applied separately to each side, so
|
||
there's no way for them to drift out of sync again). Total height is
|
||
still `62px`; the title/menu rows shrank from `38/24` to `34/22` to fit
|
||
inside the now-smaller `56px` content area (`34+22=56`, `+3+3` padding
|
||
`=62`).
|
||
- The logo `Viewbox` grew from `22×24.6` to `43×48` — closer to filling
|
||
that `56px` content height instead of floating small in the middle, so
|
||
its visible bounds now roughly bracket the text block instead of being
|
||
dwarfed by it. `Border.logoBadge` widened from `56px` to `60px` to give
|
||
the bigger mark reasonable side clearance.
|
||
- The text column's margins grew from `14,0,16,0` to `20,0,20,0` for more
|
||
breathing room from both the logo and the window's right edge.
|