Skip to content

Refactor/scanner UI - #19

Merged
MohsenBg merged 61 commits into
mainfrom
refactor/scanner-ui
Oct 21, 2026
Merged

MohsenBg merged 61 commits into
mainfrom
refactor/scanner-ui

Conversation

@MohsenBg

Copy link
Copy Markdown
Owner

Scanner UI refactor PR notes

Base: origin/main (merge-base ca1c17c)
Branch: refactor/scanner-ui
Commits: 61, including a merge from feat/libxray-link-parse
Diff: 269 files changed, 12,445 additions and 7,809 deletions

This branch started as a UI refactor but ended up touching quite a bit more than the UI. It changes how key bindings are managed, how dialogs and screens are rendered, how scanner results are collected and displayed, and how Xray configs and result files are handled.

The changes are spread across 61 commits, so going through them one by one doesn't tell the full story. I've grouped them by area below, starting with the shared infrastructure and moving up to the screens and features that use it.

The main areas are:

  1. Core changes: typed validation fields, logger cleanup, Xray config handling, link parsing, and result deduplication.
  2. Scanner lifecycle: safer state handling, clearer setup errors, and typed result columns.
  3. UI infrastructure: shared key bindings, session and environment state, dialog framing, and animation helpers.
  4. Reusable components: menus, help footers, tables, inputs, tabs, and other shared widgets.
  5. Screens and features: inspector forms, outbound management, theme colors, and the redesigned scanner screen.

At HEAD, the project builds successfully and go test ./internal/... passes. There are still a few things worth checking before merging; they're listed at the end.

Commit history

The commits are grouped by area rather than listed as a chronological changelog. This makes it easier to see which changes belong together and how the pieces fit.

[Keep the existing grouped commit index here.]


1. UI foundation

shared/ui — replacing AppState and Deps with Session and Env

Previously, the app used ui.AppState and ui.Deps, while key bindings lived separately in shared/env. That setup spread related state across multiple places.

The new structure has two main parts:

  • ui.Session is created in app.New and holds the shared Config, Store, Program, and loggers. Program only exposes Send, allowing components to send messages from worker goroutines without depending on the full Bubble Tea program.
  • ui.Env provides the values components need when rendering: layout, theme, key bindings, UI logger, and settings. Components access it through session.Env().

Key modes (Normal, Input, and Manage) now live in ui as KeyMode values. The old ManagedMode is now KeyModeManage.

This change affects most of the component tree. References to *ui.AppState have been replaced with *ui.Session, and logging is split between session.CoreLog() and session.Env().Log.

The theme handling changed too. Components no longer receive theme updates through individual SetTheme calls. Instead, the app broadcasts theme.ThemeChanged{}, and components read the current theme from the environment when they render.

deps.go, state.go, theme.go, and the old shared/env package have been removed.

One detail worth keeping in mind: Env.Theme is a theme.Theme value, not a pointer. Theme changes are propagated through the update flow rather than by mutating a shared theme object in place.

shared/keys — one place for key bindings

The new shared/keys package centralizes the app's key bindings and the text used to describe them.

It consists of four files:

  • action.go defines action identifiers such as "global.back".
  • binding.go defines keys.Binding, including the key used for matching and the text shown in short and expanded help.
  • defaults.go contains the default bindings.
  • keys.go groups bindings by context, including navigation, tables, CRUD actions, dialogs, forms, scanning, inspectors, tabs, inputs, and the picker.

Previously, components maintained their own Bubble Tea bindings and help strings. That made it easy for the actual shortcut and the displayed hint to get out of sync.

Now both come from the same binding definitions. The Short, Long, and Combine helpers build hint lists from those bindings, while DisplayKey formats key names for display (up becomes ↑, enter becomes ⏎).

shared/dialog — shared dialog framing and animations

Dialogs now use a common frame instead of each component drawing its own title, borders, and footer hints.

The new files handle different parts of that work:

  • frame.go draws the rounded border, puts the title into the top border, and places key hints along the bottom. The border is drawn manually so the title and hints can fit into the border lines.
  • composite.go combines the dialog with the content behind it during an animation. It handles partial rows using ANSI-aware text operations so the animation doesn't expose incorrectly aligned terminal content.
  • animate.go defines the reveal animations and their timing.

The available animations are split, curtain, drop-in, and rise-up, plus AnimNone. The default animation duration is 120 ms.

Dialog state is now managed at the app root rather than inside the workspace. The root keeps a stack of dialogs and tracks when each one opens or starts closing. A frame ticker runs only while an animation is active. Once a closing dialog finishes, it is removed and its deferred close commands are executed.

Keys are ignored while the top dialog is closing, preventing another action from being triggered against a component that's halfway through disappearing.

A later fix also combines the component's own OnClose() command with the callback supplied by the code that opened it. Both callbacks now run regardless of whether the dialog closes itself or the user backs out.

shared/anim — common animation helpers

The new shared/anim package contains the easing and interpolation helpers used throughout the UI:

  • Clamp01
  • EaseOutCubic
  • EaseInOutCubic
  • Progress
  • Mix

It also defines a 16 ms frame interval. Dialogs, menu entrance effects, tab transitions, and screen swaps use these helpers rather than implementing their own easing calculations.

shared/meter — interpreting scanner results

The new shared/meter package maps result values to quality tiers.

meter.Resolve uses the probe schema, column type, and cell value to select a tier. Latency and bandwidth have separate threshold tables, with thresholds keyed by schema name.

The tiers are Plain, Fail, Excellent, Good, Fair, Poor, and Over. Duration strings such as µs, ms, and s are parsed before being compared against latency thresholds.

At the moment, latency and Xray bandwidth have configured thresholds. Other schemas can use the same mechanism when their thresholds are added.


2. App shell

Screen transitions

The body component now maintains a stack of full-screen components and can animate when one screen opens or closes.

When a screen is pushed, the body starts a transition if animations are enabled and another screen is already present. When a screen is popped, its removal and OnClose callback are deferred until the transition finishes.

The transition is a horizontal page push: the old screen moves left as the new one enters from the right. Closing reverses the movement. The animation uses cubic easing and lasts 300 ms.

Earlier versions used simpler slides and other reveal effects. The push transition was kept because it works better with dense terminal content.

Input is suppressed while the transition is running, so pressing Back repeatedly doesn't accidentally close multiple screens. Closing a component in the middle of the stack remains immediate. Resetting the stack, such as when leaving the scanner, also remains immediate and runs pending close callbacks exactly once.

Back on the main menu now opens the exit confirmation instead of doing nothing.

Moving the banner into the entry menu

The old header system has been removed. Previously, opening or closing a screen could trigger a chain of messages to hide or show the header, recalculate the layout, and send a synthetic window-size update.

That was a lot of machinery for a banner that only appeared on the entry screen.

The banner now lives in components/menus/entry/banner.go and is rendered directly by the entry menu. The header package and the layout's header visibility methods have been removed.

The body always receives the available content height, and the footer sits below it. Workspace no longer needs to manage header state or react to header visibility messages.

One visible difference is that the banner now moves out with the entry screen when the scanner opens, rather than disappearing separately.

Workspace, footer, and startup

Workspace now mainly connects the body and footer and forwards theme and resize updates.

The footer retains its version, current-screen status, and runtime statistics. Screen status updates use footer.UpdateStatus.

The splash screen can be dismissed with any key. Startup health-check delays were also shortened from 100–200 ms to 50–80 ms.


3. Core changes

Typed validation fields

Validation errors now use typed string keys instead of plain strings.

internal/core/config/validate/fields.go defines field identifiers for general settings and probe configurations. internal/core/dns/fields.go does the same for DNS tunnel settings.

The validation rules and their bounds haven't changed. The main difference is that callers now use constants such as validate.FieldWorkers instead of repeating string literals. This makes it easier to keep validation results and inspector form fields in sync when a field is renamed.

The DNS validators still use a shared return type, so the six tunnel implementations share one set of field keys. Existing string values were preserved, including the difference between FieldDNSPort and FieldResolverPort in some implementations.

ValidateDNS still prefixes resolver and tunnel field names where needed. Tests now cover the resulting composite keys.

The form error formatter was also updated to accept any string-based key type. It sorts field names before displaying errors, so validation messages appear in a consistent order.

Logger changes

The logger setup has been simplified to two loggers: core.log and ui.log.

The old logger.Set, NewSet, and DiscardSet APIs have been removed. Each logger now has its own fallback if its file can't be opened, so a failure to initialize one no longer disables the other. debug.log and its startup check have also been removed.

The logging methods now share a central level check, and nil-sink guards have been added to methods such as With, SetLevel, Level, Slog, Subscribe, Unsubscribe, and Close.

The app passes both loggers into app.New, which applies the saved log level. The settings screen updates their levels directly.

One small documentation detail: the old package comment describing printf-style logging and nil-logger behavior has been reduced to a single line. It may be worth restoring the useful parts.


4. Xray config handling and result files

Typed configs and preserving unknown fields

The Xray config types have been renamed from XrayConfig and XrayOutboundsFile to Config and OutboundFile.

Outbounds in Config are now stored as []json.RawMessage, since the scanner doesn't need to interpret every outbound before passing it to Xray.

The outbound metadata used by the UI is now typed. Protocol, network, and security are represented explicitly, and the outbounds table displays a Security column instead of the previous TLS yes/no field.

Outbound, StreamSettings, and TLSSettings now preserve unknown JSON fields through an Extra map. This matters when importing provider configs: decoding and re-encoding a config should not silently discard fields the current structs don't know about.

The new tests cover these round trips and cleanJSONC, which removes JSON comments while preserving comment-like text inside strings.

Link parsing now uses libxray

The hand-written share-link parser has been replaced with libxray.ConvertShareLinksToXrayJson.

This removes a substantial amount of parser code and its associated tests. The new path converts the links through libxray and extracts the main outbound.

The trade-off is that this repository no longer has direct ParseLink tests. Scheme-specific behavior, including older Shadowsocks URL formats and base64 padding, now depends on the libxray implementation.

Replacing outbound addresses

Outbound templates no longer need to contain a literal $ADDRESS placeholder.

Previously, saving a template without that placeholder failed, and the scanner replaced the placeholder when generating a config. The new implementation stores the outbound as provided and replaces the server address structurally when preparing a scan.

It handles common address locations, including settings.address, vnext, servers, and WireGuard peer endpoints.

requireReplaceableAddress checks the template when it is saved, so templates without a usable server address are rejected earlier.

GenerateConfig now takes separate local and remote ports. The remote port is currently passed as 0, leaving room for future port-range probing.

Existing templates containing $ADDRESS continue to work because the replacement logic no longer depends on that exact placeholder.

The generated inbound config also no longer enables sniffing. Sniffing can rewrite the destination based on SNI or Host, which conflicts with scanning a specific raw IP and port.

Result-file deduplication

The result merger has been rewritten to prevent duplicate rows when new scan results are merged into an existing CSV.

The previous implementation had several problems: it didn't remove duplicates within a batch, could append a key already present elsewhere in the file, and didn't guarantee stable ordering when scores were equal.

The new implementation sorts by descending score and then ascending key. It removes duplicates within each batch before writing and uses key-based matching when merging with the existing file.

A new test suite covers the merge behavior, including a case where a new row beats rows encountered earlier in the file but still scores below the old version of that same key.

Result columns also have a ColumnType, allowing the UI to distinguish text, IP addresses, durations, and bandwidth values. All nine probes now declare their column types. The CSV format itself hasn't changed.


5. Scanner lifecycle

Scanner lifecycle state now uses atomic booleans for started and closed, and Close is protected by sync.Once.

Previously, some invalid lifecycle operations failed silently. AddStage and UpdateStageHooks now return errors when the scanner has already started or closed. Run also returns distinct errors for a closed scanner, repeated runs, cancelled contexts, and missing stages.

Stage initialization errors now include context, such as load config, init port manager, and init slipstream service.

The shutdown timeout has been increased to 10 seconds, and the resulting timeout error is retained for subsequent calls to Close.

The separate runSingle path has been removed. runPipeline now handles both single-stage and multi-stage scans, using sequential execution when there's only one stage.

One area worth checking is the interaction between Run and Close. There may be a narrow race between Run marking the scanner as started and registering its wait-group work, during which Close could begin shutting down shared resources. The current UI flow may prevent this in practice, but the ordering deserves a closer look.


6. Reusable components

Menus

The menu no longer uses the Bubble Tea list component. It has its own renderer and supports item descriptions, scrolling, shortcut selection, and an optional sidebar with additional information.

When the terminal is wide enough and menu items have descriptions, the menu uses a two-column layout. Narrower terminals fall back to a regular list.

Menu entrance animations fade rows in with a small delay between each row. They're disabled when the global animation setting is off, and the ticker stops when the animation finishes.

Shared help

The new basic/help package builds short and expanded help views from the shared key bindings.

Short help packs hints into the available width and height, showing a +N more indicator when everything won't fit. Expanded help uses a scrollable layout.

Menus, tables, and inspector panels now use this shared component instead of maintaining their own help strings and layout logic.

Tables

Tables now support typed sorting, optional filtering, loading indicators, custom empty-state messages, and shared key hints.

IP addresses, durations, and bandwidth values are compared using their actual types instead of lexicographic string ordering. Text columns retain the string comparator.

Table height is calculated from the number of rows and the actual frame borders, rather than filling the available space regardless of content.

Filtering can be disabled per table. Filter mode also reports KeyModeManage, allowing the app to route keyboard input correctly.

One testing gap remains: the old sorting and filtering test files were deleted during the rewrite, and the replacement tests don't directly cover all of the previous behavior.

Inputs, dialogs, and picker

The text input, textarea, select, multiselect, and toggle input components now build their keymaps from the shared bindings. Each exposes dialog hints so the surrounding frame can display the correct shortcuts.

The textarea now handles newline insertion separately from submission, allowing multiline input without accidentally submitting the form.

Confirm dialogs and notices no longer draw all their own framing and footer hints. The shared dialog frame handles that consistently. The file picker also uses the shared keymap and has its own default title and entrance animation.

A new keyboard-layout warning detects non-ASCII text typed outside input mode and displays a single dialog suggesting that the user switch layouts.

Sparkline and tabs

A new sparkline component renders small charts using braille characters. It smooths incoming samples, handles flat data without exaggerating small changes, and keeps the gradient tied to screen position to avoid flickering as the data moves.

Tabs can optionally animate the underline when switching between tab labels. This effect uses the shared animation helpers and respects the global animation setting.


7. Inspector and scanner screens

CRUD actions

CRUD components now handle their own key bindings instead of relying on message interception in parent screens.

The shared hooks include OnAdd, and the action bindings come from the central keymap. Help hints are generated in a fixed order rather than depending on map iteration.

CRUD sources also expose a singular name for prompts and help text, so screens can use labels such as "add an outbound" instead of the generic "add an item".

Inspector forms

The inspector screens have been updated to use Session and Env, and theme changes no longer need to be passed through a chain of SetTheme calls.

Form rendering has been simplified because the dialog frame now owns the title and footer hints. Form sizing is also calculated from the available body dimensions before applying component-specific options.

The UI settings screen has a new Animation toggle that controls menu entrance effects, dialogs, tabs, and screen transitions. Settings save errors are now checked rather than silently ignored.

Scanner screen rewrite

The scanner screen has been split into separate stage, frame, panel, and formatting components.

Previously, the screen maintained several parallel slices indexed by stage ID, all protected by a shared mutex. The new design gives each stage its own state and result table.

Workers append results to a stage-local pending batch. The UI drains that batch by swapping it under the mutex, avoiding a separate Bubble Tea message for every result and keeping the locked section short.

The stage update messages now carry stage IDs, so progress, errors, and completion updates go to the right stage.

Result merging has also been improved. Since the existing results and incoming batch are already sorted, they can be merged in linear time instead of sorting everything again. If the table is full and the best incoming result can't beat the worst displayed result, the merge can stop early.

The screen now has separate Scan and Errors panels, showing scan counts, remaining work, elapsed time, hit rate, and a breakdown of failures by type. Stage tables use the new typed result columns and quality-tier colors.

Closing the scanner now follows a consistent path: close the scanner, reset the screen stack when shutdown succeeds, or show an error notice if shutdown fails. Closing that notice also returns to the main menu.

Theme colors and result highlighting

Themes now support four configurable quality-tier colors: excellent, good, fair, and poor. Failed values use the muted color, values above the configured range use the error color, and plain values aren't styled.

The IP viewer uses the result schema and column type to choose a tier for each cell.

It also fixes a terminal styling issue where a cell's foreground reset could interfere with the selected-row background. A focused test covers that behavior.

Copy-IP now finds the IP column by its declared type rather than assuming it is the first column. Refreshes also avoid resetting the table rows when nothing has changed, preserving the current scroll position.


8. Dependencies and test coverage

The dependency updates include the Bubble Tea v2 stack, ANSI-aware text handling, libxray, display-width support, and updates to several networking-related dependencies.

The Xray and TheFeed submodule references have been updated to match the dependency versions. Two outbound example files also had trailing commas removed so they're valid JSON, and their example addresses now use example.com instead of $ADDRESS.

New tests cover config field preservation, outbound handling, result merging, scanner lifecycle behavior, theme tier colors, and IP viewer row styling.

Some previous tests were removed alongside the implementations they covered:

  • The hand-written Xray link parser tests were removed with the parser.
  • The table sorting and filtering tests were removed during the table rewrite.
  • The old theme tests were replaced by tier-color tests.

These deletions are worth considering separately from the new implementation. In particular, the table behavior needs enough coverage to make sure the rewrite hasn't dropped existing functionality.


9. Changes visible to users

The main differences users will notice are:

  • Logging now uses core.log and ui.log; debug.log is gone.
  • Menus show descriptions and have a more consistent layout.
  • Dialogs share a common frame, with titles and shortcut hints in the border.
  • Menus, dialogs, tabs, and screen transitions have animations, with a setting to turn them off.
  • Tables can sort IP addresses, durations, and bandwidth by value, and scanner results use quality-tier colors.
  • The scanner screen has new summary panels and a per-stage layout.
  • Invalid scanner lifecycle operations now return errors instead of failing silently.
  • Back from the main menu opens the exit confirmation.
  • Typing non-ASCII text outside an input field triggers a keyboard-layout hint.

10. Things to check before merging

  1. Scanner shutdown race: Check whether Close() can run between the Run() state change and wait-group registration.
  2. Link parser coverage: ParseLink now delegates to libxray, but the new path has no direct tests.
  3. Table tests: Add coverage for typed sorting, filtering, and their interaction.
  4. Input dialog title: The OpenInputDialog comment says the input title is used by default, but the implementation only attaches hints.
  5. Menu sizing: Check whether chromeRows and effectiveSize can leave the rendered menu one row taller than expected.
  6. Non-Latin detection: The ASCII hex-digit check appears unreachable after the ASCII check, and the comment may not accurately describe the scripts being detected.
  7. Confirm button layout: Button widths are calculated against terminal width rather than the dialog frame's inner width.
  8. Scanner error notices: Check whether repeated worker errors can flood the UI with notices.
  9. Existing outbound files: Confirm older templates load with the new Security column populated correctly.
  10. Slipstream validation: Verify that the slipstream form uses FieldDNSPort for the corresponding inline validation error.
  11. Tab animation: Switching between tabs with different label widths may cause the underline to shift during the animation.
  12. Outbound import flow: Check that opening a picker while the menu is closing doesn't produce a layering issue.
  13. CRUD update assertion: crud.Update still asserts updated.(*table.Model) directly, which can panic if the component type changes.
  14. Result deduplication: Add an end-to-end test covering duplicate results across multiple flushes.
  15. Writer logging API: Check that no external consumer still expects the removed writer logging field.
  16. Writer validation key: ValidateWriter reports ResultDirectory under FieldResultBaseDir; confirm whether that mismatch should be fixed here.
  17. Menu initialization: Check that repeated calls to menu.Init() don't create duplicate animation tickers.
  18. Failure color visibility: Review the muted color used for failed cells across all built-in themes.

These are review points, not all confirmed bugs. The goal is to verify the questionable paths before merging rather than assume each one needs a code change.

Replace the bubbles list embedding with a custom renderer offering two
modes: a sidebar with a detail pane (descriptions, open hint, shortcut)
when the block is wide enough, and compact capsule rows otherwise.

- add shared anim package (easing, color mixing, standard timings)
- add item descriptions to main, target source, and scan type menus
- staggered entrance fade; selection and activation stay instant
- gap-spaced rows, left-bar selector, scroll markers for long lists
- forward Init from wrapper menus so the entrance replays on mount
- promote x/ansi to a direct dependency
Track header visibility in Layout and drive it with a
HeaderVisibilityMsg emitted on every component stack change, so
sub-screens get the extra body rows back.
Tables now grow with the row count (min 25 lines of box, capped by the
body region) instead of always filling the body, and the help bar drops
items that do not fit instead of wrapping past its border.
Any key now dismisses the splash, and the health check report delay
drops from 100-200ms to 50-80ms so startup feels snappier.
Replace the string keys used in the validation error maps with a Field
type, declared as constants in a new fields.go in both the dns and
config/validate packages. The DNS probes, the config inspectors, the
tunnel config forms and the formkit helpers move to the same type, so a
misspelled field is a compile error rather than a silently missing
error.
Add internal/ui/shared/keys holding every Bubble Tea binding together
with its help text, plus matching and help-rendering helpers. Deps now
carries the shared Keys, so components stop hardcoding key strings such
as "up", "k" and "?" in their update loops.
Dialogs gain a title, a severity style and key hints drawn in the frame
border, replacing the chrome each component used to render for itself.
The dialog stack moves out of the workspace and into the root app model,
which composites the frames over the current screen. The workspace goes
back to a plain header/body/footer layout.
New basic/help component renders the compact hint row inline and the
full binding list in a scrolling dialog, sized from the layout and
clamped to the body.
Components hosted in a dialog now expose their title and key hints as
data and let the frame draw them, so nothing is rendered twice. Add
SetInDialog to the inputs, forms, log view, notice, picker and formkit
base so they drop their own title and hint rows, and open the tunnel
menus and configuration forms as framed dialogs.
The menu and the startup check screen match against the shared bindings
instead of literal key strings, and the menu embeds the shared help
panel. The preflight checks are split into named steps and the error
formatting moves out of the reporter.
Each settings section now says what it holds in the menu's detail pane
instead of leaving the reader to infer it from the title alone, matching
how the entry menu lists its items.
Dialogs drop in from the top and collapse back up over 200ms instead of
appearing and vanishing instantly, and back is held to the same short
duration so it never feels like it is delaying the user.

The frame renderer moves out of the app into the dialog package so it and
the reveal share one home. The compositor that drives the reveal replaces
bubbletea-overlay, which overwrites spaces and so would punch holes in
the screen behind a half-open dialog; it is dropped from go.mod.

A closing dialog now stays on the stack until its collapse finishes,
otherwise there is nothing left on screen to animate away. That means the
stack holds its transition state per layer instead of a parallel frames
map, and OnClose is held until the layer is actually removed rather than
fired by whoever asked for the close. A dialog on its way out keeps the
keyboard without acting on it, so a second back press cannot start a
second close and fire OnClose twice.
Default() hands back Keys by value instead of a pointer, so a component
that was built from hand-assembled Deps no longer has to be told what to
do about a nil keymap. OrDefault fills the gap for those callers.

The bindings are grouped by the component that uses them: table, crud,
filter and form each get their own struct, and the menu, dialog and form
entries are derived from the shared nav bindings rather than repeating
them. Half-page movement joins the table group.

Global gains force-quit, goroutine dump and back-from-input, so the app
stops matching literal key strings for them.
…lpers

Deps.Keys becomes a value alongside the keys rework, and the env package
loses IsBackKey and IsQuitKey. Those two maps described back and quit per
mode in a place no component could see; the shared bindings now express
the same thing and each screen matches against them directly.
The header, body and footer regions were each sized to the full content
width, so a component drawing its own border pushed two cells past the
right edge of the terminal. The regions now account for the frame the app
draws around the whole screen.

The footer in particular measured its text against a width that assumed
it had no frame of its own, which made its last column fall outside the
terminal.
The short help hints are packed greedily into the rows available, each
hint truncated on its own first. Anything that does not fit becomes a
"+n more" marker rather than being dropped in silence, which is what the
old single-pass version did once a line ran out of room.

Long words are now broken across lines by splitWord instead of a second
copy of the same loop, and the wrapping no longer treats an over-wide
word as a reason to leave gaps. The long help width comes from the widest
label including the title, so the dialog stops resizing as its content
changes.
The table drops its own KeyMap of ActionKey entries and matches against
the shared keys instead, so a verb bound there is the same everywhere the
table is used. The old filter was a plain string edited by hand; it is
now a textinput, which gives it a real cursor, a character limit and the
ability to clear itself.

Rendering is rebuilt around a rounded frame: the body, the status line
that continues the bottom border, the filter line and the help panel are
all equal-width blocks, so joining them is exact and the frame no longer
shifts a column between rows. The selected row's marker is drawn into the
rendered string rather than copied, and the frame's own border size feeds
the width arithmetic, so the two cannot drift apart.

Sizing is explicit: WithWidth and WithHeight bound the table on purpose,
the height accounts for the frame instead of a hardcoded 25 rows, and the
width subtracts the real horizontal padding. Sorting is unchanged but
compareCells now compares both cells in one pass instead of guessing from
whichever side parsed first.

The stale tests are dropped with the machinery they covered: the ActionKey
keys, the string filter and the sorting they asserted are gone.
The menu reads its own menu keys from the shared set, which are the nav
bindings plus select, instead of reaching into Nav for movement and Menu
for select. The nil check that guarded the pointer is no longer needed
now the keys arrive by value.

Navigation folds into handleKey, and the long help lists every item's
shortcut so the full help dialog describes the menu it was opened from.
Confirm, notice and form stop dereferencing Deps.Keys now that it is a
value, and form.Hints takes the bindings so it can build its save hint
from them instead of hardcoding the key.

The save binding moves to a plain "s" so it is reachable without the
modifier, matching the other single-letter verbs in the crud set.
The verb table becomes a slice of entries carrying the shared binding
that triggers them, so a hook that is not set advertises no verb and
responds to no key, and the hints the table shows are derived from the
same table rather than reassembled by hand.

Verbs are matched before the table's own keys so a provider cannot be
shadowed by navigation or sorting, but the table still owns every key
while the filter is open -- including enter, which has to confirm the
filter rather than select the row.

Failing to resolve the selected row now raises a notice, the same path
the table itself takes.
The scan screen stops registering its tab, pause and log shortcuts as
table keys, because the table does not act on them -- the scan screen
does. They are passed in as hints instead, so the results table still
tells the reader where to find them without pretending to own them.

The viewer takes its own short and long hint sets through options and
titles itself from its name, so the same component can be embedded with a
different heading. dnstun threads the app state into openFormDialog to
reach the bindings its form hints come from.
The app root matches force-quit and the goroutine dump against the shared
bindings, and closing a dialog goes through the same per-mode switch the
body stack already used. That switch is the fix: the old helper treated
back as back in every mode, which let it fire while an input owned the
keyboard, and it returned "handled" for keys it did not match, so an
unrelated key press would still swallow a back.

The body stack and the startup screen switch on the mode explicitly, and
the startup screen raises the same exit confirmation the rest of the app
does instead of quitting outright. Mode gains a String so the mode a
component reports is readable in the logs.
Dialogs now open by splitting in from the centre rather than dropping in
from the top, which matches the entrance animation the menus already use
and reads as the dialog growing into place instead of sliding over the
screen behind it.
The footer held back two cells of padding it no longer had once the
layout regions started accounting for the screen frame. That left its
right-hand column two cells short of the terminal edge, so the status
line never reached the border.
Leaves were each holding their own layout/theme/log copies from
Deps, so after a resize or theme switch some of them kept drawing
with the old values. Now every leaf just keeps env *ui.Env and reads
everything live from it.

What changed:
- basic components, header and footer take env *ui.Env instead of
  ui.Deps. No more separate layout/theme/log/keys fields.
- layout is private now, read it with Terminal()/Body()/Header()/
  Footer(). Layout.Update mutates in place so all holders see it.
- theme lives as a value in Env, keys stay a pointer, log is a plain
  logger.Logger value sharing the same sink. Theme switch just
  replaces Env.Theme and sends an empty ThemeChanged.
- screens and menus/tables/scanner/forms take session *ui.Session,
  build leaves with session.Env() and use session.CoreLog() for
  scanner/config/result/xray work.
- dropped logger.Set and debug.log, AppState/Deps/HandleTheme and the
  old ThemeChangedMsg payload, and the old env.Mode. huh/bubbles
  leaves rebuild their cached styles on ThemeChanged, the rest just
  read env live.
Switch the module dependency from the old xray-core snapshot to v26.10.10
and pull in github.com/xtls/libxray for sharing-link conversion. The bundled
third_party/xray-core submodule is advanced to the matching v26.10.10
scanner-fix commit, and indirect dependencies are updated accordingly.
Replace the old XrayConfig/XrayOutboundsFile types with Config, Outbound,
OutboundFile, and related enums. Outbounds and stream/TLS settings now
preserve fields bgscan does not model in Extra maps, and outbound templates
are read as JSONC through cleanJSONC so Xray-style comments and BOMs are
accepted without changing error line numbers.

Outbound templates no longer use  placeholders. SaveOutboundFromFile
and ParseLink both extract the primary proxy outbound; addresses are rewritten
at scan time by setOutboundAddress, which covers flat, vnext, servers, and
WireGuard endpoints and supports an optional remote port override.
ParseLink now delegates sharing-link conversion to github.com/xtls/libxray
instead of maintaining a separate link parser. Inbound metadata loses its
unneeded sniffing settings in favor of the simpler localhost SOCKS inbound.

GenerateConfig gains a remotePort parameter and service/config tests use the
new Config shape. The outbound table type checks and xrayprobe fakes are
updated to OutboundFile/Config and the four-argument GenerateConfig signature.

Example outbound templates use example.com addresses.
Update the outbound CRUD table and scan-type selector to the renamed xray
OutboundFile type. The table now reports Security instead of a derived TLS
yes/no column and renders protocol, network, and security as strings, matching
the typed fields produced by the xray package.
Define shared bindings for the text, textarea, select, multi-select and
toggle inputs, register their actions, and expose them on Keys. Also let
Binding.LongItem append extra detail to the help label.
Build each input's huh/bubbles keymap from the shared key bindings and
render frame hints from DialogHints instead of hard-coded strings. Drop
the in-dialog title/hint duplication and align the Dialog interface
around DialogHints and AppendOnSubmit.
Rename the crud messages to the exported RefreshMsg/TriggerActionMsg
and the internal loaded/error messages. Add Source.Name for short
labels, route the add verb through the injected hook, centralize item
resolution, mark the table as loading while a refresh is in flight, and
use the shared notice helper for selection errors.
Implement Source.Name on the dns tun, ip list and result list sources,
wire their add verb through the OnAdd hook instead of intercepting
MsgActionTrigger in Update, and update them to the renamed refresh
message.
Return commands from the outbound menu actions instead of mutating
state from inside a tea.Cmd: the add verb now opens the import menu via
a message, the menu closes through Update, and saving runs in commands.
Rewrite the outbound menu entries and hints from the shared menu keys,
adapt the source name and table title, and update its callers.
Pad the dialog body uniformly and drop the unused confirm style
helpers while tightening the confirm message padding.
Let effectiveSize prefer an explicit WithHeight value over the body
height, and remove the stray width log line from View.
Bind TextArea.NewLine to a dedicated ActionTextAreaNewLine instead of
the submit action, rename the constant to match its siblings and fix
its help text.
Define InspectorKeys with the navigation, tab, edit and description
bindings, register the inspector actions, and expose the set on Keys so
the inspector can drive its help from shared bindings.
Swap the list's built-in help for the shared help component driven by the
inspector bindings, and export DialogHints for dialogs that host it. Field
is a list.Item now and the tabs/help children are kept by their concrete
types, so a good chunk of the model and its sizing logic could go.
The form is only ever shown in a dialog now, so drop the in-dialog
branching along with the local title, focus and hint rendering; the frame
supplies the title and hints and the form renders just the inspector.

Save and cancel now come from the shared form bindings via a new cancel
action, which also retires the keys OrDefault fallback. Validation errors
are sorted and multi-line messages get indented.
The merge was trusting a score-only cursor to handle replacements. A new
record sharing a key with an existing one could slip past it, leaving both
copies in the file - and since we re-read the file on every merge, the
dupe stuck around for good.

Changes:

- Batch dedup now costs nothing: sort by score desc then key, so equal
  keys end up next to each other and CompactFunc drops the losers.
- Replacement lookup uses an int array holding batch positions sorted by
  key, found with a binary search. No strings copied, so an IP key costs
  8 bytes here instead of map overhead per row of the file.
- A bool per batch record keeps track of what made it to disk, so a batch
  record can't be written twice (once by score, once as replacement).
- Scanner: cancel() was skipped on the slipstream init error path, leaking
  the derived context on every failed construct.

Tests cover same-key higher/lower score, dup within one batch, and a
match sitting between two other records in score order - the last one was
impossible to hit with the old positional check.
Notices, pickers and dialogs now get their chrome from the shared dialog
frame, so the notice component drops its own title/footer handling. The
picker goes the same way and shrinks a lot.

The Tab component gets its animation split into an animate.go file: a
220 ms ease-in-out slide for its chevron. Table and inspector models
receive small adjustments to match the new shared components.
Run/Close/AddStage guarded everything with one big mutex and silently ignored a stage added after start. started/closed are atomic now, close is wrapped in sync.Once with its error kept, and Run reports "scanner has already run" on a second call. AddStage returns an error instead of dropping a stage without a word, UpdateStageHooks refuses hook changes once a scan is running, and the stale allowed pre-scan type list is gone from the xray pre-scan error. Scantype menu adapted to the new AddStage signature.
ColumnDef carries a ColumnType (text, ip, duration, bandwidth) and every probe schema declares what its columns actually hold. Tables can now sort by the value behind the cell instead of the formatted string, and row actions can find the ip column by schema instead of guessing an index.
WithColumnTypes feeds the sort comparators from the schema: IPs compare as netip.Addr, durations as parsed values, bandwidth as bits per second, allANSI-stripped, with the old string compare as the fallback. WithFiltering switches the / filter off for tables that embed another one, and the filter hints follow the setting.
New sparkline for compact value charts. Progress drops the magic padding math: width is an option now and SetWidth clamps against the layout, so the caller can lay the bar out itself. Tabs lose the alignment option, gain a title, and slide in 110ms instead of 220.
Meter looks at a column type and maps the value to a quality tier using per-probe bands, so probe code never needs to know about colors. Duration and bandwidth parsing live here so the meter and the table sorting read values the same way. Zero bandwidth means the speed test was disabled, and that gets the fail tier instead of being scored as garbage.
Theme keeps optional tier fields and falls back to tokens when a theme doesn't tune them, so TierColor works for every theme out of the box. Retuned the good-tier tint per theme since the default blue read wrong against most palettes. The old theme test is replaced with a tier-focused one.
Latency and bandwidth cells take their color from the meter when the schema says the column carries one of those. Embedded resets used to kill the selected row's background halfway through, so trailing resets are swapped for SGR 39 now. Copy IP reads the schema's ip column instead of a hardcoded index, column types and the filtering switch pass through to the table, and resultlist hands the env the new constructor wants.
The layout warning used to live inside the scanner and never saw the dialog stack, so it could fight with notices. The app checks it once now: the effective key mode comes from the top dialog when one is open, otherwise workspace delegates Mode() down the chain (body -> topmost screen) and the warning opens only in normal mode with nothing already up. The non-latin detector moved to the notice package and the scanner's duplicate copy is gone.
Closing a dialog with q/esc/b ran only the component's OnClose, while the dialog closing itself ran only the WithOnClose command its opener passed. Openers' cleanup silently vanished depending on which key the user pressed - this is what stuck the keyboard layout flag with the notice closed by back. Both close paths build the same batch of the two callbacks now.
The scanner screen was one fat model. Format helpers, the chrome frame, the status panels and the stage log live in their own files now, styles moved out of the markup strings, and the model/view shed a large part of their lines. scannerUi.New takes just the session and the scanner. The non-latin check that used to sit in the key handling is gone - the app warning owns it.
The full help dialog animates with split instead of wipe, multiselect and select stop setting a huh title nothing shows, the inspector only offers tab-switch hints when there is more than one tab, the startup title row gets its height back, and the key alias maps fold to lowercase so hint labels match however a binding spells the key.
open now pushes the old screen out the left edge while the new one
comes in from the right, both moving together. on close the same page
runs back the other way. tried a plain slide, a full-cell dissolve and
a top-down wipe before landing on the push - the first two just read
as flicker on dense screens.

the closing screen stays in the stack until the swap settles, so its
OnClose and the footer status flip run at expiry, dialog-style. keys
are swallowed for the swap window and reset still snaps instantly.
settled body costs no timers - the tick stops when the swap does.
the banner was one static string shown on exactly one screen, but
showing and hiding it cost a message round-trip through body,
workspace and layout plus a fake WindowSizeMsg on every screen
change. the banner now lives in the entry screen and goes up and
down with everything else in that screen.

header package, HeaderVisibilityMsg and the showHeader branch of the
layout are gone. regions are now just body and footer, and the body
gets the rows the header used to reserve.
back did nothing at the root, quitting needed its own key. now back
opens the exit confirm too, so one key always takes you out from
anywhere.
quic-go, purego, thefeed, libxray and friends. xray-core moved to the
1017 build and the third_party submodule goes with it.
@MohsenBg
MohsenBg merged commit 5cadb76 into main Oct 21, 2026
5 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