48 Commits

Author SHA1 Message Date
530bf24abf fix(market): populate combined address list so portfolio sees addresses
The Market-tab portfolio (editor checklist + SumPortfolioBalance) reads the
combined WalletState::addresses view, but the full-node refresh path only ever
updated the authoritative z_addresses/t_addresses lists — rebuildAddressList()
was never called, so `addresses` stayed empty. Result: the manage-portfolio
modal showed no addresses to pick, and addresses added via the overview
right-click menu contributed no value to their entry (SumPortfolioBalance
found nothing in the empty combined list).

Rebuild the combined view in NetworkRefreshService::applyAddressRefreshResult
(the sole bulk updater of the address lists), and push newly created addresses
into the combined view immediately in the full-node create paths for
zero-latency parity with the overview (matching the lite branch). This also
repairs two other silently-broken full-node consumers of state.addresses: the
auto-shield target-address finder and the pool-mining transparent fallback.

Add a regression test (testWalletStateAddressListRebuild) covering the
empty-before / union-after rebuild and pending-send delta reflection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 03:27:44 -05:00
e3b53258b7 fix(settings): swap Install bundled/Test connection; unique daemon Refresh ID
Swap the "Install bundled" and "Test connection" daemon-binary buttons:
Install bundled now sits in the left common row (Check for updates | Refresh
| Install bundled); Test connection leads the right-aligned maintenance
group. Each button keeps its own disabled-predicates, action, and tooltip.

Give the daemon-binary Refresh button a unique ImGui ID
("Refresh##daemonRefresh") so it no longer collides with the theme-section
Refresh buttons, which triggered Dear ImGui's "2 visible items with
conflicting ID!" error. The label still displays "Refresh".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 03:09:43 -05:00
f9e4db4abf feat(settings): merge BACKUP & DATA into the WALLET card
Move the Backup & Data button row (Import/Export key, Export all, Backup, Export
CSV, and the full-node Download Bootstrap / Setup Wizard) into the WALLET card as
a "Backup & Data" sub-section under the privacy toggles + Tools & Actions, and
delete the standalone BACKUP & DATA card (its own glass panel + header). One
fewer top-level card; all actions preserved.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:59:31 -05:00
bdccddfeee feat(settings): rename RPC toggle to "RPC Connection..." (Tools & Actions style)
Rename the RPC collapsible header from "RPC CONNECTION" to "RPC Connection..." and
render it in the body2 font so it matches the "Tools & Actions..." toggle exactly.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:53:08 -05:00
d68bc27aac feat(settings): make RPC CONNECTION a collapsible toggle (like Tools & Actions)
Replace the static "RPC CONNECTION" overline header with a full-width clickable
toggle (label + expand/collapse arrow, transparent button with hover) that
reveals the Host/Port/Username/Password fields + auto-detected caption only when
expanded — same idiom as the "Tools & Actions…" section. Collapsed by default
(rpc_expanded). Field rendering/bindings unchanged.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:49:19 -05:00
51667b5a27 feat(settings): add "Open app folder" button next to "Open data folder"
Add a button to the left of "Open data folder" that opens the ObsidianDragon
config folder (util::Platform::getObsidianDragonDir() — ~/.config/ObsidianDragon
etc.: settings, themes, logs), distinct from the daemon data/blockchain folder.
The two buttons render as a right-aligned group on the data-dir row, each an
explicit button-font width so the group's right edge lands exactly on the card
edge (no overflow). New i18n: settings_open_app_dir / tt_open_app_dir.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:44:43 -05:00
96e05b4ba6 feat(settings): daemon maintenance actions right-aligned on Test row; fix Open-folder overflow
- Remove the "Advanced" toggle. The four maintenance actions (Install bundled /
  Rescan / Delete blockchain / Repair wallet) now render right-aligned on the same
  row as Check-for-updates / Refresh / Test connection (wrapping to a right-aligned
  next row only when the window is too narrow). Predicates preserved.
- Fix the Open-data-folder button overflowing the card's right edge (worse at high
  display scaling): its width was estimated with the CURRENT font, not the button
  font, so the estimate was wrong. Measure with the button font and give the button
  an explicit width so its right edge lands exactly on the card edge regardless of
  DPI/font-scale timing.
- Drop the now-unused node_advanced_expanded flag.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 02:26:58 -05:00
36386b2b84 fix(settings): keep "Open data folder" on the data-dir row
The right-aligned Open-data-folder button captured its Y from
GetCursorScreenPos() after the wallet-size text, by which point ImGui had already
advanced to the next line — so the button dropped below the data-dir row. Capture
the row's top Y before rendering the row and pin the button to it, so Data Dir /
Wallet Size / Open data folder sit on one line.

Full-node build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:56:29 -05:00
2fc577fb18 feat(settings): SECURITY on one fill-width row + Advanced daemon actions shown by default
- SECURITY: the auto-lock + PIN controls now continue on the same row as the
  encrypt/change/lock/remove controls (full-node), so the whole security band
  fills the card width instead of leaving the right half blank. Lite (no encrypt
  row) still starts auto-lock/PIN on a fresh row at the left edge.
- Daemon Binary "Advanced" (Install bundled / Rescan / Delete blockchain / Repair
  wallet) now defaults to expanded (node_advanced_expanded = true) so the actions
  are visible without a click; the toggle can still collapse them.

Full-node + lite build clean; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:43:46 -05:00
5180f74a68 feat(settings): rebuild Node & Security as one fill-width card
Replace the column/grid/masonry machinery (which kept leaving empty gaps and
buried the daemon Advanced actions) with a single full-width card whose controls
are arranged horizontally to use the width:
- NODE: Data Dir + Wallet Size + Open-folder on one row.
- RPC: Host / Port / Username / Password four-across when wide (>=700), wrapping
  to 2x2 when narrow; auto-detected + plaintext warning kept.
- SECURITY: encryption buttons + Auto-lock combo + PIN controls on horizontal
  rows (factored into renderSecuritySection so lite gets auto-lock+PIN and
  full-node adds the RPC-backed encrypt/lock/remove block — same gating as before).
- DAEMON BINARY: inline Installed/Bundled/status; Check/Refresh/Test row; and the
  four maintenance actions (Install bundled / Rescan / Delete blockchain / Repair)
  behind a now-prominent, clearly-clickable "Advanced ▾" toggle instead of the
  easy-to-miss faint overline — fixing the "daemon options missing" report.

Lite keeps its existing wallet-lifecycle/backup/security block, full width.
Removed useGrid/stacked/column clips/dividers. Every control + state branch +
confirm flag + tooltip preserved (verified). Full-node + lite build clean; ctest
green; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 01:28:14 -05:00
fc5ce019e2 feat(settings): 2x2 grid for Node & Security (fills the card width)
Single-column left every row hugging the left with a blank right half (and a
split RPC row); two-column variants left a bottom-corner gap. Rework the card as
a 2x2 grid on wide full-node windows so both halves carry real content:
  TOP-LEFT NODE/Data | TOP-RIGHT RPC
  BOTTOM-LEFT SECURITY | BOTTOM-RIGHT DAEMON BINARY
with the two top cells sharing a row bottom, a thin vertical + horizontal
divider, and the Daemon "Advanced" destructive buttons dropping to a full-width
4-button strip below the grid when expanded.

Gated behind one `useGrid` flag (supportsFullNodeLifecycleActions() &&
availWidth >= node-grid-breakpoint, default 900). When false — narrow windows and
lite — it falls through to the existing single full-width column, unchanged
(one-flag-revertible). Wallet Size moved to its own row (removes a mid-row gap in
both layouts). RPC / Security / Daemon rebased to per-cell X/width via aliases;
Advanced buttons factored into one lambda so the disabled predicates live in a
single place. Design produced + adversarially critiqued via a multi-agent pass.

New: components.settings-page.node-grid-breakpoint (ui.toml), "rpc_connection"
i18n string. Full-node + lite build clean; ctest green; hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:55:52 -05:00
7826be4b13 feat(settings): single full-width Node & Security column (no empty gap)
Two-column layouts left an unbalanced empty gap for this content (Node/RPC vs
Security+Daemon can't be evened into two columns), so drop the split entirely:
Node -> RPC -> Security -> Daemon Binary now stack vertically at full card width.
This guarantees no horizontal gap and gives the RPC grid and the Daemon Binary
button rows the full width they want.

Implemented by forcing the existing vertical-flow path (`stacked`) always on, so
the section bodies are unchanged; removes the now-unused column-split math and the
node-sec-stack-width schema key.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:20:30 -05:00
5d6e78c719 feat(settings): masonry Node & Security — fill the empty bottom-right
The 60/40 split left the right (Security) column short while Node/RPC on the left
was tall, and Daemon Binary sat full-width below — leaving a large empty gap in
the card's bottom-right.

Rework it as a greedy two-column masonry: even 50/50 columns, and the Daemon
Binary section is placed into whichever column is currently shorter (clipped to
that column's width). In practice Node/RPC fills the left and Security + Daemon
Binary stack on the right, so the gap is filled and the two sides balance in
height. When the window is narrow the columns stack and Daemon Binary spans full
width below, as before. Daemon Binary now sizes to its column (dbColW) instead of
the full card width.

Full-node + lite build clean; source hygiene clean. Visual QA needed (column
balance + the daemon button rows in the narrower column).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 00:08:32 -05:00
a1e4acfe86 feat(settings): fix Node & Security empty space — 70/30 split + Advanced in right column
The fixed 60/40 split left a large empty gap in the bottom-right: the SECURITY
column is short while NODE (data dir + RPC grid) is tall, and the DAEMON BINARY
row spans full width below both. Two changes:

- Widen the side-by-side split to 70/30 so NODE (the content-heavy column, esp.
  the RPC grid) gets the room and the right-side gap shrinks.
- Move the "Advanced" destructive-actions expander out of the full-width DAEMON
  BINARY row and into the right column below Security, filling the gap. Its four
  actions now stack vertically to fit the narrow column.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:48:45 -05:00
419f8bb9cb fix(console): fill scanline banding into the empty space below the last row
The zebra banding is emitted per text row, so it stopped at the last line and
left the bottom fade-padding (up to 18% of the panel) unbanded — very visible
when scrolled/pinned to the bottom. After the text-row bands, continue the
alternating pattern (same parity, lineHeight pitch) down to the panel bottom so
the zebra fills the whole output area. Also covers the case where the content is
shorter than the panel.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:35:01 -05:00
c5ef9e42ed fix(console): correct CRT scanline alternation (was skipping rows)
The per-line scanline shade alternated on rowIndex = floor((cumulativeY +
yOffset) / lineHeight + 0.5), but cumulativeY includes the 2px inter-line gap
while the divisor is lineHeight alone. That drifts ~0.14 row per line and, with
the rounding, jumps the index by 2 every ~7 rows — so two adjacent rows get the
same shade and the alternation visibly breaks in patches.

Replace it with a true per-visual-row counter (one increment per drawn sub-row),
seeded with the first on-screen line's sub-row ordinal so the shading stays
stable while scrolling. Parity now alternates exactly every row.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 23:25:09 -05:00
f0243307f1 feat(portfolio): Overview right-click to add/remove an address to a portfolio
Add a "Portfolio" submenu to the Overview address-list context menu: it lists
each Market-tab portfolio entry with a checkmark showing whether this address is
in it, and clicking toggles membership (add/remove), persisting immediately. If
no entries exist yet, a disabled hint points to creating one.

Full-node + lite build clean; ctest green; source hygiene clean. Completes the
Market configurable-portfolio item (last of the todo.md batch).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:44:04 -05:00
95ddfa05ee feat(portfolio): Market card entries + "Manage" editor dialog
Build the configurable-portfolio UI on the persisted model:
- The portfolio card keeps the "all funds" summary and now renders each custom
  entry below it as a compact row (label · summed DRGX · USD), live-computed via
  SumPortfolioBalance over the wallet's per-address balances; the card grows to
  fit the entries.
- A right-aligned "Manage…" button on the card header opens a modal editor
  (material overlay dialog): list entries with Edit/Delete, "Add entry", and an
  edit form = label field + quick "All shielded / All transparent / Clear"
  selectors + a checklist of wallet addresses (with balances). Persists to
  Settings on each mutation.
- i18n English defaults for the new portfolio_* strings.
- Test namespace fix (AddressInfo is dragonx::, not dragonx::data::).

Full-node + lite build clean; ctest green; source hygiene clean. Remaining:
the Overview-tab right-click "add/remove address to a label" integration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:41:20 -05:00
ecc0043559 feat(portfolio): persisted portfolio entries + pure sum helpers
Foundation for the Market tab's configurable portfolio (custom labels tied to
groups of addresses):

- Settings gains a PortfolioEntry {label, addresses[]} list, persisted in
  settings.json as a JSON array-of-objects (same mechanism as lite_servers_),
  with get/setPortfolioEntries().
- data/portfolio.h (header-only, ImGui-free): SumPortfolioBalance() sums a
  group's DRGX balance against the live per-address list (unknown addresses
  contribute 0), plus PortfolioEntryContains/Add/Remove group helpers.
- Unit-tested (testPortfolioHelpers): empty/known/unknown-address sums, and the
  add/remove/contains membership semantics.

UI (editor dialog, Market card rework, Overview right-click) comes next.
Full-node + lite build clean; ctest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:32:53 -05:00
3a9ff5f021 feat(settings): make Node & Security columns responsive (stack when narrow)
The NODE + SECURITY split was a fixed 60/40 two-column layout regardless of
width, which cramps the RPC grid and wastes space on narrow windows. Make it
responsive: below a configurable content width (node-sec-stack-width, default
600) the two columns stack into one full-width column — SECURITY flows below
NODE — so each section gets the full width; side-by-side 60/40 is kept when
there's room. Column-geometry-only change (leftX/rightX/widths + the right
column's top Y); the content blocks are untouched. The rejoin already used
max(leftBottom, rightBottom), correct in both modes.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:46:11 -05:00
1faaa54a07 feat(market): merge price + chart into one card; simplify stats
Combine the top two Market cards: the price-summary hero and the chart now share
a single glass panel (one panel spans the price/stats header + the chart drawn
below it; the chart no longer draws its own panel or an inter-card gap).

Simplify the stat strip from three columns to two — drop the niche BTC-price
column, keeping 24h Volume + Market Cap — and remove the "updated Ns ago"
staleness line (data freshness is still shown by the attribution "Updated" line
under the pair buttons).

Full-node + lite build clean; ctest green; source hygiene clean. Completes the
Market "round buttons + combine/simplify top cards" item.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:31:29 -05:00
61f8c54742 feat(market): flatten pair selection into round buttons (no exchange dropdown)
Replace the exchange combo dropdown + the horizontally-scrolling pair chip bar
with a single flat selector: every trading pair across all exchanges is shown as
a round button (pair name + dimmed exchange sublabel) that wraps to multiple
rows. Clicking one selects that exchange+pair, persists it, and refreshes market
data. The CoinGecko attribution + last-updated line moves under the buttons.

Removes the now-unused pair-scroll state (scroll/drag/arrow machinery) and the
pair-bar-height budget input.

Full-node + lite build clean; source hygiene clean. (Second half of the Market
"round buttons + combine cards" item — the hero/chart card merge is next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:20:12 -05:00
48fc9ba8bb fix(settings): clamp About-logo height so it doesn't overlap the buttons
The About card draws its logo deferred in the left column, scaled to the card
height (cardMax.y - bottomPad). Because the buttons row is full-width at the
bottom of the card, the tall logo extended down over it. Clamp the logo to end
just above the buttons row (captured Y minus a small gap) instead of the card
bottom — aspect + reserved-width caps are unchanged.

Full-node + lite build clean; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:06:39 -05:00
2552234610 feat(mining): fetch pool hashrates + miner version at startup
Warm the Mining tab's data during App::init() (both off the UI thread) instead of
only when the user opens the tab or clicks a mine / auto-balance button:
- kick the idempotent one-shot `xmrig --version` detection, and
- kick a background pool-stats refresh over the known pools.

Gated on supportsPoolMining(). The pool-stats snapshot lands on a later frame and
the auto-balance driver's self-throttling still applies, so this is just an
earlier first fetch — hashrates + version are visible as soon as the tab opens.

Full-node + lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 21:01:35 -05:00
ff20a8a44e feat(console): terminal polish — lite/color-coded toggles, dark bg, monospace font
Work through the console items in todo.md:

- Lite filter toggles (#5): replace the single hasLogFilters() bool with a
  ConsoleLogFilterCaps struct so each backend advertises which toggles apply.
  Full node = daemon/errors/rpc-trace/app; lite = errors-only + app (its
  diagnostics ring maps to the App/Error channels; the text filter is always
  shown). Lite previously showed no toggles at all.
- Color-coded toggles (#6): each filter checkbox is tinted with its channel's
  accent color (daemon=blue, errors=red, rpc=secondary, app=teal) via a new
  channelAccentColor() that also drives the output's left accent bar — one source
  of truth for channel color.
- Darker terminal look (#7): drop the light glass rectangle on the input; both
  output and input now get a terminal-dark overlay (tabs.console.bg-darken-alpha,
  default 110) and the input field blends into it (transparent frame bg).
- Monospace font (#8): bundle Ubuntu Mono (res/fonts/UbuntuMono-R.ttf, Ubuntu
  Font License — same family as the existing Ubuntu fonts) via INCBIN, load it at
  caption size as Type().mono(), and render console output + input in it so
  pretty-printed JSON columns and terminal text align. Falls back to the
  proportional caption font if unavailable.

Full-node + lite build clean; ctest green; source hygiene clean; sandboxed
startup smoke confirms the mono font loads + atlas builds without crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:53:08 -05:00
245d9bf976 feat(console): collapsible JSON in command output
Add fold/unfold of JSON objects and arrays in console command results, built on
the refactored channel + model foundation.

- ComputeConsoleFoldSpans() (console_input_model): pure brace/bracket matcher
  over a block of pretty-printed JSON lines; each opener line gets the offset to
  its matching closer at the same indentation (empty/one-line blocks are not
  foldable). Unit tested (nested objects, arrays, empty blocks, non-JSON, and a
  string value that merely contains a brace).
- ConsoleModelLine gains foldSpan (relative, so it survives front-eviction by the
  line cap) + a collapsed flag; ingest() carries the span and toggleCollapsed()
  flips openers (main thread). addFormattedResult computes the block's spans and
  ingests them atomically.
- computeVisibleLines() skips a collapsed block's interior (opener stays, its
  foldSpan lines through the closer are hidden); folding is bypassed while a
  filter is active so every match stays reachable.
- drawVisibleLines() draws a fold triangle in the free left gutter of opener
  lines (result/JSON channels carry no accent bar there), toggled by a click in
  the gutter cell, and appends a dim " ... }" / " ... ]" summary on collapsed
  openers.

Full-node + lite build clean; ctest green (incl. fold-span + model-fold tests);
source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 20:11:44 -05:00
1c248d727a refactor(console): phase 5b — decompose toolbar/input/commands-popup methods
Finish the thin-view pass on the remaining large render methods (pure code
motion, no behavior change):

- renderToolbar (225 -> 99 lines): extracted drawToolbarStatus(), the
  drawLogFilterToggles() checkbox group, drawFilterInput(), and drawZoomControls().
- renderInput: factored the command echo + built-in interception + submit into
  submitConsoleCommand(exec, cmd) -> bool, leaving renderInput to own just the
  glass panel + InputText + completion/history callback.
- renderCommandsPopup (194 -> 135 lines): removed the duplicated lowercase-and-
  find filter logic (match-count loop and per-row filter now share
  consoleCommandMatchesFilter()) and extracted the [optional]-param dimming loop
  into drawConsoleCommandParams().

Full-node + lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:25:12 -05:00
57291ef28c refactor(console): phase 5 — decompose renderOutput into focused sub-steps
renderOutput() was a ~410-line method doing filter-building, mouse/keyboard
interaction, the per-line draw loop, the new-output pill, and the right-click
context menu all inline. Split it into a thin orchestrator that calls five
single-purpose private methods (pure code motion, no behavior change):

  - computeVisibleLines()      -> the filter -> visible_indices_ + match count
  - handleOutputInteraction()  -> wheel-up, selection drag lifecycle, Ctrl+C/A
  - drawVisibleLines()         -> accent bars, JSON indent guides, selection +
                                  filter highlight, scanline capture, text
  - drawOutputContextMenu()    -> the right-click menu
  - drawNewOutputIndicator()   -> the jump-to-bottom pill

Ordering and every operation are preserved exactly (interaction still runs
after layout build and before the draw; selection bounds are recomputed post-
interaction; scanline_rows_ cleared before the draw pushes to it). Full-node +
lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 19:16:36 -05:00
18c1de87db refactor(console): phase 4b — extract ConsoleScrollController
Pull the auto-scroll concern out of ConsoleTab into a focused, ImGui-free
ConsoleScrollController (console_scroll_controller.{h,cpp}). It owns the three
coupled fields that were loose on the god-class — auto_scroll_, the wheel-up
cooldown, and the new-lines-while-scrolled-up backlog count — and the state
machine tying them together: wheel-up detaches + starts a cooldown, the cooldown
gates the at-bottom re-enable check, backlog counting happens only while
detached, and re-pinning (checkbox / jump pill / reaching the bottom) clears it.

The view keeps only what needs ImGui: it measures scroll position, hands the
controller a plain `atBottom` bool + frame delta, and issues the actual
SetScrollHereY. ConsoleTab loses three more members and all the scattered
auto-scroll bookkeeping collapses to scroll_.* calls.

Adds testConsoleScrollController: pinned start ignores backlog, wheel-up detach +
cooldown gating, re-enable only when at bottom, checkbox toggle semantics, and
the jump-to-bottom pill. Full-node + lite build clean; ctest green; source
hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:52:15 -05:00
0cdbbd024e refactor(console): phase 4 — extract ConsoleSelectionController from the god-class
Pull the text-selection concern out of ConsoleTab into a focused, ImGui-free
ConsoleSelectionController (console_selection_controller.{h,cpp}). It owns the
anchor/caret state and all the fiddly logic around it: the mouse-drag lifecycle
(beginDrag/updateDrag/endDrag), select-all, range ordering, text extraction over
the visible model, and the index shift applied when the buffer cap evicts lines
from the top.

ConsoleTab now delegates: the four scattered selection fields (is_selecting_,
has_selection_, sel_anchor_, sel_end_) and five helper methods (selectionStart/
End, isPosBeforeOrEqual, getSelectedText, clearSelection) collapse to a single
selection_ member plus a thin selectedText() wrapper. screenToTextPos (still
view-side — it needs the frame's layout) now returns the shared ConsoleTextPos,
so ConsoleTab::TextPos is retired.

The byte-offset / eviction math was previously only reachable through the live
ImGui renderer; it now has direct unit coverage (testConsoleSelectionController:
ordering, drag lifecycle, upward drag, select-all, partial/full/no-op eviction
shift, and filtered extraction). Full-node + lite build clean; ctest green;
source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:46:06 -05:00
779c9682d4 refactor(console): phase 3 — thread-safe ConsoleModel ingest queue; fix data race
Introduce ConsoleModel (console_model.{h,cpp}): an ImGui-free, thread-safe line
store. Producers on any thread call ingest(), which only briefly locks a small
pending queue; the main thread calls drain() once per frame to move pending
lines into the visible deque and enforce the line cap, reporting how many were
added and evicted from the front.

This fixes the real data race. Previously a single lines_mutex_ guarded both the
line container AND the UI-only interaction state (selection, scroll, auto-scroll
counters), and it was held across the whole render pass — yet render() touched
auto_scroll_ / new_lines_since_scroll_ outside that lock, racing the background
RPC-trace and app-logger producers that call addLine() on worker threads.

Now:
- addLine() only ingests (lock-free w.r.t. the model); safe from any thread.
- render() drains once per frame, then does the cap/selection/scroll bookkeeping
  on the main thread (new drainModel()).
- The visible deque + all selection/scroll fields are main-thread-only, so every
  lines_mutex_ lock is gone — including the one wrapping the entire renderOutput.

Adds testConsoleModel() covering ingest/drain ordering, the line-cap eviction
counts, view-only clear (queued lines survive), and an 8-thread concurrent
ingest/drain stress with no lost lines. Full-node + lite build clean; ctest
green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:35:25 -05:00
33735b1d52 refactor(console): phase 2 — channel-as-semantic key; decouple executor from UI colors
Make ConsoleChannel the canonical semantic classification of every console
line. Producers (command executor, app log forwarders, result formatter) tag
each line with a channel; the UI derives both the text color and the left
accent-bar color from it at draw time, and the output filter keys off it.

This replaces the prior scheme of storing an ImU32 color per line and then
reverse-engineering the source from color-equality plus a "[daemon] "/"[xmrig] "
/"[app] "/"[rpc] " text prefix. Consequences:

- ConsoleLine now stores {text, ConsoleChannel} — no per-line color.
- addLine()/addRpcTraceLine() take a channel; the redundant text prefixes are
  gone (the accent bar carries the origin).
- The executor's ConsoleAddLineFn hands a channel, not an ImU32, so the backend
  no longer depends on ConsoleTab::COLOR_* — FullNode/Lite executors emit clean
  text + channel.
- The theme-remap loop that rewrote stored colors on light/dark flip is deleted;
  colors are resolved per-channel each frame (refreshColors on flip only).
- ConsoleOutputFilter drops its color fields; consoleLinePassesFilter() keys off
  the channel. Test updated to the channel-based API.
- Added a Warning channel (amber severity peer of Error/Success) so the
  notification + logger warning forwarders keep their color.

Full-node + lite build clean; ctest green; source hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 18:22:35 -05:00
19e83eb12a refactor(console): phase 1 — extract pure ConsoleTextLayout (wrap/hit-test/selection)
Lift the console's word-wrap layout, screen->text hit-testing, and selection extraction
out of ConsoleTab into a pure, ImGui-free module (console_text_layout.{h,cpp}). Text
measurement is injected via a ConsoleTextMeasure interface — production uses an
ImFont-backed impl (identical CalcWordWrapPositionA/CalcTextSizeA calls as before), while
tests drive a fixed-width stub.

- BuildConsoleLayout: per-line wrap segments + heights + cumulative Y (was inline in
  renderOutput).
- HitTestConsoleLayout: point -> (visibleRow, byte col) (was screenToTextPos).
- ExtractConsoleSelection: selected text across visible lines (was getSelectedText).
- ConsoleTab now holds a single `ConsoleLayout layout_` (replacing 4 mutable members;
  drops the dead cached_wrap_width_) and calls the pure functions.
- New unit test testConsoleTextLayout covers wrapping, multi-row hit-testing, bottom
  clamp, and filtered/unfiltered selection — the biggest previously-untested surface.

No behavior change (algorithm preserved verbatim). Full-node + lite build clean, ctest
green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:58:29 -05:00
f12153cdd0 refactor(console): phase 0 — remove dead code + fix channel/arg bugs
Zero-behavior-change cleanup ahead of the console refactor:
- Delete dead API/members: addCommandResult (unused), handleSelection (declared, no
  def), isAutoScrollEnabled (no callers), and scroll_to_bottom_ (written 8× but never
  read) with all its writes.
- Drop the unused screenToTextPos `line_height` parameter (+ 4 call sites) and the unused
  `segEnd` local — clears the two -Wunused warnings.
- Remove the toolbar filter checkboxes' dead `static bool s_prev_*` change-detectors,
  whose only effect was setting the dead scroll_to_bottom_.
- Fix the console_new_lines format/arg mismatch (format takes one %d; the call passed a
  spurious plural-suffix arg).
- Fix the channel/color incoherence: a "[daemon] error:" line kept red text but got a
  blue daemon bar — the prefix no longer downgrades an error channel.
- Delete 11 orphaned lite_console_* i18n keys left over from the console merge (keep
  lite_console_help_passthrough).

Full-node + lite build clean, ctest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 17:40:38 -05:00
8abc0ec113 feat(console): JSON indent guides on result lines
Draw faint vertical guides per 2-space nesting level on result (non-channel) lines, so
nested RPC JSON is easier to read. Draw-only in the per-line loop (like the channel bar),
so it doesn't touch the text layout or selection.

Note: collapsible JSON nodes were intentionally deferred — folding entangles with the
wrap cache / filter / selection line indices and warrants a runtime-verified pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:43:34 -05:00
5a677f6202 feat(console): right-click "Copy value" for hashes/addresses under the cursor
Right-clicking the output now offers "Copy <value>" when a long alphanumeric token
(txid/blockhash/address, >=16 chars) is under the cursor — resolved via screenToTextPos.
Uses right-click so it never conflicts with the left-drag text selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:40:38 -05:00
efec3a50a1 revert(console): drop the redundant jump-to-bottom pill
renderOutput already draws a clickable "N new lines" jump-to-bottom indicator
(console_tab.cpp ~996), so the pill added earlier duplicated it — and reused the
"console_new_lines" format string incorrectly. Remove the pill and the stray i18n entry;
the existing indicator stands.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:38:05 -05:00
4ed69d4e9f feat(console): filter match highlight + match count
- Highlight the matched substring (case-insensitive) in every visible line with a
  translucent yellow behind the text, drawn per wrap-segment like the selection highlight.
- Show a live match count next to the filter input ("N matches", red when zero).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:34:18 -05:00
e0d6d6bc4d feat(console): left-edge channel accent bar (replaces inline log prefixes)
Each line gets a `channel` inferred in addLine() from its color and any
"[daemon]"/"[xmrig]"/"[app]"/"[rpc]" prefix, which is then stripped. renderOutput draws
a thin colored bar in the existing left margin (padX) per line — node log (blue), mining
(amber), app (teal), rpc (secondary), command (accent), error (red). Drawn in the margin
so it never touches the text layout or selection; filtering is unaffected (it keys off
color, not the prefix).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:30:43 -05:00
e9121fe49e feat(console): jump-to-bottom "N new" pill when scrolled up
Surface new_lines_since_scroll_ (already tracked) as a floating pill at the output
panel's bottom-right whenever the user is scrolled up with unseen output; clicking it
re-enables auto-scroll and snaps to the bottom.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 16:25:34 -05:00
b5001ad4db refactor(console): unify lite + full-node console behind a pluggable executor
The two consoles were separate implementations (the rich full-node ConsoleTab and a
minimal RenderLiteConsoleTab). Introduce ConsoleCommandExecutor to abstract the only
real differences — command execution + log source — so both variants share the one rich
terminal (the lite console now gains selection, zoom, copy, and JSON-colored output).

- console_command_executor.{h,cpp}: interface + FullNodeConsoleExecutor (dragonxd log
  ingestion + RPC command execution, result queue) and LiteConsoleExecutor (lite
  diagnostics ring + backend runConsoleCommand/takeConsoleResult, connection/sync status).
- ConsoleTab::render() now takes a ConsoleCommandExecutor&; log ingestion, command
  submission, and command results flow through it. The RPC command-reference popup and
  the daemon/errors/rpc-trace/app filter toggles are gated on the executor's capabilities;
  the toolbar status dot + a status header come from the executor.
- Safety preserved: `clear`/`cls` is intercepted as a view-only clear in the shared UI so
  it is NEVER forwarded (the lite backend's `clear` wipes tx history); command output is
  not persisted to diagnostics.
- App owns the executor (created per variant) and routes both NavPages to console_tab_;
  lite_console_tab.{h,cpp} deleted.

Needs runtime verification of BOTH consoles (full-node RPC + lite: clear, seed/export
secrecy, sync status, command results).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:59:17 -05:00
1266b6e68e feat(market): auto-populate exchange pairs from CoinGecko (adds OurBit); DRGX dashboard
- Fetch /coins/dragonx-2/tickers once per session (App::refreshExchanges, reusing the
  price-fetch worker path) and build the exchange registry at runtime into
  state.market.exchanges; parseCoinGeckoTickers groups tickers by venue. The Market tab
  sources from this live list and falls back to the compiled-in registry (now including
  OurBit + Nonkyc.io) when offline. Fixes the missing OurBit pair and auto-tracks future
  listings.
- Fix stale selection defaults (TradeOgre / DRGX-BTC -> Nonkyc.io / DRGX/USDT) that never
  matched the registry, and make the attribution generic ("Price data from CoinGecko").
- Reframe the single-asset portfolio card as a DRGX holdings dashboard: relabel to
  "MY DRGX" and show the 24h change on the holdings value (colored by direction).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:25:22 -05:00
57f52b7a6c feat(ui): semibold window title + "Lite" suffix on the lite build
- Draw the brand word "ObsidianDragon" in Ubuntu Medium (the closest weight the
  project ships — no true 600 face) as a stand-in for semibold; append "Lite" in the
  Regular font under DRAGONX_LITE_BUILD so the lite window reads "ObsidianDragonLite".
- Fix a misleading comment: pool mining (and thus auto-balance) is available in both
  builds; only solo mining is full-node-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:13:40 -05:00
a6964621a8 feat(settings): About logo scales to card height; Node&Security Advanced expander; lite gating
- About: draw the logo deferred, scaled to the card's full height (aspect-preserved,
  capped to the reserved width) so there's no empty space below it.
- Node & Security: split the daemon toolbar — Refresh + Test connection stay visible;
  the rare/destructive actions (Install bundled, Rescan, Delete blockchain, Repair
  wallet) move behind a collapsible "Advanced" header (reusing the DEBUG LOGGING idiom).
- Lite gating: hide the DEBUG LOGGING card (dragonxd debug= categories, no daemon in
  lite) and the RPC-backed encrypt/change/lock/remove block (lite has its own encryption
  in the NODE column) behind full-node guards; label the lite left column "WALLET" not
  "NODE". (Auto-lock + PIN remain in both.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:13:40 -05:00
14f5fdc559 fix(overview): mining addresses visible at 0 balance; drag-reorder always applies
- Address list: a mining-flagged address stays visible even at 0 balance when
  "hide zero balances" is on (payout addresses shouldn't vanish).
- Drag-reorder: persist dense sort orders (0..N-1) for the whole visible list on
  drop via App::reorderAddresses, instead of a pairwise swap that no-ops when both
  rows are still at the default un-ordered state. First drag now always takes
  effect, and explicit order keeps overriding the starred/type/balance sort.
- Tests: 0-balance mining row survives hide-zero; ordered non-favorite outranks a
  favorite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 15:02:15 -05:00
1a00ec3359 feat(mining): left pool card (Manual/Auto, metrics, scrollable pool list)
Split the pool-mode hashrate card into a 25%-width left card + 75% chart so the
Manual/Auto toggle and pool metrics no longer reflow the tab:
- left card: Manual|Auto toggle (with tooltips), a Local/Pool/Shares/Uptime
  metric stack, and a fixed-height scrolling pool list — rows click-to-select in
  Manual (reusing the existing save+restart path) and show a "mining here" dot in
  Auto. Fixed height keeps the card constant as pools are added.
- mode-toggle row: keep the suggested-pools dropdown + auto-mode URL disable;
  the toggle and pool list moved into the card.
- mining_controls: show the installed/running miner version ("Current: …")
  before and during mining instead of "none".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:35:33 -05:00
5c490c8d53 feat(mining): auto-balance driver, per-pool algo, live miner version
- app: periodic weighted-random auto-balance of the pool while pool-mining
  (~30-min cadence; restarts the miner only when the pick actually changes),
  plus snapshot + refresh accessors for the UI.
- app_network: resolve the xmrig algo per pool at start (pool.dragonx.cc =
  rx/dragonx, pool.dragonx.is = rx/hush; custom hosts keep the setting).
- xmrig_manager: schema-aware pool-side hashrate readout (fixes a silent 0 for
  Miningcore pools); expose the running miner's version from its HTTP API and a
  cached `xmrig --version` detection so the UI can show it before mining.
- wallet_state: carry the running miner's version.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:35:22 -05:00
d0586e5e75 feat(mining): pool registry, schema-aware stats fetcher, pool-select setting
Data + service layer for supporting multiple mining pools:
- pool_registry: KnownPool table (pool.dragonx.is + pool.dragonx.cc) carrying
  each pool's stratum host, xmrig algo, and stats-API schema; pure helpers
  findKnownPoolByUrl / resolvePoolAlgo / parsePoolHashrate / chooseWeightedPool
  (weighted-random by hashrate with incumbent stickiness).
- pool_stats_service: background, schema-aware hashrate fetcher (libcurl, TLS,
  browser UA for Cloudflare) modelled on XmrigUpdater.
- settings: pool_select_mode (manual | auto_balance), string-serialized.
- i18n strings + unit tests (both stats schemas, algo resolution, weighted pick).

Note: pool.dragonx.cc's stratum host is us.dragonx.cc:3333 — the .cc domain is
only the Cloudflare-proxied web/API host and does not accept stratum on :3333.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 14:35:13 -05:00
53 changed files with 5137 additions and 2359 deletions

View File

@@ -452,14 +452,18 @@ set(APP_SOURCES
src/ui/windows/mining_tab_helpers.cpp
src/ui/windows/peers_tab.cpp
src/ui/windows/network_tab.cpp
src/ui/windows/lite_console_tab.cpp
src/ui/windows/explorer_tab.cpp
src/ui/windows/market_tab.cpp
src/ui/windows/console_tab.cpp
src/ui/windows/console_command_executor.cpp
src/ui/windows/console_command_reference.cpp
src/ui/windows/console_input_model.cpp
src/ui/windows/console_model.cpp
src/ui/windows/console_output_model.cpp
src/ui/windows/console_scroll_controller.cpp
src/ui/windows/console_selection_controller.cpp
src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp
src/ui/windows/settings_window.cpp
src/ui/pages/settings_page.cpp
src/ui/windows/about_dialog.cpp
@@ -500,6 +504,8 @@ set(APP_SOURCES
src/daemon/xmrig_manager.cpp
src/util/bootstrap.cpp
src/util/lite_server_probe.cpp
src/util/pool_registry_core.cpp
src/util/pool_stats_service.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp
@@ -576,9 +582,13 @@ set(APP_HEADERS
src/ui/windows/peers_tab.h
src/ui/windows/explorer_tab.h
src/ui/windows/market_tab.h
src/ui/windows/console_channel.h
src/ui/windows/console_command_reference.h
src/ui/windows/console_input_model.h
src/ui/windows/console_model.h
src/ui/windows/console_output_model.h
src/ui/windows/console_scroll_controller.h
src/ui/windows/console_selection_controller.h
src/ui/windows/console_tab.h
src/ui/windows/console_tab_helpers.h
src/ui/windows/settings_window.h
@@ -679,6 +689,7 @@ set_source_files_properties(
"${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-R.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Light.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/Ubuntu-Medium.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/UbuntuMono-R.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialIcons-Regular.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf;\
${CMAKE_SOURCE_DIR}/res/fonts/NotoSansCJK-Subset.ttf"
@@ -1015,8 +1026,12 @@ if(BUILD_TESTING)
src/ui/windows/balance_address_list.cpp
src/ui/windows/balance_recent_tx.cpp
src/ui/windows/console_input_model.cpp
src/ui/windows/console_model.cpp
src/ui/windows/console_output_model.cpp
src/ui/windows/console_scroll_controller.cpp
src/ui/windows/console_selection_controller.cpp
src/ui/windows/console_tab_helpers.cpp
src/ui/windows/console_text_layout.cpp
src/ui/windows/mining_benchmark.cpp
src/ui/windows/mining_pool_panel.cpp
src/ui/windows/mining_tab_helpers.cpp
@@ -1035,6 +1050,7 @@ if(BUILD_TESTING)
src/util/platform.cpp
src/util/logger.cpp
src/util/lite_server_probe.cpp
src/util/pool_registry_core.cpp
src/util/xmrig_updater.cpp
src/util/xmrig_updater_core.cpp
src/util/daemon_updater.cpp

BIN
res/fonts/UbuntuMono-R.ttf Normal file

Binary file not shown.

View File

@@ -908,6 +908,7 @@ scroll-fade-zone = { size = 24.0 }
[tabs.console]
input-area-padding = 8.0
bg-darken-alpha = { size = 110.0 } # black overlay alpha (0-255) for the terminal-dark output + input
output-line-spacing = 2.0
output = { line-spacing = 2 }
scroll-multiplier = { size = 3.0 }
@@ -1324,10 +1325,13 @@ wallet-btn-padding = { size = 24.0 }
rpc-label-min-width = { size = 70.0 }
rpc-label-width = { size = 85.0 }
security-combo-width = { size = 120.0 }
node-grid-breakpoint = { size = 900.0 }
port-input-min-width = { size = 60.0 }
port-input-width-ratio = { size = 0.4 }
idle-combo-width = { size = 64.0 }
about-logo-size = { size = 64.0 }
# Reserved height basis for the About-card logo; the logo is drawn scaled to the
# card's actual height (aspect-preserved) and capped to this * aspect in width.
about-logo-size = { size = 150.0 }
[components.main-layout]
app-bar-height = { size = 64.0 }

View File

@@ -22,6 +22,7 @@
#include "daemon/embedded_daemon.h"
#include "daemon/lifecycle_adapters.h"
#include "daemon/xmrig_manager.h"
#include "util/pool_registry.h"
#include "ui/windows/main_window.h"
#include "ui/windows/balance_tab.h"
#include "ui/windows/send_tab.h"
@@ -30,7 +31,7 @@
#include "ui/windows/mining_tab.h"
#include "ui/windows/peers_tab.h"
#include "ui/windows/network_tab.h"
#include "ui/windows/lite_console_tab.h"
#include "ui/windows/console_command_executor.h"
#include "ui/windows/explorer_tab.h"
#include "ui/windows/market_tab.h"
#include "ui/windows/settings_window.h"
@@ -109,9 +110,120 @@ namespace dragonx {
using json = nlohmann::json;
App::App() = default;
App::App()
{
// Seed the auto-balance RNG once per run so weighted-random pool selection isn't
// deterministic across launches.
balance_rng_.seed(std::random_device{}());
}
App::~App() = default;
namespace {
// How often auto-balance re-evaluates the pool while active. Long, because switching
// restarts the miner (drops in-flight shares + reconnect); the incumbent stickiness
// in chooseWeightedPool makes actual switches rarer still.
constexpr long long kRebalanceIntervalMs = 30LL * 60LL * 1000LL; // 30 minutes
long long steadyNowMs()
{
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
} // namespace
void App::updatePoolAutoBalance()
{
if (!settings_) return;
if (!supportsPoolMining()) return; // pool mining is available in both builds (solo is full-node only)
if (settings_->getPoolSelectMode() != config::Settings::PoolSelectMode::AutoBalance) return;
if (!settings_->getPoolMode()) return; // only while POOL mode is selected
const long long now = steadyNowMs();
const bool intervalDue = (last_balance_eval_ms_ == 0) ||
(now - last_balance_eval_ms_ >= kRebalanceIntervalMs);
// Kick a background refresh when requested or due (never while one is in flight).
if ((balance_refresh_pending_ || intervalDue) && !pool_stats_service_.busy()) {
balance_refresh_pending_ = false;
last_balance_eval_ms_ = now;
balance_snapshot_seen_ = false;
pool_stats_service_.refresh(util::knownPools());
return; // results land on a later frame
}
// Apply the freshly-completed snapshot exactly once.
if (!pool_stats_service_.busy()) {
const auto snap = pool_stats_service_.snapshot();
if (snap.ready && !balance_snapshot_seen_) {
balance_snapshot_seen_ = true;
applyPoolAutoBalance(snap);
}
}
}
void App::applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap)
{
const auto& pools = util::knownPools();
std::vector<util::PoolHashrate> samples;
samples.reserve(pools.size());
for (const auto& kp : pools) {
const auto it = snap.byId.find(kp.id);
samples.push_back(it != snap.byId.end()
? it->second
: util::PoolHashrate{kp.id, 0.0, false});
}
const util::KnownPool* current = util::findKnownPoolByUrl(settings_->getPoolUrl());
const std::string currentId = current ? current->id : std::string{};
const std::string chosenId = util::chooseWeightedPool(samples, currentId, balance_rng_);
if (chosenId.empty() || chosenId == currentId) return; // keep the current pool
const util::KnownPool* chosen = nullptr;
for (const auto& kp : pools)
if (kp.id == chosenId) { chosen = &kp; break; }
if (!chosen) return;
settings_->setPoolUrl(chosen->stratum);
settings_->save();
ui::Notifications::instance().info(
std::string(TR("mining_auto_balanced_to")) + " " + chosen->label);
// Restart so the new pool (and its per-pool algo) takes effect immediately.
if (xmrig_manager_ && xmrig_manager_->isRunning()) {
const int threads = xmrig_manager_->getRequestedThreads();
stopPoolMining();
startPoolMining(threads);
}
}
std::string App::poolMiningInstalledVersion()
{
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot
return daemon::XmrigManager::installedVersion();
}
// Console backend accessors — prefer the fast-lane RPC connection (so console commands
// don't queue behind the refresh batch), falling back to the main connection.
rpc::RPCClient* App::consoleRpc()
{
return (fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get();
}
rpc::RPCWorker* App::consoleWorker()
{
return (fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get();
}
daemon::EmbeddedDaemon* App::consoleDaemon()
{
return daemon_controller_ ? daemon_controller_->daemon() : nullptr;
}
daemon::XmrigManager* App::consoleXmrig()
{
return xmrig_manager_.get();
}
bool App::sendStopCommandSafely(rpc::RPCClient& client, const char* context)
{
const char* label = context ? context : "App";
@@ -284,37 +396,47 @@ bool App::init()
worker_->start();
network_refresh_.markDue(services::NetworkRefreshService::Timer::Price);
// Forward error/warning notifications to the console tab
// Use ConsoleTab colors so the Errors filter works correctly
// Warm the Mining tab's data at startup (both async / off the UI thread) so pool
// hashrates and the installed miner version are shown as soon as the tab is opened,
// instead of only after the user clicks a mine / auto-balance button.
if (supportsPoolMining()) {
daemon::XmrigManager::startVersionDetection(); // idempotent one-shot `xmrig --version`
pool_stats_service_.refresh(util::knownPools()); // snapshot lands on a later frame
}
// Forward error/warning notifications to the console tab. The channel is the semantic
// key — the Errors filter keys off ConsoleChannel::Error, and the color/accent bar are
// derived from the channel at draw time.
ui::Notifications::instance().setConsoleCallback(
[this](const std::string& msg, bool is_error) {
ImU32 color = is_error ? ui::ConsoleTab::COLOR_ERROR : ui::material::Warning();
console_tab_.addLine(msg, color);
console_tab_.addLine(msg, is_error ? ui::ConsoleChannel::Error
: ui::ConsoleChannel::Warning);
});
// Forward all app-level log messages (DEBUG_LOGF, LOGF, etc.) to the
// console tab so they are visible in the UI, not just in the log file.
util::Logger::instance().setCallback(
[this](const std::string& msg) {
// Classify by content: errors in red, warnings in warning color,
// everything else in the default info color.
ImU32 color = ui::ConsoleTab::COLOR_INFO;
// Classify by content into a severity channel: errors, warnings, else the App
// channel (the routine wallet log stream, controlled by the App messages filter).
ui::ConsoleChannel channel = ui::ConsoleChannel::App;
if (msg.find("[ERROR]") != std::string::npos ||
msg.find("error") != std::string::npos ||
msg.find("Error") != std::string::npos ||
msg.find("failed") != std::string::npos ||
msg.find("Failed") != std::string::npos) {
color = ui::ConsoleTab::COLOR_ERROR;
channel = ui::ConsoleChannel::Error;
} else if (msg.find("[WARN]") != std::string::npos ||
msg.find("warn") != std::string::npos) {
color = ui::material::Warning();
channel = ui::ConsoleChannel::Warning;
}
// Strip trailing newline so console tab lines look clean
// Strip trailing newline so console tab lines look clean. The App channel
// supplies the accent bar; no "[app] " text prefix needed.
std::string trimmed = msg;
while (!trimmed.empty() && (trimmed.back() == '\n' || trimmed.back() == '\r'))
trimmed.pop_back();
if (!trimmed.empty())
console_tab_.addLine("[app] " + trimmed, color);
console_tab_.addLine(trimmed, channel);
});
// Check for first-run wizard — also re-run if blockchain data is missing
@@ -706,6 +828,7 @@ void App::update()
ps.pool_diff = xs.pool_diff;
ps.pool_url = xs.pool_url;
ps.algo = xs.algo;
ps.version = xs.version;
ps.connected = xs.connected;
// Get memory directly from OS (more reliable than API)
double memMB = xmrig_manager_->getMemoryUsageMB();
@@ -723,6 +846,9 @@ void App::update()
state_.pool_mining.xmrig_running = false;
}
// Auto-balance: periodically re-pick the pool by hashrate (self-throttled).
updatePoolAutoBalance();
// Populate solo mining log lines from daemon output
if (daemon_controller_ && daemon_controller_->isRunning()) {
state_.mining.log_lines = daemon_controller_->recentLines(50);
@@ -1381,9 +1507,18 @@ void App::render()
// Apply header text opacity
int a = (int)((float)((textCol >> 24) & 0xFF) * hdrOpacity);
textCol = (textCol & 0x00FFFFFF) | ((ImU32)a << 24);
dl->AddText(titleFont, titleFontSize,
ImVec2(logoX, textY),
textCol, "ObsidianDragon");
// Base brand word in a heavier (Ubuntu Medium) weight as the "semibold" —
// the project ships no true 600 face. On the lite build the "Lite" suffix is
// appended in the Regular title font.
ImFont* baseFont = ui::material::Type().subtitle2(); // Ubuntu Medium
if (!baseFont) baseFont = titleFont;
const char* baseWord = "ObsidianDragon";
dl->AddText(baseFont, titleFontSize, ImVec2(logoX, textY), textCol, baseWord);
#if DRAGONX_LITE_BUILD
float baseW = baseFont->CalcTextSizeA(titleFontSize, FLT_MAX, 0.0f, baseWord).x;
dl->AddText(titleFont, titleFontSize, ImVec2(logoX + baseW, textY), textCol, "Lite");
#endif
}
}
@@ -1708,23 +1843,23 @@ void App::render()
case ui::NavPage::LiteNetwork:
ui::RenderLiteNetworkTab(this);
break;
case ui::NavPage::LiteConsole:
ui::RenderLiteConsoleTab(this);
break;
case ui::NavPage::Explorer:
ui::RenderExplorerTab(this);
break;
case ui::NavPage::Market:
ui::RenderMarketTab(this);
break;
// One console UI for both variants; the executor supplies the backend (full-node
// RPC vs lite). The two NavPages are mutually exclusive per build.
case ui::NavPage::LiteConsole:
case ui::NavPage::Console:
// Use fast-lane worker for console commands to avoid head-of-line
// blocking behind the consolidated refreshData() batch.
// Fall back to main rpc/worker if fast-lane hasn't connected yet.
console_tab_.render(daemon_controller_ ? daemon_controller_->daemon() : nullptr,
(fast_rpc_ && fast_rpc_->isConnected()) ? fast_rpc_.get() : rpc_.get(),
(fast_rpc_ && fast_rpc_->isConnected() && fast_worker_) ? fast_worker_.get() : worker_.get(),
xmrig_manager_.get());
if (!console_exec_) {
if (isLiteBuild())
console_exec_ = std::make_unique<ui::LiteConsoleExecutor>(this);
else
console_exec_ = std::make_unique<ui::FullNodeConsoleExecutor>(this);
}
console_tab_.render(*console_exec_);
break;
case ui::NavPage::Settings:
ui::RenderSettingsPage(this);

View File

@@ -19,6 +19,7 @@
#include "services/wallet_security_controller.h"
#include "services/wallet_security_workflow.h"
#include "util/async_task_manager.h"
#include "util/pool_stats_service.h"
#include "wallet/wallet_capabilities.h"
#include "ui/sidebar.h"
#include "ui/windows/console_tab.h"
@@ -154,6 +155,12 @@ public:
// Accessors for subsystems
rpc::RPCClient* rpc() { return rpc_.get(); }
rpc::RPCWorker* worker() { return worker_.get(); }
// Console backend accessors (fast-lane-preferring, defined in app.cpp where the
// subsystem types are complete) used by the shared console executor.
rpc::RPCClient* consoleRpc();
rpc::RPCWorker* consoleWorker();
daemon::EmbeddedDaemon* consoleDaemon();
daemon::XmrigManager* consoleXmrig();
config::Settings* settings() { return settings_.get(); }
// Lite wallet controller (non-null only in lite builds with a linked backend).
wallet::LiteWalletController* liteWallet() { return lite_wallet_.get(); }
@@ -204,6 +211,17 @@ public:
return xmrig_manager_ && xmrig_manager_->isRunning();
}
// Auto-balance: latest per-pool hashrate snapshot (for the mining tab pool list),
// and a request to refresh it now (Refresh button / switching into Auto mode).
util::PoolStatsService::Snapshot poolStatsSnapshot() const {
return pool_stats_service_.snapshot();
}
void requestPoolBalanceRefresh() { balance_refresh_pending_ = true; }
// Installed miner version (detected from `xmrig --version`, cached; kicks the one-shot
// detection on first call) so the mining tab can show it before mining starts.
std::string poolMiningInstalledVersion();
// Mine-when-idle state query
bool isIdleMiningActive() const { return idle_mining_active_; }
@@ -240,6 +258,10 @@ public:
void setAddressSortOrder(const std::string& addr, int order);
int getNextSortOrder() const;
void swapAddressOrder(const std::string& a, const std::string& b);
// Assign dense sort orders (0..N-1) to the given addresses in the given order and
// persist once. Used by drag-reorder so a drop always takes effect (even from the
// default un-ordered state, where a pairwise swap would be a no-op).
void reorderAddresses(const std::vector<std::string>& orderedAddrs);
bool isMiningAddress(const std::string& addr) const;
void setMiningAddress(const std::string& addr, bool mining);
void invalidateAddressValidationCache();
@@ -269,6 +291,9 @@ public:
void refreshMiningInfo();
void refreshPeerInfo();
void refreshMarketData();
// Fetch the live exchange/pair list from CoinGecko once per session (venues are
// near-static); populates state.market.exchanges. Safe to call every frame.
void refreshExchanges();
/// @brief Per-category refresh intervals, adjusted by active tab
using RefreshIntervals = services::NetworkRefreshService::Intervals;
@@ -455,6 +480,11 @@ private:
void pruneShieldedHistoryScanProgress();
void invalidateShieldedHistoryScanProgress(bool persistCache);
// Auto-balance pool selection: drive the periodic hashrate refresh and apply a
// freshly-completed snapshot (weighted-random pick + optional miner restart).
void updatePoolAutoBalance();
void applyPoolAutoBalance(const util::PoolStatsService::Snapshot& snap);
// Subsystems
std::unique_ptr<rpc::RPCClient> rpc_;
std::unique_ptr<rpc::RPCWorker> worker_;
@@ -488,6 +518,14 @@ private:
bool lite_startup_lock_checked_ = false;
std::unique_ptr<daemon::DaemonController> daemon_controller_;
std::unique_ptr<daemon::XmrigManager> xmrig_manager_;
// Auto-balance runtime state (pool mining, full-node only). The service fetches
// pool hashrates off-thread; the RNG drives the weighted-random pick.
util::PoolStatsService pool_stats_service_;
std::mt19937 balance_rng_;
long long last_balance_eval_ms_ = 0; // steady-clock ms of the last refresh kick
bool balance_refresh_pending_ = false; // UI asked for an immediate refresh
bool balance_snapshot_seen_ = false; // the current in-flight snapshot was applied
bool exchanges_fetch_started_ = false; // once-per-session CoinGecko tickers fetch
util::AsyncTaskManager async_tasks_;
bool pending_antivirus_dialog_ = false; // Show Windows Defender help dialog
@@ -575,8 +613,9 @@ private:
int coin_logo_h_ = 0;
bool coin_logo_loaded_ = false;
// Console tab
// Console tab + its backend executor (full-node RPC or lite backend), created lazily.
ui::ConsoleTab console_tab_;
std::unique_ptr<ui::ConsoleCommandExecutor> console_exec_;
// Pending payment from URI
bool pending_payment_valid_ = false;

View File

@@ -38,6 +38,7 @@
#include "daemon/daemon_controller.h"
#include "daemon/embedded_daemon.h"
#include "daemon/xmrig_manager.h"
#include "util/pool_registry.h"
#include "ui/notifications.h"
#include "default_banlist_embedded.h"
#include "util/amount_format.h"
@@ -1630,6 +1631,51 @@ void App::refreshPrice()
void App::refreshMarketData()
{
refreshPrice();
refreshExchanges();
}
void App::refreshExchanges()
{
if (!settings_ || !settings_->getFetchPrices()) return;
if (!worker_) return;
if (exchanges_fetch_started_) return; // once per session (venues are near-static)
exchanges_fetch_started_ = true;
worker_->post([this]() -> rpc::RPCWorker::MainCb {
std::vector<data::ExchangeInfo> exchanges;
try {
CURL* curl = curl_easy_init();
if (curl) {
std::string body;
const char* url = "https://api.coingecko.com/api/v3/coins/dragonx-2/tickers";
auto write_callback = [](void* contents, size_t size, size_t nmemb, std::string* userp) -> size_t {
size_t total = size * nmemb;
userp->append((char*)contents, total);
return total;
};
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +write_callback);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 12L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DragonX-Wallet/1.0");
CURLcode res = curl_easy_perform(curl);
long http_code = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code);
curl_easy_cleanup(curl);
if (res == CURLE_OK && http_code >= 200 && http_code < 300)
exchanges = data::parseCoinGeckoTickers(body);
}
} catch (...) {}
return [this, exchanges = std::move(exchanges)]() mutable {
if (!exchanges.empty())
state_.market.exchanges = std::move(exchanges);
else
exchanges_fetch_started_ = false; // allow a retry if it failed/empty
};
});
}
// ============================================================================
@@ -1718,7 +1764,9 @@ void App::startPoolMining(int threads)
daemon::XmrigManager::Config cfg;
cfg.pool_url = settings_->getPoolUrl();
cfg.worker_name = settings_->getPoolWorker();
cfg.algo = settings_->getPoolAlgo();
// The algo follows the pool: official pools use their own algo (pool.dragonx.cc
// needs rx/dragonx, pool.dragonx.is rx/hush); custom hosts keep the setting.
cfg.algo = util::resolvePoolAlgo(cfg.pool_url, settings_->getPoolAlgo());
cfg.threads = threads; // Use the same thread selection as solo mining
cfg.tls = settings_->getPoolTls();
cfg.hugepages = settings_->getPoolHugepages();
@@ -1932,6 +1980,7 @@ void App::createNewZAddress(std::function<void(const std::string&)> callback)
info.type = "shielded";
info.balance = 0.0;
state_.z_addresses.push_back(info);
state_.addresses.push_back(info); // keep combined view in sync immediately
address_list_dirty_ = true;
// Also trigger full refresh to get proper balances
addresses_dirty_ = true;
@@ -1979,6 +2028,7 @@ void App::createNewTAddress(std::function<void(const std::string&)> callback)
info.type = "transparent";
info.balance = 0.0;
state_.t_addresses.push_back(info);
state_.addresses.push_back(info); // keep combined view in sync immediately
address_list_dirty_ = true;
// Also trigger full refresh to get proper balances
addresses_dirty_ = true;
@@ -2091,6 +2141,14 @@ void App::swapAddressOrder(const std::string& a, const std::string& b)
}
}
void App::reorderAddresses(const std::vector<std::string>& orderedAddrs)
{
if (!settings_) return;
for (size_t i = 0; i < orderedAddrs.size(); ++i)
settings_->setAddressSortOrder(orderedAddrs[i], static_cast<int>(i));
settings_->save();
}
bool App::isMiningAddress(const std::string& addr) const
{
return settings_ && settings_->isMiningAddress(addr);

View File

@@ -57,6 +57,25 @@ const char* liteServerSelectionPreferenceModeName(
return "sticky";
}
Settings::PoolSelectMode parsePoolSelectMode(const json& value)
{
if (!value.is_string()) return Settings::PoolSelectMode::Manual;
const std::string mode = value.get<std::string>();
if (mode == "auto_balance" || mode == "auto") {
return Settings::PoolSelectMode::AutoBalance;
}
return Settings::PoolSelectMode::Manual;
}
const char* poolSelectModeName(Settings::PoolSelectMode mode)
{
switch (mode) {
case Settings::PoolSelectMode::Manual: return "manual";
case Settings::PoolSelectMode::AutoBalance: return "auto_balance";
}
return "manual";
}
} // namespace
std::string Settings::getDefaultPath()
@@ -257,6 +276,7 @@ bool Settings::load(const std::string& path)
if (j.contains("pool_tls")) pool_tls_ = j["pool_tls"].get<bool>();
if (j.contains("pool_hugepages")) pool_hugepages_ = j["pool_hugepages"].get<bool>();
if (j.contains("pool_mode")) pool_mode_ = j["pool_mode"].get<bool>();
if (j.contains("pool_select_mode")) pool_select_mode_ = parsePoolSelectMode(j["pool_select_mode"]);
if (j.contains("mine_when_idle")) mine_when_idle_ = j["mine_when_idle"].get<bool>();
if (j.contains("xmrig_version")) xmrig_version_ = j["xmrig_version"].get<std::string>();
if (j.contains("mine_idle_delay")) mine_idle_delay_= std::max(30, j["mine_idle_delay"].get<int>());
@@ -274,6 +294,19 @@ bool Settings::load(const std::string& path)
for (const auto& w : j["saved_pool_workers"])
if (w.is_string()) saved_pool_workers_.push_back(w.get<std::string>());
}
if (j.contains("portfolio_entries") && j["portfolio_entries"].is_array()) {
portfolio_entries_.clear();
for (const auto& e : j["portfolio_entries"]) {
if (!e.is_object()) continue;
PortfolioEntry entry;
if (e.contains("label") && e["label"].is_string())
entry.label = e["label"].get<std::string>();
if (e.contains("addresses") && e["addresses"].is_array())
for (const auto& a : e["addresses"])
if (a.is_string()) entry.addresses.push_back(a.get<std::string>());
if (!entry.label.empty()) portfolio_entries_.push_back(std::move(entry));
}
}
if (j.contains("font_scale") && j["font_scale"].is_number())
font_scale_ = std::max(1.0f, std::min(1.5f, j["font_scale"].get<float>()));
if (j.contains("window_width") && j["window_width"].is_number_integer())
@@ -403,6 +436,7 @@ bool Settings::save(const std::string& path)
j["pool_tls"] = pool_tls_;
j["pool_hugepages"] = pool_hugepages_;
j["pool_mode"] = pool_mode_;
j["pool_select_mode"] = poolSelectModeName(pool_select_mode_);
j["mine_when_idle"] = mine_when_idle_;
j["xmrig_version"] = xmrig_version_;
j["mine_idle_delay"]= mine_idle_delay_;
@@ -416,6 +450,14 @@ bool Settings::save(const std::string& path)
j["saved_pool_workers"] = json::array();
for (const auto& w : saved_pool_workers_)
j["saved_pool_workers"].push_back(w);
j["portfolio_entries"] = json::array();
for (const auto& e : portfolio_entries_) {
json entry;
entry["label"] = e.label;
entry["addresses"] = json::array();
for (const auto& a : e.addresses) entry["addresses"].push_back(a);
j["portfolio_entries"].push_back(std::move(entry));
}
j["font_scale"] = font_scale_;
j["settings_version"] = std::string(DRAGONX_VERSION);
if (window_width_ > 0 && window_height_ > 0) {

View File

@@ -60,12 +60,26 @@ public:
Random
};
// Pool selection mode for the mining tab: Manual (user picks the pool) or
// AutoBalance (the wallet spreads miners across the official pools by hashrate).
enum class PoolSelectMode {
Manual,
AutoBalance
};
struct LiteServerPreference {
std::string url;
std::string label;
bool enabled = true;
};
// A user-defined portfolio entry: a custom label tied to a group of wallet addresses.
// The Market tab's portfolio card sums these addresses' balances under the label.
struct PortfolioEntry {
std::string label;
std::vector<std::string> addresses;
};
// Theme
std::string getTheme() const { return theme_; }
void setTheme(const std::string& theme) { theme_ = theme; }
@@ -244,6 +258,10 @@ public:
const std::vector<LiteServerPreference>& getLiteServers() const { return lite_servers_; }
void setLiteServers(const std::vector<LiteServerPreference>& servers) { lite_servers_ = servers; }
// User-defined portfolio entries (Market tab). "All funds" is implicit, not stored here.
const std::vector<PortfolioEntry>& getPortfolioEntries() const { return portfolio_entries_; }
void setPortfolioEntries(const std::vector<PortfolioEntry>& entries) { portfolio_entries_ = entries; }
// Lite servers the user has hidden from the Network tab (kept by URL, shown via a toggle).
const std::set<std::string>& getLiteHiddenServers() const { return lite_hidden_servers_; }
bool isLiteServerHidden(const std::string& url) const { return lite_hidden_servers_.count(url) > 0; }
@@ -305,6 +323,8 @@ public:
void setPoolHugepages(bool v) { pool_hugepages_ = v; }
bool getPoolMode() const { return pool_mode_; }
void setPoolMode(bool v) { pool_mode_ = v; }
PoolSelectMode getPoolSelectMode() const { return pool_select_mode_; }
void setPoolSelectMode(PoolSelectMode v) { pool_select_mode_ = v; }
// Installed DRG-XMRig release tag (for in-app miner update detection); empty if unknown/bundled.
std::string getXmrigVersion() const { return xmrig_version_; }
@@ -423,13 +443,15 @@ private:
};
std::set<std::string> lite_hidden_servers_; // server URLs hidden from the Network tab
std::vector<PortfolioEntry> portfolio_entries_; // Market tab custom portfolio groups
bool verbose_logging_ = false;
std::set<std::string> debug_categories_;
bool theme_effects_enabled_ = true;
bool low_spec_mode_ = false;
bool reduce_motion_ = false;
std::string selected_exchange_ = "TradeOgre";
std::string selected_pair_ = "DRGX/BTC";
std::string selected_exchange_ = "Nonkyc.io";
std::string selected_pair_ = "DRGX/USDT";
// Pool mining
std::string pool_url_ = "pool.dragonx.is:3433";
@@ -439,6 +461,7 @@ private:
bool pool_tls_ = false;
bool pool_hugepages_ = true;
bool pool_mode_ = false; // false=solo, true=pool
PoolSelectMode pool_select_mode_ = PoolSelectMode::Manual; // manual vs auto-balance pool choice
std::string xmrig_version_; // installed DRG-XMRig release tag (update detection)
bool mine_when_idle_ = false; // auto-start mining when system idle
int mine_idle_delay_= 120; // seconds of idle before mining starts

View File

@@ -8,6 +8,7 @@
#include "xmrig_manager.h"
#include "../resources/embedded_resources.h"
#include <cctype>
#include <cstdio>
#include <cstdlib>
#include <cstring>
@@ -22,6 +23,7 @@
#include <curl/curl.h>
#include "../util/logger.h"
#include "../util/pool_registry.h"
#ifdef _WIN32
#include <winsock2.h>
@@ -718,6 +720,11 @@ void XmrigManager::fetchStatsHttp() {
std::lock_guard<std::mutex> lk(stats_mutex_);
// Miner version (top-level in /2/summary) — lets the UI show the actually
// running miner's version even when no release tag was persisted (bundled miner).
if (resp.contains("version") && resp["version"].is_string())
stats_.version = resp["version"].get<std::string>();
if (resp.contains("hashrate") && resp["hashrate"].contains("total")) {
auto& total = resp["hashrate"]["total"];
if (total.is_array() && total.size() >= 3) {
@@ -768,10 +775,14 @@ void XmrigManager::fetchStatsHttp() {
void XmrigManager::fetchPoolApiStats() {
if (state_ != State::Running || pool_host_.empty()) return;
// Query the pool's public stats API
std::string url = "https://" + pool_host_ + "/api/stats";
std::string responseData;
// Resolve the stats endpoint + JSON schema for this pool. Known pools carry their
// own API shape (pool.dragonx.is = custom /api/stats; pool.dragonx.cc = Miningcore
// /api/pools); unknown/custom hosts fall back to the .is convention.
const util::KnownPool* known = util::findKnownPoolByUrl(pool_host_);
const std::string url = known ? known->statsUrl
: ("https://" + pool_host_ + "/api/stats");
std::string responseData;
CURL* curl = curl_easy_init();
if (!curl) return;
@@ -782,31 +793,99 @@ void XmrigManager::fetchPoolApiStats() {
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT_MS, 3000L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents.
curl_easy_setopt(curl, CURLOPT_USERAGENT, "Mozilla/5.0 (compatible; ObsidianDragon)");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
CURLcode res = curl_easy_perform(curl);
curl_easy_cleanup(curl);
if (res != CURLE_OK) return;
try {
json resp = json::parse(responseData);
bool ok = false;
const double poolHR = util::parsePoolHashrate(
known ? known->schema : util::PoolStatsSchema::DragonXIs,
responseData, known ? known->miningcorePoolId : std::string{}, ok);
if (!ok) return;
// Pool stats API format: { "pools": { "<name>": { "hashrate": ... } } }
double poolHR = 0;
if (resp.contains("pools") && resp["pools"].is_object()) {
for (auto& [key, pool] : resp["pools"].items()) {
if (pool.contains("hashrate") && pool["hashrate"].is_number()) {
poolHR = pool["hashrate"].get<double>();
break; // Use the first pool entry
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_.pool_hashrate = poolHR;
}
// ============================================================================
// Installed-miner version detection (`<binary> --version`, cached)
// ============================================================================
namespace {
std::mutex g_installed_ver_mutex;
std::string g_installed_ver;
std::atomic<bool> g_ver_detect_started{false};
// Extract the first "D.D[.D...]" version token from `--version` output (skips the
// build date, which uses '-' separators). Returns e.g. "6.21.0", or "" if none.
std::string parseMinerVersion(const std::string& out)
{
for (size_t i = 0; i < out.size(); ++i) {
if (std::isdigit(static_cast<unsigned char>(out[i]))) {
size_t j = i;
int dots = 0;
while (j < out.size() &&
(std::isdigit(static_cast<unsigned char>(out[j])) || out[j] == '.')) {
if (out[j] == '.') ++dots;
++j;
}
if (dots >= 1 && (j - i) >= 3) {
// Include a trailing build suffix like "-hac" / "-drg1" (e.g. "6.25.1-hac"),
// matching what the running miner's API reports.
size_t end = j;
if (end < out.size() && out[end] == '-') {
size_t k = end + 1;
while (k < out.size() && std::isalnum(static_cast<unsigned char>(out[k]))) ++k;
if (k > end + 1) end = k;
}
return out.substr(i, end - i);
}
i = j;
}
}
return {};
}
} // namespace
void XmrigManager::startVersionDetection()
{
if (g_ver_detect_started.exchange(true)) return; // one-shot
std::thread([]() {
const std::string bin = findXmrigBinary();
std::string ver;
if (!bin.empty()) {
const std::string cmd = "\"" + bin + "\" --version 2>&1";
#ifdef _WIN32
FILE* fp = _popen(cmd.c_str(), "r");
#else
FILE* fp = popen(cmd.c_str(), "r");
#endif
if (fp) {
std::string out;
char buf[256];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0) out.append(buf, n);
#ifdef _WIN32
_pclose(fp);
#else
pclose(fp);
#endif
ver = parseMinerVersion(out);
}
}
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
g_installed_ver = ver;
}).detach();
}
std::lock_guard<std::mutex> lk(stats_mutex_);
stats_.pool_hashrate = poolHR;
} catch (...) {
// Malformed response — ignore
}
std::string XmrigManager::installedVersion()
{
std::lock_guard<std::mutex> lk(g_installed_ver_mutex);
return g_installed_ver;
}
} // namespace daemon

View File

@@ -43,6 +43,7 @@ public:
double pool_diff = 0;
std::string pool_url;
std::string algo;
std::string version; // miner version reported by the running xmrig API
bool connected = false;
// Memory usage
int64_t memory_free = 0; // bytes
@@ -137,6 +138,18 @@ public:
*/
static std::string findXmrigBinary();
/**
* @brief Kick a one-shot background `<binary> --version` detection (idempotent).
* Lets the UI show the installed miner's version before mining is ever started.
*/
static void startVersionDetection();
/**
* @brief Cached installed-miner version parsed by startVersionDetection().
* Empty until detection completes (or if no binary/parse failed). Thread-safe.
*/
static std::string installedVersion();
private:
bool generateConfig(const Config& cfg, const std::string& outPath);
bool startProcess(const std::string& xmrigPath, const std::string& cfgPath, int threads);

View File

@@ -4,11 +4,19 @@
#include "exchange_info.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <unordered_map>
namespace dragonx {
namespace data {
const std::vector<ExchangeInfo>& getExchangeRegistry()
{
// Offline seed / fallback. The live list from CoinGecko (parseCoinGeckoTickers)
// supersedes this when available; keep the known venues here so the tab is never
// empty without network.
static const std::vector<ExchangeInfo> registry = {
{
"Nonkyc.io",
@@ -17,9 +25,68 @@ const std::vector<ExchangeInfo>& getExchangeRegistry()
{"DRGX", "USDT", "DRGX/USDT", "https://nonkyc.io/market/DRGX_USDT"},
}
},
{
"OurBit",
"https://www.ourbit.com",
{
{"DRGX", "USDT", "DRGX/USDT", "https://www.ourbit.com/exchange/DRGX_USDT"},
}
},
};
return registry;
}
namespace {
// Extract the scheme://host origin from a URL (for ExchangeInfo.baseUrl).
std::string originOf(const std::string& url)
{
const auto scheme = url.find("://");
if (scheme == std::string::npos) return url;
const auto host = url.find('/', scheme + 3);
return (host == std::string::npos) ? url : url.substr(0, host);
}
} // namespace
std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& body)
{
std::vector<ExchangeInfo> exchanges;
try {
const nlohmann::json j = nlohmann::json::parse(body);
if (!j.contains("tickers") || !j["tickers"].is_array()) return {};
std::unordered_map<std::string, size_t> byName; // exchange name -> index
for (const auto& t : j["tickers"]) {
if (!t.is_object()) continue;
const std::string base = t.value("base", std::string{});
const std::string target = t.value("target", std::string{});
std::string market;
if (t.contains("market") && t["market"].is_object())
market = t["market"].value("name", std::string{});
const std::string tradeUrl = t.value("trade_url", std::string{});
if (base.empty() || target.empty() || market.empty()) continue;
auto it = byName.find(market);
if (it == byName.end()) {
byName[market] = exchanges.size();
exchanges.push_back(ExchangeInfo{market, originOf(tradeUrl), {}});
it = byName.find(market);
}
ExchangePair pair{base, target, base + "/" + target, tradeUrl};
// Skip duplicate pairs on the same exchange.
bool dup = false;
for (const auto& p : exchanges[it->second].pairs)
if (p.displayName == pair.displayName) { dup = true; break; }
if (!dup) exchanges[it->second].pairs.push_back(std::move(pair));
}
} catch (...) {
return {};
}
// Drop exchanges that ended up with no pairs (defensive).
exchanges.erase(std::remove_if(exchanges.begin(), exchanges.end(),
[](const ExchangeInfo& e) { return e.pairs.empty(); }),
exchanges.end());
return exchanges;
}
} // namespace data
} // namespace dragonx

View File

@@ -30,9 +30,19 @@ struct ExchangeInfo {
};
/**
* @brief Returns the static registry of supported exchanges + pairs
* @brief Returns the static registry of supported exchanges + pairs.
* Used as the offline fallback / seed when the live CoinGecko list is unavailable.
*/
const std::vector<ExchangeInfo>& getExchangeRegistry();
/**
* @brief Parse a CoinGecko /coins/{id}/tickers JSON body into the exchange registry.
*
* Groups tickers by exchange (market.name), preserving first-seen order, into
* ExchangeInfo entries (each pair carries base/target/displayName/trade_url). Returns
* an empty vector on parse failure or when no usable tickers are present. Pure (no I/O).
*/
std::vector<ExchangeInfo> parseCoinGeckoTickers(const std::string& json);
} // namespace data
} // namespace dragonx

61
src/data/portfolio.h Normal file
View File

@@ -0,0 +1,61 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Portfolio helpers — pure balance math for the Market tab's configurable portfolio.
// A portfolio entry is a user label tied to a group of wallet addresses (persisted in
// Settings as PortfolioEntry); these helpers sum/query that group against the live
// per-address balance list. No I/O, no ImGui — unit-testable.
#pragma once
#include "wallet_state.h" // AddressInfo
#include <string>
#include <unordered_set>
#include <vector>
namespace dragonx {
namespace data {
// Sum the DRGX balance of `entryAddresses` by looking them up in the wallet's per-address
// list. Addresses not present in the wallet contribute 0 (e.g. removed/rescanned). Pure.
inline double SumPortfolioBalance(const std::vector<std::string>& entryAddresses,
const std::vector<AddressInfo>& walletAddresses)
{
if (entryAddresses.empty()) return 0.0;
std::unordered_set<std::string> want(entryAddresses.begin(), entryAddresses.end());
double sum = 0.0;
for (const auto& a : walletAddresses)
if (want.count(a.address)) sum += a.balance;
return sum;
}
// Whether `address` is part of the entry's address group.
inline bool PortfolioEntryContains(const std::vector<std::string>& entryAddresses,
const std::string& address)
{
for (const auto& a : entryAddresses)
if (a == address) return true;
return false;
}
// Add `address` to the group if absent (returns true if it was added).
inline bool PortfolioEntryAdd(std::vector<std::string>& entryAddresses, const std::string& address)
{
if (address.empty() || PortfolioEntryContains(entryAddresses, address)) return false;
entryAddresses.push_back(address);
return true;
}
// Remove `address` from the group if present (returns true if it was removed).
inline bool PortfolioEntryRemove(std::vector<std::string>& entryAddresses, const std::string& address)
{
for (auto it = entryAddresses.begin(); it != entryAddresses.end(); ++it) {
if (*it == address) { entryAddresses.erase(it); return true; }
}
return false;
}
} // namespace data
} // namespace dragonx

View File

@@ -9,6 +9,8 @@
#include <cstdint>
#include <chrono>
#include "exchange_info.h"
namespace dragonx {
/**
@@ -160,6 +162,10 @@ struct MarketInfo {
// Price history for chart
std::vector<double> price_history;
static constexpr int MAX_HISTORY = 24; // 24 hours
// Exchanges/pairs fetched live from CoinGecko (empty until fetched; the Market tab
// falls back to data::getExchangeRegistry() while empty).
std::vector<data::ExchangeInfo> exchanges;
};
/**
@@ -170,6 +176,7 @@ struct PoolMiningState {
bool xmrig_running = false;
std::string pool_url;
std::string algo;
std::string version; // running miner's version (from its API)
double hashrate_10s = 0;
double hashrate_60s = 0;

View File

@@ -11,6 +11,7 @@
INCBIN(ubuntu_regular, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-R.ttf");
INCBIN(ubuntu_light, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Light.ttf");
INCBIN(ubuntu_medium, "@CMAKE_SOURCE_DIR@/res/fonts/Ubuntu-Medium.ttf");
INCBIN(ubuntu_mono, "@CMAKE_SOURCE_DIR@/res/fonts/UbuntuMono-R.ttf");
INCBIN(material_icons, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialIcons-Regular.ttf");
INCBIN(mdi_pickaxe_subset, "@CMAKE_SOURCE_DIR@/res/fonts/MaterialDesignIcons-Pickaxe-Subset.ttf");
INCBIN(noto_cjk_subset, "@CMAKE_SOURCE_DIR@/res/fonts/NotoSansCJK-Subset.ttf");

View File

@@ -24,6 +24,9 @@ extern "C" {
extern const unsigned char g_ubuntu_medium_data[];
extern const unsigned int g_ubuntu_medium_size;
extern const unsigned char g_ubuntu_mono_data[];
extern const unsigned int g_ubuntu_mono_size;
extern const unsigned char g_material_icons_data[];
extern const unsigned int g_material_icons_size;

View File

@@ -1255,6 +1255,11 @@ void NetworkRefreshService::applyAddressRefreshResult(WalletState& state,
{
state.z_addresses = std::move(result.shieldedAddresses);
state.t_addresses = std::move(result.transparentAddresses);
// Keep the combined `addresses` view in sync with the authoritative z/t lists.
// Consumers that read state.addresses (Market-tab portfolio, auto-shield target
// selection, pool-mining transparent fallback) depend on this being rebuilt — the
// full-node refresh path is the only place that bulk-updates the address lists.
state.rebuildAddressList();
}
void NetworkRefreshService::applyTransactionRefreshResult(WalletState& state,

View File

@@ -184,6 +184,10 @@ bool Typography::load(ImGuiIO& io, float dpiScale)
// ButtonLg: Medium
fonts_[14] = loadFont(io, kWeightMedium, Layout::kFontButtonLg() * scale, "ButtonLg");
// Monospace (Ubuntu Mono) at caption size — used by the console so terminal text +
// pretty-printed JSON columns align. Same size as Caption to keep line heights matched.
mono_ = loadFont(io, kWeightMono, Layout::kFontCaption() * scale, "Mono");
// --- Icon fonts at multiple sizes ---
// These are standalone fonts (not merged) so we can PushFont(iconXxx) for icon rendering.
iconFonts_[0] = loadIconFont(io, 14.0f * scale, "IconSmall");
@@ -236,6 +240,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
fontData = g_ubuntu_medium_data;
fontDataLen = g_ubuntu_medium_size;
break;
case kWeightMono:
fontData = g_ubuntu_mono_data;
fontDataLen = g_ubuntu_mono_size;
break;
case kWeightRegular:
default:
fontData = g_ubuntu_regular_data;
@@ -271,10 +279,10 @@ ImFont* Typography::loadFont(ImGuiIO& io, int weight, float size, const char* na
cfg.GlyphRanges = glyphRanges;
// Create a unique name for this font variant
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx",
weight == kWeightLight ? "Light" :
weight == kWeightMedium ? "Medium" : "Regular",
size);
const char* weightName = weight == kWeightLight ? "Light" :
weight == kWeightMedium ? "Medium" :
weight == kWeightMono ? "Mono" : "Regular";
snprintf(cfg.Name, sizeof(cfg.Name), "Ubuntu %s %.0fpx", weightName, size);
ImFont* font = io.Fonts->AddFontFromMemoryTTF(fontDataCopy, fontDataLen, size, &cfg);

View File

@@ -168,6 +168,10 @@ public:
ImFont* caption() const { return getFont(TypeStyle::Caption); }
ImFont* overline() const { return getFont(TypeStyle::Overline); }
// Monospace (Ubuntu Mono) at caption size — for the console, so JSON/columns align
// like a real terminal. Falls back to the proportional caption font if not loaded.
ImFont* mono() const { return mono_ ? mono_ : caption(); }
// Icon fonts — Material Design Icons merged at specific pixel sizes
// Use these when you need to render an icon at a specific size via AddText/ImGui::Text.
// Common sizes: iconSmall (14px), iconMed (18px), iconLarge (24px).
@@ -253,6 +257,7 @@ private:
static constexpr int kWeightLight = 300;
static constexpr int kWeightRegular = 400;
static constexpr int kWeightMedium = 500;
static constexpr int kWeightMono = 401; // selects Ubuntu Mono (monospace, regular)
bool loaded_ = false;
float dpiScale_ = 1.0f;
@@ -260,6 +265,9 @@ private:
// Fonts for each type style
ImFont* fonts_[15] = {};
// Monospace font (Ubuntu Mono) at caption size — console/terminal text.
ImFont* mono_ = nullptr;
// Icon fonts at different sizes: [0]=small(14), [1]=med(18), [2]=large(24), [3]=xl(40)
ImFont* iconFonts_[4] = {};
ImFont* pickaxeFonts_[4] = {};

File diff suppressed because it is too large Load Diff

View File

@@ -41,7 +41,8 @@ std::vector<AddressListRow> BuildAddressListRows(const std::vector<AddressListIn
if (!input.info) continue;
if (!AddressListMatchesFilter(input, filter)) continue;
if (input.hidden && !showHidden) continue;
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite) continue;
// Keep mining-flagged addresses visible even at 0 balance (they receive payouts).
if (hideZeroBalances && input.info->balance < 1e-9 && !input.hidden && !input.favorite && !input.mining) continue;
rows.push_back({input.info, input.isZ, input.hidden, input.favorite, input.mining,
input.label, input.icon, input.sortOrder});
}

View File

@@ -15,6 +15,7 @@
#include "../../app.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../config/version.h"
#include "../../util/i18n.h"
#include "../../util/text_format.h"
@@ -343,12 +344,20 @@ void RenderSharedAddressList(App* app, float listH, float availW,
for (int i = 0; i < (int)rows.size(); ++i) {
if (mousePos.y > rowY[i] + rowH * 0.5f) insertIdx = i + 1;
}
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1) {
int targetIdx = (insertIdx > s_dragIdx) ? insertIdx - 1 : insertIdx;
if (targetIdx >= 0 && targetIdx < (int)rows.size() && s_dragIdx < (int)rows.size()) {
app->swapAddressOrder(rows[s_dragIdx].info->address,
rows[targetIdx].info->address);
}
if (insertIdx != s_dragIdx && insertIdx != s_dragIdx + 1 &&
s_dragIdx < (int)rows.size()) {
// Build the new visible order (move the dragged row to insertIdx) and
// persist dense sort orders 0..N-1 in one shot. This works even when the
// rows were still at the default order (a pairwise swap would no-op).
std::vector<std::string> newOrder;
newOrder.reserve(rows.size());
for (int i = 0; i < (int)rows.size(); ++i)
if (i != s_dragIdx) newOrder.push_back(rows[i].info->address);
int clamped = std::min(insertIdx, (int)rows.size());
int destIdx = (insertIdx > s_dragIdx) ? clamped - 1 : clamped;
destIdx = std::max(0, std::min(destIdx, (int)newOrder.size()));
newOrder.insert(newOrder.begin() + destIdx, rows[s_dragIdx].info->address);
app->reorderAddresses(newOrder);
}
}
}
@@ -669,6 +678,25 @@ void RenderSharedAddressList(App* app, float listH, float availW,
if (ImGui::MenuItem(TR("favorite_address")))
app->favoriteAddress(addr.address);
}
// Portfolio membership — toggle this address in each Market-tab portfolio entry.
ImGui::Separator();
if (ImGui::BeginMenu(TR("portfolio_add_to"))) {
const auto& pf = app->settings()->getPortfolioEntries();
if (pf.empty()) {
ImGui::MenuItem(TR("portfolio_no_entries"), nullptr, false, false);
}
for (int pi = 0; pi < (int)pf.size(); pi++) {
bool inSet = dragonx::data::PortfolioEntryContains(pf[pi].addresses, addr.address);
if (ImGui::MenuItem(pf[pi].label.c_str(), nullptr, inSet)) {
auto entries = pf; // copy, mutate, persist
if (inSet) dragonx::data::PortfolioEntryRemove(entries[pi].addresses, addr.address);
else dragonx::data::PortfolioEntryAdd(entries[pi].addresses, addr.address);
app->settings()->setPortfolioEntries(entries);
app->settings()->save();
}
}
ImGui::EndMenu();
}
effects::ImGuiAcrylic::EndAcrylicPopup();
}
ImGui::PopID();

View File

@@ -0,0 +1,35 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleChannel — the semantic classification of a console line. It is the canonical
// key: producers (the command executor, app log forwarders, the result formatter) tag
// each line with a channel, the UI derives both the text color and the left accent-bar
// color from it at draw time, and the output filter keys off it. This replaces the older
// approach of storing an ImU32 color and reverse-engineering the source from color
// equality + a "[daemon] " text prefix.
#pragma once
namespace dragonx {
namespace ui {
enum class ConsoleChannel {
None = 0, // plain result / default text
Command, // the "> cmd" echo
Info, // informational / help text
Success, // positive events (connected, accepted, ready)
Warning, // non-fatal warnings (amber)
Error, // errors
Rpc, // RPC trace lines
Daemon, // dragonxd log output
Xmrig, // miner log output
App, // app / wallet / lite-diagnostics log
JsonKey, // result JSON: object key
JsonString, // result JSON: string value
JsonNumber, // result JSON: number / bool / null
JsonBrace, // result JSON: braces / brackets
};
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,313 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "console_command_executor.h"
#include "console_channel.h"
#include "console_input_model.h" // BuildConsoleRpcCall
#include "../../app.h"
#include "../../data/wallet_state.h"
#include "../../rpc/rpc_client.h"
#include "../../rpc/rpc_worker.h"
#include "../../daemon/embedded_daemon.h"
#include "../../daemon/xmrig_manager.h"
#include "../../wallet/lite_wallet_controller.h"
#include "../../wallet/lite_diagnostics.h"
#include "../../util/i18n.h"
#include "../material/colors.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cstdio>
#include <sstream>
namespace dragonx {
namespace ui {
using namespace material;
// ============================================================================
// FullNodeConsoleExecutor
// ============================================================================
bool FullNodeConsoleExecutor::isReady() const
{
rpc::RPCClient* rpc = app_->consoleRpc();
return rpc && rpc->isConnected();
}
void FullNodeConsoleExecutor::submit(const std::string& cmd)
{
rpc::RPCClient* rpc = app_->consoleRpc();
if (!rpc || !rpc->isConnected()) {
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({std::string(TR("console_not_connected")), true});
return;
}
auto call = BuildConsoleRpcCall(cmd);
if (!call.valid) return;
const std::string method = call.method;
const nlohmann::json params = call.params;
rpc::RPCWorker* worker = app_->consoleWorker();
if (worker) {
worker->post([rpc, method, params, this]() -> rpc::RPCWorker::MainCb {
std::string result_str;
bool is_error = false;
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
result_str = rpc->callRaw(method, params);
} catch (const std::exception& e) {
result_str = e.what();
is_error = true;
}
return [this, result_str, is_error]() {
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
};
});
} else {
std::string result_str;
bool is_error = false;
try {
rpc::RPCClient::TraceScope trace("Console tab / User command");
result_str = rpc->callRaw(method, params);
} catch (const std::exception& e) {
result_str = e.what();
is_error = true;
}
std::lock_guard<std::mutex> lk(results_mutex_);
results_.push_back({result_str, is_error});
}
}
bool FullNodeConsoleExecutor::pollResult(std::string& result, bool& isError)
{
std::lock_guard<std::mutex> lk(results_mutex_);
if (results_.empty()) return false;
result = std::move(results_.front().first);
isError = results_.front().second;
results_.pop_front();
return true;
}
void FullNodeConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
{
using DState = daemon::EmbeddedDaemon::State;
daemon::EmbeddedDaemon* d = app_->consoleDaemon();
if (d) {
const DState cur = d->getState();
const int prev = last_daemon_state_;
if (cur == DState::Starting && prev == static_cast<int>(DState::Stopped)) {
add("", ConsoleChannel::None);
add(TR("console_starting_node"), ConsoleChannel::Info);
add(TR("console_capturing_output"), ConsoleChannel::Info);
add("", ConsoleChannel::None);
} else if (cur == DState::Running && prev != static_cast<int>(DState::Running)) {
add(TR("console_daemon_started"), ConsoleChannel::Info);
} else if (cur == DState::Stopped && prev == static_cast<int>(DState::Running)) {
add("", ConsoleChannel::None);
add(TR("console_daemon_stopped"), ConsoleChannel::Info);
} else if (cur == DState::Error) {
add(std::string(TR("console_daemon_error")) + d->getLastError() + " ===", ConsoleChannel::Error);
}
last_daemon_state_ = static_cast<int>(cur);
}
rpc::RPCClient* rpc = app_->consoleRpc();
if (rpc) {
const bool now = rpc->isConnected();
if (now && !last_rpc_connected_) add(TR("console_connected"), ConsoleChannel::Info);
else if (!now && last_rpc_connected_) add(TR("console_disconnected"), ConsoleChannel::Error);
last_rpc_connected_ = now;
}
if (d) {
std::string out = d->getOutputSince(last_daemon_output_size_);
if (!out.empty()) {
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
if (line.empty()) continue;
ConsoleChannel c = ConsoleChannel::Daemon;
if (line.find("[ERROR]") != std::string::npos ||
line.find("error:") != std::string::npos ||
line.find("Error:") != std::string::npos)
c = ConsoleChannel::Error;
add(line, c); // channel conveys the source; no "[daemon] " prefix
}
}
}
daemon::XmrigManager* x = app_->consoleXmrig();
if (x && x->isRunning()) {
std::string out = x->getOutputSince(last_xmrig_output_size_);
if (!out.empty()) {
std::istringstream ss(out);
std::string line;
while (std::getline(ss, line)) {
if (line.empty()) continue;
ConsoleChannel c = ConsoleChannel::Xmrig;
if (line.find("error") != std::string::npos ||
line.find("ERROR") != std::string::npos ||
line.find("failed") != std::string::npos)
c = ConsoleChannel::Error;
else if (line.find("accepted") != std::string::npos)
c = ConsoleChannel::Success;
add(line, c); // channel conveys the source; no "[xmrig] " prefix
}
}
} else {
last_xmrig_output_size_ = 0; // reset so we get fresh output when it restarts
}
}
void FullNodeConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
{
add(TR("console_available_commands"), ConsoleChannel::Info);
add(TR("console_help_clear"), ConsoleChannel::None);
add(TR("console_help_help"), ConsoleChannel::None);
add("", ConsoleChannel::None);
add(TR("console_common_rpc"), ConsoleChannel::Info);
add(TR("console_help_getinfo"), ConsoleChannel::None);
add(TR("console_help_getbalance"), ConsoleChannel::None);
add(TR("console_help_gettotalbalance"), ConsoleChannel::None);
add(TR("console_help_getblockcount"), ConsoleChannel::None);
add(TR("console_help_getpeerinfo"), ConsoleChannel::None);
add(TR("console_help_setgenerate"), ConsoleChannel::None);
add(TR("console_help_getmininginfo"), ConsoleChannel::None);
add(TR("console_help_stop"), ConsoleChannel::None);
add("", ConsoleChannel::None);
add(TR("console_click_commands"), ConsoleChannel::Info);
add(TR("console_tab_completion"), ConsoleChannel::Info);
}
ConsoleStatusLine FullNodeConsoleExecutor::toolbarStatus() const
{
ConsoleStatusLine s;
daemon::EmbeddedDaemon* d = app_->consoleDaemon();
if (!d) return s; // empty text -> toolbar shows the generic "no daemon" label
using DState = daemon::EmbeddedDaemon::State;
switch (d->getState()) {
case DState::Stopped: s.text = TR("console_status_stopped"); s.color = IM_COL32(150,150,150,255); break;
case DState::Starting: s.text = TR("console_status_starting"); s.color = Warning(); s.pulse = true; break;
case DState::Running: s.text = TR("console_status_running"); s.color = Success(); break;
case DState::Stopping: s.text = TR("console_status_stopping"); s.color = Warning(); s.pulse = true; break;
case DState::Error: s.text = TR("console_status_error"); s.color = Error(); break;
}
return s;
}
// ============================================================================
// LiteConsoleExecutor
// ============================================================================
namespace {
// Classify a lite diagnostics line into a channel for at-a-glance scanning.
ConsoleChannel liteLogChannel(const std::string& line)
{
const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; };
if (has("failed") || has(" unreachable") || has("blocked") || has("could not") ||
has("Error") || has("error"))
return ConsoleChannel::Error;
if (has(": connected") || has("opened") || has("wallet ready") || has("Ready"))
return ConsoleChannel::Success;
return ConsoleChannel::App; // lite diagnostics / wallet log
}
} // namespace
bool LiteConsoleExecutor::isReady() const
{
wallet::LiteWalletController* lw = app_->liteWallet();
return lw && lw->walletOpen();
}
bool LiteConsoleExecutor::busy() const
{
wallet::LiteWalletController* lw = app_->liteWallet();
return lw && lw->consoleCommandInProgress();
}
void LiteConsoleExecutor::submit(const std::string& cmd)
{
wallet::LiteWalletController* lw = app_->liteWallet();
if (!lw) return;
lw->runConsoleCommand(cmd); // rejection (busy) surfaces via the busy() indicator
}
bool LiteConsoleExecutor::pollResult(std::string& result, bool& isError)
{
wallet::LiteWalletController* lw = app_->liteWallet();
if (!lw) return false;
wallet::LiteConsoleResult res;
if (!lw->takeConsoleResult(res)) return false;
result = res.response.empty() ? std::string("(no output)") : res.response;
isError = !res.ok;
return true;
}
void LiteConsoleExecutor::pollLogLines(const ConsoleAddLineFn& add)
{
auto& diag = wallet::LiteDiagnostics::instance();
const std::uint64_t gen = diag.generation();
if (gen == diag_gen_) return;
const auto snap = diag.snapshot();
std::size_t startIdx = 0;
if (diag_gen_ != static_cast<std::uint64_t>(-1)) {
const std::uint64_t added = gen - diag_gen_;
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
}
for (std::size_t i = startIdx; i < snap.size(); ++i)
add(snap[i], liteLogChannel(snap[i]));
diag_gen_ = gen;
}
void LiteConsoleExecutor::printHelp(const ConsoleAddLineFn& add)
{
add(TR("console_available_commands"), ConsoleChannel::Info);
add(TR("console_help_clear"), ConsoleChannel::None);
add(TR("console_help_help"), ConsoleChannel::None);
add(TR("lite_console_help_passthrough"), ConsoleChannel::Info);
}
std::vector<ConsoleStatusLine> LiteConsoleExecutor::statusLines() const
{
std::vector<ConsoleStatusLine> out;
wallet::LiteWalletController* lw = app_->liteWallet();
if (lw && lw->walletOpen()) {
const SyncInfo& sync = app_->state().sync;
char buf[96];
if (sync.syncing && !sync.isSynced()) {
double vp = sync.verification_progress;
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
vp * 100.0, sync.blocks, sync.headers);
} else {
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
}
out.push_back({std::string(buf), OnSurfaceMedium(), false});
}
const std::string& err = app_->liteOpenError();
if (!err.empty() && (!lw || !lw->walletOpen()))
out.push_back({std::string("Last error: ") + err, Error(), false});
return out;
}
ConsoleStatusLine LiteConsoleExecutor::toolbarStatus() const
{
ConsoleStatusLine s;
wallet::LiteWalletController* lw = app_->liteWallet();
if (!lw) { s.text = "No backend"; s.color = Error(); return s; }
if (lw->walletOpen()) { s.text = "Connected"; s.color = Success(); }
else if (lw->openInProgress()) { s.text = "Connecting"; s.color = Warning(); s.pulse = true; }
else { s.text = "Disconnected"; s.color = Error(); }
return s;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,128 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleCommandExecutor — the pluggable backend behind the shared ConsoleTab UI. It
// abstracts the only real differences between the full-node console (dragonxd log + RPC
// command execution) and the lite console (lite diagnostics ring + backend command),
// so both variants use one rich terminal widget instead of two implementations.
#pragma once
#include "console_channel.h"
#include "imgui.h"
#include <cstdint>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <utility>
#include <vector>
namespace dragonx {
class App;
namespace ui {
using ConsoleAddLineFn = std::function<void(const std::string&, ConsoleChannel)>;
struct ConsoleStatusLine {
std::string text;
ImU32 color = IM_COL32(200, 200, 200, 255);
bool pulse = false; // animate the toolbar dot (starting/connecting)
};
// Which log-filter toggles the toolbar should show for a given backend. The full node has a
// daemon/xmrig log + an RPC trace; the lite backend has neither (its diagnostics ring maps to
// the App/Error channels), so it only wants the errors-only + app-messages toggles.
struct ConsoleLogFilterCaps {
bool daemon = false; // daemon + xmrig output toggle
bool errorsOnly = false; // errors-only toggle
bool rpcTrace = false; // RPC method/source trace toggle
bool appMessages = false; // app / diagnostics log toggle
bool any() const { return daemon || errorsOnly || rpcTrace || appMessages; }
};
class ConsoleCommandExecutor {
public:
virtual ~ConsoleCommandExecutor() = default;
// True when commands can be run (RPC connected / lite wallet open).
virtual bool isReady() const = 0;
// A command is currently in flight (input is disabled while true).
virtual bool busy() const { return false; }
// Submit a user command for async execution. The UI has already echoed "> cmd" and
// handled the built-ins (clear/help/quit). Results arrive later via pollResult().
virtual void submit(const std::string& cmd) = 0;
// Pop one completed command result (raw text + error flag) if available; the UI
// formats/colors it via FormatConsoleRpcResultLines. Returns false if none pending.
virtual bool pollResult(std::string& result, bool& isError) = 0;
// Pull any new passive log lines (daemon/xmrig output, or the lite diagnostics ring)
// and hand each to `add`. Called once per frame.
virtual void pollLogLines(const ConsoleAddLineFn& add) { (void)add; }
// UI-chrome capabilities.
virtual bool hasRpcReference() const { return false; } // show the RPC command-reference popup
// Which log-filter toggles the toolbar should show (default: none).
virtual ConsoleLogFilterCaps logFilterCaps() const { return {}; }
// Print the backend-appropriate `help` output via `add`.
virtual void printHelp(const ConsoleAddLineFn& add) = 0;
// Extra status lines drawn above the toolbar (lite shows sync + last error).
virtual std::vector<ConsoleStatusLine> statusLines() const { return {}; }
// Short status shown in the toolbar (daemon state / lite connection). Empty text =>
// the toolbar shows its generic "no daemon" label.
virtual ConsoleStatusLine toolbarStatus() const { return {}; }
};
// ── Full-node: dragonxd log ingestion + RPC command execution ────────────────
class FullNodeConsoleExecutor : public ConsoleCommandExecutor {
public:
explicit FullNodeConsoleExecutor(App* app) : app_(app) {}
bool isReady() const override;
void submit(const std::string& cmd) override;
bool pollResult(std::string& result, bool& isError) override;
void pollLogLines(const ConsoleAddLineFn& add) override;
bool hasRpcReference() const override { return true; }
// Full node: daemon/xmrig log, errors-only, RPC trace, and app messages.
ConsoleLogFilterCaps logFilterCaps() const override { return {true, true, true, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
ConsoleStatusLine toolbarStatus() const override;
private:
App* app_;
size_t last_daemon_output_size_ = 0;
size_t last_xmrig_output_size_ = 0;
int last_daemon_state_ = -1; // daemon::EmbeddedDaemon::State as int
bool last_rpc_connected_ = false;
std::deque<std::pair<std::string, bool>> results_; // {text, isError}
std::mutex results_mutex_;
};
// ── Lite: diagnostics ring + backend console command ─────────────────────────
class LiteConsoleExecutor : public ConsoleCommandExecutor {
public:
explicit LiteConsoleExecutor(App* app) : app_(app) {}
bool isReady() const override;
bool busy() const override;
void submit(const std::string& cmd) override;
bool pollResult(std::string& result, bool& isError) override;
void pollLogLines(const ConsoleAddLineFn& add) override;
// Lite: no daemon log / RPC trace — its diagnostics ring maps to the App + Error
// channels, so offer errors-only + app-messages (plus the always-shown text filter).
ConsoleLogFilterCaps logFilterCaps() const override { return {false, true, false, true}; }
void printHelp(const ConsoleAddLineFn& add) override;
std::vector<ConsoleStatusLine> statusLines() const override;
ConsoleStatusLine toolbarStatus() const override;
private:
App* app_;
std::uint64_t diag_gen_ = static_cast<std::uint64_t>(-1); // last consumed diagnostics generation
};
} // namespace ui
} // namespace dragonx

View File

@@ -236,5 +236,42 @@ std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& re
return lines;
}
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines)
{
std::vector<int> spans(lines.size(), 0);
auto leadingSpaces = [](const std::string& s) -> std::size_t {
std::size_t n = 0;
while (n < s.size() && s[n] == ' ') ++n;
return n;
};
auto lastNonSpace = [](const std::string& s) -> char {
for (std::size_t i = s.size(); i-- > 0; )
if (s[i] != ' ' && s[i] != '\t') return s[i];
return '\0';
};
auto firstNonSpace = [](const std::string& s) -> char {
for (char c : s)
if (c != ' ' && c != '\t') return c;
return '\0';
};
for (std::size_t i = 0; i < lines.size(); ++i) {
const char last = lastNonSpace(lines[i]);
if (last != '{' && last != '[') continue; // not a block opener
const std::size_t indent = leadingSpaces(lines[i]);
const char wantClose = (last == '{') ? '}' : ']';
// Matching closer = the next line at the same indentation whose first non-space char
// is the corresponding closing bracket (pretty-printed JSON has aligned brackets).
for (std::size_t j = i + 1; j < lines.size(); ++j) {
if (leadingSpaces(lines[j]) == indent && firstNonSpace(lines[j]) == wantClose) {
if (j - i >= 2) spans[i] = static_cast<int>(j - i); // >=1 inner line
break;
}
}
}
return spans;
}
} // namespace ui
} // namespace dragonx

View File

@@ -50,5 +50,12 @@ ConsoleRpcCall BuildConsoleRpcCall(const std::string& command);
std::vector<ConsoleResultLine> FormatConsoleRpcResultLines(const std::string& result,
bool isError);
// For a block of pretty-printed JSON lines, return a per-line fold span: for a line that
// opens a collapsible block (its last non-space char is '{' or '[') the value is the offset
// to its matching closing-bracket line at the same indentation; 0 for every other line and
// for empty/one-line blocks not worth folding (span < 2). Pure; used to drive console
// JSON folding.
std::vector<int> ComputeConsoleFoldSpans(const std::vector<std::string>& lines);
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,56 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "console_model.h"
#include <utility>
namespace dragonx {
namespace ui {
void ConsoleModel::ingest(const std::string& text, ConsoleChannel channel, int foldSpan)
{
std::lock_guard<std::mutex> lock(ingest_mutex_);
ConsoleModelLine line;
line.text = text;
line.channel = channel;
line.foldSpan = foldSpan;
pending_.push_back(std::move(line));
}
ConsoleModel::DrainResult ConsoleModel::drain()
{
std::vector<ConsoleModelLine> incoming;
{
std::lock_guard<std::mutex> lock(ingest_mutex_);
if (pending_.empty()) return {};
incoming.swap(pending_);
}
DrainResult result;
result.added = incoming.size();
for (auto& line : incoming)
lines_.push_back(std::move(line));
while (lines_.size() > max_lines_) {
lines_.pop_front();
++result.popped;
}
return result;
}
void ConsoleModel::clear()
{
lines_.clear();
}
bool ConsoleModel::toggleCollapsed(std::size_t i)
{
if (i >= lines_.size() || lines_[i].foldSpan <= 0) return false;
lines_[i].collapsed = !lines_[i].collapsed;
return lines_[i].collapsed;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,87 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleModel — the thread-safe line store behind ConsoleTab.
//
// The console is fed from multiple threads: user commands + drained daemon/lite log lines
// arrive on the main (render) thread, but RPC-trace and app-logger lines can be produced on
// background worker threads. Previously a single mutex guarded both the line container AND
// the UI-only interaction state (selection, scroll, auto-scroll counters), and it was held
// across the whole render pass — yet some of that UI state was still touched outside the
// lock in render(), racing the background producers.
//
// ConsoleModel splits the two concerns. Producers on ANY thread call ingest(), which only
// briefly locks a small pending queue. The main thread calls drain() once per frame to move
// pending lines into the visible deque and apply the line cap. The visible deque is then
// only ever read/written on the main thread, so ConsoleTab needs no lock during render and
// its selection/scroll fields become pure main-thread state.
#pragma once
#include "console_channel.h"
#include <cstddef>
#include <deque>
#include <mutex>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
struct ConsoleModelLine {
std::string text;
ConsoleChannel channel = ConsoleChannel::None;
// JSON folding: for a line that opens a collapsible block, `foldSpan` is the offset to
// its matching closing-bracket line (>= 2); 0 for non-openers. `collapsed` is the user's
// fold toggle (main-thread UI state).
int foldSpan = 0;
bool collapsed = false;
};
class ConsoleModel {
public:
static constexpr std::size_t kDefaultMaxLines = 10000;
explicit ConsoleModel(std::size_t maxLines = kDefaultMaxLines) : max_lines_(maxLines) {}
// Append a line from ANY thread. Cheap: locks only the pending queue, does not touch
// the visible deque. The line becomes visible after the next drain(). `foldSpan` marks a
// JSON block opener (offset to its matching closer); 0 for ordinary lines.
void ingest(const std::string& text, ConsoleChannel channel, int foldSpan = 0);
struct DrainResult {
std::size_t added = 0; // lines appended to the visible deque this drain
std::size_t popped = 0; // lines evicted from the front by the line cap
};
// Main thread only. Move pending lines into the visible deque, then enforce the cap.
// Returns how many were added and how many were evicted from the front (so the caller
// can shift any selection indices that referenced the evicted lines).
DrainResult drain();
// Main thread only. Clear the visible deque. Does NOT discard queued pending lines —
// new output still appears — matching the console's view-only `clear` semantics.
void clear();
// Main thread only. Toggle a foldable line's collapsed state (no-op if out of range or
// not a block opener). Returns the new collapsed state.
bool toggleCollapsed(std::size_t i);
// Visible-model access — main thread only (no lock; the deque is main-thread-owned).
const std::deque<ConsoleModelLine>& lines() const { return lines_; }
std::size_t size() const { return lines_.size(); }
bool empty() const { return lines_.empty(); }
const ConsoleModelLine& operator[](std::size_t i) const { return lines_[i]; }
const ConsoleModelLine& back() const { return lines_.back(); }
private:
const std::size_t max_lines_;
std::deque<ConsoleModelLine> lines_; // visible model — main thread only
std::vector<ConsoleModelLine> pending_; // guarded by ingest_mutex_
std::mutex ingest_mutex_;
};
} // namespace ui
} // namespace dragonx

View File

@@ -16,14 +16,14 @@ std::string lowerCopy(std::string value)
}
bool consoleLinePassesFilter(const std::string& lineText,
ImU32 lineColor,
ConsoleChannel channel,
const ConsoleOutputFilter& filter)
{
if (!filter.daemonMessagesEnabled && lineColor == filter.daemonColor) return false;
if (!filter.rpcTraceEnabled && lineColor == filter.rpcTraceColor) return false;
// "[app] ..." lines share COLOR_INFO with other info text, so match them by prefix.
if (!filter.appMessagesEnabled && lineText.rfind("[app] ", 0) == 0) return false;
if (filter.errorsOnly && lineColor != filter.errorColor) return false;
if (!filter.daemonMessagesEnabled &&
(channel == ConsoleChannel::Daemon || channel == ConsoleChannel::Xmrig)) return false;
if (!filter.rpcTraceEnabled && channel == ConsoleChannel::Rpc) return false;
if (!filter.appMessagesEnabled && channel == ConsoleChannel::App) return false;
if (filter.errorsOnly && channel != ConsoleChannel::Error) return false;
if (!filter.text.empty()) {
std::string needle = lowerCopy(filter.text);
std::string haystack = lowerCopy(lineText);

View File

@@ -1,6 +1,6 @@
#pragma once
#include "imgui.h"
#include "console_channel.h"
#include <string>
@@ -9,17 +9,15 @@ namespace ui {
struct ConsoleOutputFilter {
std::string text;
bool daemonMessagesEnabled = true;
bool errorsOnly = false;
bool rpcTraceEnabled = false;
bool appMessagesEnabled = true; // "[app] ..." wallet log lines (matched by prefix, not color)
ImU32 daemonColor = 0;
ImU32 errorColor = 0;
ImU32 rpcTraceColor = 0;
bool daemonMessagesEnabled = true; // Daemon + Xmrig (node log) channels
bool errorsOnly = false; // keep only the Error channel
bool rpcTraceEnabled = false; // Rpc channel
bool appMessagesEnabled = true; // App channel
};
// Whether a line with the given channel + text passes the active filter.
bool consoleLinePassesFilter(const std::string& lineText,
ImU32 lineColor,
ConsoleChannel channel,
const ConsoleOutputFilter& filter);
} // namespace ui

View File

@@ -0,0 +1,39 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "console_scroll_controller.h"
namespace dragonx {
namespace ui {
void ConsoleScrollController::setAutoScroll(bool on)
{
auto_scroll_ = on;
if (on) new_lines_ = 0; // re-pinning to the bottom clears the backlog counter
}
void ConsoleScrollController::onUserScrolledUp()
{
auto_scroll_ = false;
cooldown_ = kScrollUpCooldownSeconds;
}
void ConsoleScrollController::tickCooldown(float dt)
{
if (cooldown_ > 0.0f) cooldown_ -= dt;
}
void ConsoleScrollController::considerReenable(bool atBottom)
{
if (!auto_scroll_ && cooldown_ <= 0.0f && atBottom)
pinToBottom();
}
void ConsoleScrollController::onLinesAdded(int n)
{
if (!auto_scroll_ && n > 0) new_lines_ += n;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,63 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleScrollController — the console's auto-scroll state machine.
//
// It owns three coupled pieces of state that were previously loose fields on the god-class:
// - auto_scroll_ : whether output is pinned to the bottom (follows new lines)
// - a cooldown : a short grace period after a wheel-up so smooth-scroll can animate away
// from the bottom before the "am I at the bottom?" re-enable check runs
// - new_lines_ : how many lines arrived while scrolled up (drives the jump-to-bottom pill)
//
// It is ImGui-free: the view measures scroll positions and hands in a plain `atBottom` bool
// and a frame delta; the controller decides when to re-pin. That keeps the interplay
// (wheel-up pauses, cooldown gates the re-enable, new-line counting only while scrolled up)
// unit testable.
#pragma once
namespace dragonx {
namespace ui {
class ConsoleScrollController {
public:
// Seconds to ignore the at-bottom re-enable check after a user wheel-up, so an in-flight
// smooth-scroll can move away from the bottom first.
static constexpr float kScrollUpCooldownSeconds = 0.5f;
bool autoScroll() const { return auto_scroll_; }
int newLines() const { return new_lines_; }
// User toggled the "auto-scroll" checkbox. Enabling re-pins to the bottom.
void setAutoScroll(bool on);
// User scrolled up with the wheel — stop following output and start the cooldown.
void onUserScrolledUp();
// Advance the cooldown timer by one frame.
void tickCooldown(float dt);
// The view reports whether the viewport is currently at the bottom. If auto-scroll is off
// and the cooldown has elapsed, reaching the bottom re-enables auto-scroll.
void considerReenable(bool atBottom);
// `n` new lines were appended to the model this frame; count them only while scrolled up.
void onLinesAdded(int n);
// Called each frame while pinned to the bottom (the view issues the actual scroll).
void resetNewLines() { new_lines_ = 0; }
// User clicked the jump-to-bottom pill.
void jumpToBottom() { pinToBottom(); }
private:
void pinToBottom() { auto_scroll_ = true; new_lines_ = 0; }
bool auto_scroll_ = true;
float cooldown_ = 0.0f;
int new_lines_ = 0;
};
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,89 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "console_selection_controller.h"
namespace dragonx {
namespace ui {
bool ConsoleSelectionController::isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b)
{
if (a.line < b.line) return true;
if (a.line > b.line) return false;
return a.col <= b.col;
}
void ConsoleSelectionController::beginDrag(ConsoleTextPos pos)
{
anchor_ = pos;
caret_ = pos;
dragging_ = true;
active_ = false; // not a real selection until the caret moves off the anchor
}
void ConsoleSelectionController::updateDrag(ConsoleTextPos pos)
{
caret_ = pos;
// Becomes a real selection once the caret leaves the anchor; never downgrades back.
if (caret_.line != anchor_.line || caret_.col != anchor_.col)
active_ = true;
}
void ConsoleSelectionController::endDrag(ConsoleTextPos pos)
{
caret_ = pos;
dragging_ = false;
}
void ConsoleSelectionController::selectAll(int lineCount, int lastLineByteLen)
{
if (lineCount <= 0) return;
anchor_ = {0, 0};
caret_ = {lineCount - 1, lastLineByteLen};
active_ = true;
}
void ConsoleSelectionController::clear()
{
active_ = false;
dragging_ = false;
anchor_ = {-1, 0};
caret_ = {-1, 0};
}
ConsoleTextPos ConsoleSelectionController::rangeStart() const
{
return isBeforeOrEqual(anchor_, caret_) ? anchor_ : caret_;
}
ConsoleTextPos ConsoleSelectionController::rangeEnd() const
{
return isBeforeOrEqual(anchor_, caret_) ? caret_ : anchor_;
}
void ConsoleSelectionController::shiftForEviction(int popped)
{
if (popped <= 0 || !active_) return;
anchor_.line -= popped;
caret_.line -= popped;
if (anchor_.line < 0 && caret_.line < 0) {
// Entire selection was in the removed range.
active_ = false;
dragging_ = false;
} else {
if (anchor_.line < 0) { anchor_.line = 0; anchor_.col = 0; }
if (caret_.line < 0) { caret_.line = 0; caret_.col = 0; }
}
}
std::string ConsoleSelectionController::extract(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices) const
{
if (!active_) return "";
return ExtractConsoleSelection(lineCount, getLine, visibleIndices,
rangeStart(), rangeEnd());
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,65 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleSelectionController — owns the console's text-selection state (anchor + caret) and
// all the pure logic around it: the mouse-drag lifecycle, select-all, ordering, extraction,
// and the index shift applied when lines are evicted from the top by the buffer cap.
//
// It is deliberately ImGui-free. Screen->text hit-testing stays in the view (it needs the
// current frame's wrapped layout + output origin); the view feeds the resulting
// ConsoleTextPos values in here. That keeps the fiddly byte-offset / eviction math unit
// testable in isolation from the renderer.
#pragma once
#include "console_text_layout.h" // ConsoleTextPos, ConsoleLineAccessor, ExtractConsoleSelection
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
class ConsoleSelectionController {
public:
// Mouse-drag lifecycle. Positions are raw {line, col}: line = index into the full model,
// col = byte offset within that line.
void beginDrag(ConsoleTextPos pos); // button press
void updateDrag(ConsoleTextPos pos); // movement while held
void endDrag(ConsoleTextPos pos); // release
// Select the whole buffer, [0,0] .. [lineCount-1, lastLineByteLen]. No-op if empty.
void selectAll(int lineCount, int lastLineByteLen);
// Drop the selection entirely (also ends any in-progress drag).
void clear();
bool active() const { return active_; } // a selection currently exists
bool dragging() const { return dragging_; } // a drag is in progress
// Ordered bounds, start <= end. Only meaningful while active().
ConsoleTextPos rangeStart() const;
ConsoleTextPos rangeEnd() const;
// After the cap evicts `popped` lines from the front, shift the selection up to track
// the same text. Drops the selection if it fell entirely off the top.
void shiftForEviction(int popped);
// Selected text, or "" when inactive. `visibleIndices` are the raw line indices currently
// shown (the selection spans raw lines but copies only the visible ones).
std::string extract(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices) const;
// Position ordering: is a at or before b in reading order?
static bool isBeforeOrEqual(ConsoleTextPos a, ConsoleTextPos b);
private:
ConsoleTextPos anchor_{-1, 0}; // where the drag began / the selection origin
ConsoleTextPos caret_{-1, 0}; // current drag position / the selection focus
bool active_ = false;
bool dragging_ = false;
};
} // namespace ui
} // namespace dragonx

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,11 @@
#pragma once
#include "console_channel.h"
#include "console_model.h"
#include "console_scroll_controller.h"
#include "console_selection_controller.h"
#include "console_text_layout.h"
#include "../layout.h"
#include "../../daemon/embedded_daemon.h"
#include "../../daemon/xmrig_manager.h"
@@ -18,10 +23,14 @@
namespace dragonx {
namespace ui {
class ConsoleCommandExecutor;
struct ConsoleLogFilterCaps;
/**
* @brief Console tab for daemon output and command input
* @brief Console tab — a rich terminal shared by the full-node and lite variants.
*
* Shows dragonxd output and allows executing RPC commands.
* The command execution + log sources are provided by a ConsoleCommandExecutor, so this
* one widget serves both dragonxd (RPC) and the lite backend.
*/
class ConsoleTab {
public:
@@ -29,12 +38,9 @@ public:
~ConsoleTab();
/**
* @brief Render the console tab
* @param daemon Pointer to embedded daemon (may be null)
* @param rpc Pointer to RPC client for command execution
* @param xmrig Pointer to xmrig manager for pool mining output (may be null)
* @brief Render the console tab against a backend executor.
*/
void render(daemon::EmbeddedDaemon* daemon, rpc::RPCClient* rpc, rpc::RPCWorker* worker, daemon::XmrigManager* xmrig = nullptr);
void render(ConsoleCommandExecutor& exec);
/**
* @brief Render the RPC Command Reference popup at top-level scope.
@@ -45,7 +51,7 @@ public:
/**
* @brief Add a line to the console output
*/
void addLine(const std::string& line, ImU32 color = IM_COL32(200, 200, 200, 255));
void addLine(const std::string& line, ConsoleChannel channel = ConsoleChannel::None);
void addRpcTraceLine(const std::string& source, const std::string& method);
/**
@@ -53,11 +59,6 @@ public:
*/
void clear();
/**
* @brief Check if auto-scroll is enabled
*/
bool isAutoScrollEnabled() const { return auto_scroll_; }
// Scanline effect toggle (set from settings)
static bool s_scanline_enabled;
@@ -88,52 +89,64 @@ public:
static ImU32 COLOR_RPC;
private:
struct ConsoleLine {
std::string text;
ImU32 color;
};
// Text color for a channel, resolved from the current theme (COLOR_* + material). Each
// line stores only its semantic ConsoleChannel (in ConsoleModel); the text color and
// the left accent-bar color are both derived from it at draw time.
ImU32 channelTextColor(ConsoleChannel channel) const;
// Saturated accent color for a channel — the left accent-bar color, also used to tint the
// matching toolbar filter toggle. 0 for channels that get no accent (result/JSON/None).
static ImU32 channelAccentColor(ConsoleChannel channel);
void executeCommand(const std::string& cmd, rpc::RPCClient* rpc, rpc::RPCWorker* worker);
void addCommandResult(const std::string& cmd, const std::string& result, bool is_error = false);
void renderToolbar(daemon::EmbeddedDaemon* daemon);
void renderOutput();
void renderInput(rpc::RPCClient* rpc, rpc::RPCWorker* worker);
// Drain the thread-safe ingest queue into the visible model (once per frame, main
// thread), applying the line cap + selection/scroll bookkeeping for the new lines.
void drainModel();
// Format a completed command result (JSON role -> channel) into console lines.
void addFormattedResult(const std::string& result, bool is_error);
void renderStatusHeader(ConsoleCommandExecutor& exec);
void renderCommandsPopup();
// Selection helpers
void handleSelection();
std::string getSelectedText() const;
void clearSelection();
// renderToolbar() draws the top bar; these are its sub-steps:
void renderToolbar(ConsoleCommandExecutor& exec);
void drawToolbarStatus(ConsoleCommandExecutor& exec); // backend status dot + label
void drawLogFilterToggles(const ConsoleLogFilterCaps& caps); // color-coded filter checkboxes
void drawFilterInput(); // text filter + match count
void drawZoomControls(); // zoom -/+ buttons
// Convert screen position to line/column
struct TextPos {
int line = -1;
int col = 0;
};
TextPos screenToTextPos(ImVec2 screen_pos, float line_height) const;
bool isPosBeforeOrEqual(const TextPos& a, const TextPos& b) const;
TextPos selectionStart() const; // Returns the earlier of sel_anchor_ and sel_end_
TextPos selectionEnd() const; // Returns the later of sel_anchor_ and sel_end_
// renderInput() draws the command line; dispatch is factored out:
void renderInput(ConsoleCommandExecutor& exec);
// Echo + handle a submitted command line (built-ins + submit). True if non-empty/handled.
bool submitConsoleCommand(ConsoleCommandExecutor& exec, const std::string& cmd);
std::deque<ConsoleLine> lines_;
// renderOutput() orchestrates the scrollable output area; these are its sub-steps:
void renderOutput();
void computeVisibleLines(bool& hasTextFilter, std::string& filterLower); // -> visible_indices_
void handleOutputInteraction(ImVec2 mousePos, bool mouseInOutput); // wheel/selection/keys
void drawVisibleLines(float padX, float lineHeight, bool hasTextFilter,
const std::string& filterLower); // bars/guides/highlight/text
void drawNewOutputIndicator(); // jump-to-bottom pill
void drawOutputContextMenu(); // right-click menu
// Map a screen point to a raw {line, col} position via this frame's wrapped layout.
// (View-side because it needs layout_ + output_origin_; feeds the selection controller.)
ConsoleTextPos screenToTextPos(ImVec2 screen_pos) const;
// The current selection's text (via selection_ over the visible model). "" if inactive.
std::string selectedText() const;
// Thread-safe line store: producers on any thread call model_.ingest(); the main
// thread drains it once per frame in render(). The visible deque is main-thread-only.
ConsoleModel model_;
std::vector<std::string> command_history_;
int history_index_ = -1;
char input_buffer_[4096] = {0};
bool auto_scroll_ = true;
bool scroll_to_bottom_ = false;
float scroll_up_cooldown_ = 0.0f; // seconds to wait before re-enabling auto-scroll
int new_lines_since_scroll_ = 0; // new lines while scrolled up (for indicator)
size_t last_daemon_output_size_ = 0;
size_t last_xmrig_output_size_ = 0;
bool shown_startup_message_ = false;
daemon::EmbeddedDaemon::State last_daemon_state_ = daemon::EmbeddedDaemon::State::Stopped;
bool last_rpc_connected_ = false;
// (log-ingestion cursors + result queue moved to the ConsoleCommandExecutor)
// Text selection state
bool is_selecting_ = false;
bool has_selection_ = false;
TextPos sel_anchor_; // Where the mouse was first pressed
TextPos sel_end_; // Where the mouse currently is / was released
// Auto-scroll state machine (pin-to-bottom, wheel-up cooldown, new-line backlog count).
ConsoleScrollController scroll_;
// Text-selection state + logic (anchor/caret, ordering, extraction, eviction shift).
ConsoleSelectionController selection_;
float output_scroll_y_ = 0.0f; // Track scroll position for selection
ImVec2 output_origin_ = {0, 0}; // Top-left of output area
float output_line_height_ = 0.0f; // Text line height (for scanline alignment)
@@ -145,28 +158,16 @@ private:
};
std::vector<ScanlineRow> scanline_rows_;
std::mutex lines_mutex_;
// Output filter
char filter_text_[128] = {0};
mutable int filter_match_count_ = 0; // lines matching the text filter (for the toolbar)
std::string context_token_; // hash/address under the cursor at right-click
mutable std::vector<int> visible_indices_; // Cached for selection mapping
// Wrapped line height caching (for variable-height text wrapping)
mutable std::vector<float> wrapped_heights_; // Height of each visible line (accounts for wrapping)
mutable std::vector<float> cumulative_y_offsets_; // Cumulative Y offset for each visible line
mutable float total_wrapped_height_ = 0.0f; // Total height of all visible lines
mutable float cached_wrap_width_ = 0.0f; // Wrap width used for cached heights
// Sub-row layout: each visible line is split into wrap segments so
// selection and hit-testing know the exact screen position of every
// character.
struct WrapSegment {
int byteStart; // byte offset into ConsoleLine::text
int byteEnd; // byte offset past last char in this segment
float yOffset; // Y offset of this segment relative to the line's top
float height; // visual height of this segment
};
mutable std::vector<std::vector<WrapSegment>> visible_wrap_segments_; // [vi] -> segments
// Wrap layout for the visible lines (segments + per-line heights + cumulative Y),
// recomputed each frame by the pure BuildConsoleLayout (console_text_layout.h) and
// consumed by the renderer + hit-testing.
mutable ConsoleLayout layout_;
// Commands popup
bool show_commands_popup_ = false;

View File

@@ -0,0 +1,162 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure implementation of the console wrap layout / hit-test / selection. No ImGui — the
// algorithm mirrors the former inline code in ConsoleTab exactly, with text measurement
// injected via ConsoleTextMeasure.
#include "console_text_layout.h"
#include <algorithm>
namespace dragonx {
namespace ui {
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
float wrapWidth, float lineHeight, float interLineGap,
const ConsoleTextMeasure& measure)
{
ConsoleLayout L;
if (visibleCount < 0) visibleCount = 0;
L.segments.resize(visibleCount);
L.heights.resize(visibleCount);
L.cumulativeY.resize(visibleCount);
L.totalHeight = 0.0f;
for (int vi = 0; vi < visibleCount; vi++) {
const std::string& text = getVisibleText(vi);
auto& segs = L.segments[vi];
segs.clear();
if (text.empty()) {
segs.push_back({0, 0, 0.0f, lineHeight});
L.cumulativeY[vi] = L.totalHeight;
L.heights[vi] = lineHeight + interLineGap;
L.totalHeight += L.heights[vi];
continue;
}
const char* textStart = text.c_str();
const char* textEnd = textStart + text.size();
const char* cur = textStart;
float segY = 0.0f;
while (cur < textEnd) {
const char* wrapPos = measure.wrapPosition(cur, textEnd, wrapWidth);
if (wrapPos <= cur) wrapPos = cur + 1; // ensure forward progress
if (*cur == '\n') { cur++; continue; } // skip a newline ending the prior seg
int byteStart = static_cast<int>(cur - textStart);
int byteEnd = static_cast<int>(wrapPos - textStart);
if (byteEnd > byteStart && text[byteEnd - 1] == '\n') byteEnd--; // trim trailing '\n'
segs.push_back({byteStart, byteEnd, segY, lineHeight});
segY += lineHeight;
cur = wrapPos;
}
if (segs.empty()) {
segs.push_back({0, 0, 0.0f, lineHeight});
segY = lineHeight;
}
L.cumulativeY[vi] = L.totalHeight;
L.heights[vi] = segY + interLineGap;
L.totalHeight += L.heights[vi];
}
return L;
}
ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount,
const ConsoleLineAccessor& getVisibleText,
float relX, float relY,
const ConsoleTextMeasure& measure)
{
ConsoleHit hit{0, 0};
if (visibleCount <= 0 || layout.cumulativeY.empty()) return hit;
// Binary search for the visible line containing relY.
int visibleLine = 0;
{
int lo = 0, hi = static_cast<int>(layout.cumulativeY.size()) - 1;
while (lo < hi) {
int mid = (lo + hi + 1) / 2;
if (layout.cumulativeY[mid] <= relY) lo = mid;
else hi = mid - 1;
}
visibleLine = lo;
}
if (visibleLine < 0) visibleLine = 0;
if (visibleLine >= visibleCount) {
visibleLine = visibleCount - 1;
hit.visibleRow = visibleLine;
hit.col = static_cast<int>(getVisibleText(visibleLine).size());
return hit;
}
hit.visibleRow = visibleLine;
const std::string& text = getVisibleText(visibleLine);
if (text.empty()) { hit.col = 0; return hit; }
// Which wrap sub-row does relY fall into.
const auto& segs = layout.segments[visibleLine];
float lineRelY = relY - layout.cumulativeY[visibleLine];
int segIdx = 0;
for (int s = 0; s < static_cast<int>(segs.size()); s++)
if (lineRelY >= segs[s].yOffset) segIdx = s;
const auto& seg = segs[segIdx];
if (relX <= 0.0f) { hit.col = seg.byteStart; return hit; }
const char* segStart = text.c_str() + seg.byteStart;
int segLen = seg.byteEnd - seg.byteStart;
hit.col = seg.byteEnd; // default: past the end of the segment
for (int c = 0; c < segLen; c++) {
float wCur = measure.width(segStart, segStart + c + 1);
float wPrev = (c > 0) ? measure.width(segStart, segStart + c) : 0.0f;
float charMid = (wPrev + wCur) * 0.5f;
if (relX < charMid) { hit.col = seg.byteStart + c; return hit; }
}
return hit;
}
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices,
ConsoleTextPos start, ConsoleTextPos end)
{
if (start.line < 0 || start.line >= lineCount) return "";
// Only restrict to visible lines when a filter is active (fewer visible than total).
const bool hasFilter = !visibleIndices.empty() &&
static_cast<int>(visibleIndices.size()) < lineCount;
std::vector<bool> visible;
if (hasFilter) {
visible.assign(lineCount, false);
for (int idx : visibleIndices)
if (idx >= 0 && idx < lineCount) visible[idx] = true;
}
std::string result;
bool firstLine = true;
for (int i = start.line; i <= end.line && i < lineCount; i++) {
if (hasFilter && !visible[i]) continue;
const std::string& text = getLine(i);
int colStart = 0;
int colEnd = static_cast<int>(text.size());
if (i == start.line) colStart = std::min(start.col, static_cast<int>(text.size()));
if (i == end.line) colEnd = std::min(end.col, static_cast<int>(text.size()));
if (!firstLine) result += "\n";
firstLine = false;
if (colStart < colEnd) result += text.substr(colStart, colEnd - colStart);
}
return result;
}
} // namespace ui
} // namespace dragonx

View File

@@ -0,0 +1,72 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// ConsoleTextLayout — pure (ImGui-free) word-wrap layout + hit-testing + selection
// extraction for the console output. Text measurement is injected via ConsoleTextMeasure
// so the production path uses ImFont exactly as before while tests drive it with a
// fixed-width stub. Extracted from ConsoleTab::renderOutput / screenToTextPos /
// getSelectedText so the trickiest console logic (byte-offset selection across wrapped
// rows) becomes unit-testable.
#pragma once
#include <functional>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
// One visual sub-row of a (possibly wrapped) logical line.
struct ConsoleWrapSegment {
int byteStart = 0; // byte offset into the line's text
int byteEnd = 0; // byte offset past the last char on this sub-row
float yOffset = 0; // Y offset of this sub-row relative to the line's top
float height = 0; // visual height of this sub-row
};
// The wrap layout for a set of visible lines.
struct ConsoleLayout {
std::vector<std::vector<ConsoleWrapSegment>> segments; // per visible line
std::vector<float> heights; // per visible line (includes the inter-line gap)
std::vector<float> cumulativeY; // cumulative Y offset of each visible line
float totalHeight = 0.0f;
};
struct ConsoleTextPos { int line = -1; int col = 0; }; // line = raw index; col = byte offset
struct ConsoleHit { int visibleRow = 0; int col = 0; }; // visibleRow = index into the visible set
// Text measurement backend. Production wraps ImFont; tests inject a fixed-width stub.
struct ConsoleTextMeasure {
virtual ~ConsoleTextMeasure() = default;
// Pixel width of the byte range [begin, end).
virtual float width(const char* begin, const char* end) const = 0;
// The byte position at which [begin, end) should wrap for the given width — mirrors
// ImFont::CalcWordWrapPositionA. Returns `end` when the whole range fits.
virtual const char* wrapPosition(const char* begin, const char* end, float wrapWidth) const = 0;
};
// Accessor returning a visible/raw line's text by index (avoids copying the buffer).
using ConsoleLineAccessor = std::function<const std::string&(int)>;
// Build the wrap layout for `visibleCount` lines (text via getVisibleText(0..N-1)).
ConsoleLayout BuildConsoleLayout(int visibleCount, const ConsoleLineAccessor& getVisibleText,
float wrapWidth, float lineHeight, float interLineGap,
const ConsoleTextMeasure& measure);
// Map a point relative to the layout origin to a (visibleRow, byte col). The caller maps
// visibleRow -> a raw line index. Returns {0,0} when there are no visible lines.
ConsoleHit HitTestConsoleLayout(const ConsoleLayout& layout, int visibleCount,
const ConsoleLineAccessor& getVisibleText,
float relX, float relY,
const ConsoleTextMeasure& measure);
// Extract the selected text between raw positions [start, end], restricted to visible
// lines when a filter is active, joining lines with '\n'. Pure.
std::string ExtractConsoleSelection(int lineCount, const ConsoleLineAccessor& getLine,
const std::vector<int>& visibleIndices,
ConsoleTextPos start, ConsoleTextPos end);
} // namespace ui
} // namespace dragonx

View File

@@ -1,274 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "lite_console_tab.h"
#include "console_input_model.h" // reuse the generic command-history helpers
#include "../../app.h"
#include "../../data/wallet_state.h"
#include "../../util/i18n.h"
#include "../../wallet/lite_diagnostics.h"
#include "../../wallet/lite_wallet_controller.h"
#include "../layout.h"
#include "../material/type.h"
#include "../material/colors.h"
#include "imgui.h"
#include <algorithm>
#include <cctype>
#include <cfloat>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
namespace dragonx {
namespace ui {
using namespace material;
namespace {
struct ConsoleLine {
std::string text;
ImU32 color;
};
// Unified, in-memory console buffer: it interleaves the automatic diagnostics log (lifecycle /
// connection / sync events) with the user's interactive commands and their responses, the same
// way the full-node Console interleaves the daemon log with RPC I/O. NOTE: this buffer can hold
// secret command output (e.g. `seed`/`export`) and is NEVER persisted to LiteDiagnostics.
std::vector<ConsoleLine> s_lines;
std::uint64_t s_diagGen = static_cast<std::uint64_t>(-1); // last consumed diagnostics generation
char s_input[512] = "";
std::vector<std::string> s_history;
int s_historyIndex = -1;
bool s_autoScroll = true;
bool s_scrollToBottom = false;
bool s_focusInput = false;
// Colour error/success lines for at-a-glance scanning (substring match on the messages the
// controller emits). Anything else renders in the muted default colour.
ImU32 logColor(const std::string& line)
{
const auto has = [&line](const char* s) { return line.find(s) != std::string::npos; };
if (has("failed") || has(" unreachable") || has("blocked") || has("could not") ||
has("Error") || has("error"))
return Error();
if (has(": connected") || has("opened") || has("wallet ready") || has("Ready"))
return Success();
return OnSurfaceMedium();
}
// Append text as one or more lines (splitting on '\n' so multi-line JSON responses render row by row).
void appendLines(const std::string& text, ImU32 color)
{
std::size_t start = 0;
while (start <= text.size()) {
std::size_t nl = text.find('\n', start);
std::string line = text.substr(start, nl == std::string::npos ? std::string::npos : nl - start);
while (!line.empty() && (line.back() == '\r')) line.pop_back();
s_lines.push_back({std::move(line), color});
if (nl == std::string::npos) break;
start = nl + 1;
}
}
std::string lowerFirstToken(const std::string& cmd)
{
std::size_t b = cmd.find_first_not_of(" \t");
if (b == std::string::npos) return {};
std::size_t e = cmd.find_first_of(" \t", b);
std::string tok = cmd.substr(b, e == std::string::npos ? std::string::npos : e - b);
std::transform(tok.begin(), tok.end(), tok.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
return tok;
}
// InputText history navigation (Up/Down) — reuses the generic console history helpers.
int inputCallback(ImGuiInputTextCallbackData* data)
{
if (data->EventFlag == ImGuiInputTextFlags_CallbackHistory) {
const bool up = (data->EventKey == ImGuiKey_UpArrow);
s_historyIndex = NavigateConsoleHistoryIndex(s_historyIndex, s_history.size(), up);
const std::string entry = ConsoleHistoryEntry(s_history, s_historyIndex);
data->DeleteChars(0, data->BufTextLen);
if (!entry.empty()) data->InsertChars(0, entry.c_str());
}
return 0;
}
} // namespace
void RenderLiteConsoleTab(App* app)
{
if (!app) return;
wallet::LiteWalletController* lw = app->liteWallet();
// ── Header ──────────────────────────────────────────────────────────────────
Type().text(TypeStyle::H6, TR("lite_console_title"));
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("lite_console_intro"));
ImGui::Spacing();
// ── Live status (read straight from the controller — always meaningful) ───────
{
const char* connText;
ImU32 connCol;
if (!lw) {
connText = "Lite backend not linked in this build (rebuild with --lite-backend)";
connCol = Error();
} else if (lw->walletOpen()) {
connText = "Connected";
connCol = Success();
} else if (lw->openInProgress()) {
connText = "Connecting\xE2\x80\xA6"; // ellipsis
connCol = Warning();
} else {
connText = "Disconnected";
connCol = Error();
}
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("lite_console_status"));
ImGui::SameLine();
Type().textColored(TypeStyle::Body2, connCol, connText);
if (lw && lw->walletOpen()) {
const SyncInfo& sync = app->state().sync;
char buf[96];
if (sync.syncing && !sync.isSynced()) {
double vp = sync.verification_progress;
if (vp < 0.0) vp = 0.0; else if (vp > 1.0) vp = 1.0;
std::snprintf(buf, sizeof(buf), "Syncing %.1f%% (block %d / %d)",
vp * 100.0, sync.blocks, sync.headers);
} else {
std::snprintf(buf, sizeof(buf), "Synced (block %d)", sync.blocks);
}
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
}
const std::string& openErr = app->liteOpenError();
if (!openErr.empty() && (!lw || !lw->walletOpen())) {
Type().textColored(TypeStyle::Caption, Error(),
(std::string("Last error: ") + openErr).c_str());
}
}
ImGui::Spacing();
// ── Ingest new automatic diagnostics events into the unified buffer ───────────
auto& diag = wallet::LiteDiagnostics::instance();
const std::uint64_t gen = diag.generation();
if (gen != s_diagGen) {
const auto snap = diag.snapshot();
// First pass dumps the whole ring; later passes append only the delta (generation counts
// total events ever added; the ring is bounded, so cap the delta at the snapshot size).
std::size_t startIdx = 0;
if (s_diagGen != static_cast<std::uint64_t>(-1)) {
const std::uint64_t added = gen - s_diagGen;
startIdx = (added >= snap.size()) ? 0 : snap.size() - static_cast<std::size_t>(added);
}
for (std::size_t i = startIdx; i < snap.size(); ++i)
s_lines.push_back({snap[i], logColor(snap[i])});
if (gen != s_diagGen && !snap.empty()) s_scrollToBottom = true;
s_diagGen = gen;
}
// ── Drain a completed interactive command ────────────────────────────────────
if (lw) {
wallet::LiteConsoleResult res;
if (lw->takeConsoleResult(res)) {
appendLines(res.response.empty() ? "(no output)" : res.response,
res.ok ? OnSurface() : Error());
s_scrollToBottom = true;
}
}
// ── Toolbar ─────────────────────────────────────────────────────────────────
if (ImGui::Button(TR("lite_console_clear"))) {
s_lines.clear();
s_diagGen = diag.generation(); // don't re-dump the whole ring on the next frame
}
ImGui::SameLine();
if (ImGui::Button(TR("lite_console_copy"))) {
std::string all;
for (const auto& l : s_lines) { all += l.text; all.push_back('\n'); }
ImGui::SetClipboardText(all.c_str());
}
ImGui::SameLine();
ImGui::Checkbox(TR("lite_console_autoscroll"), &s_autoScroll);
ImGui::Spacing();
// ── Output log (terminal-styled scroll region) ───────────────────────────────
const float inputRowH = ImGui::GetFrameHeightWithSpacing() + Layout::spacingSm();
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 90));
ImGui::BeginChild("##LiteConsoleLog", ImVec2(0, -inputRowH), true,
ImGuiWindowFlags_HorizontalScrollbar);
ImGui::PushFont(Type().caption());
if (s_lines.empty()) {
ImGui::PushStyleColor(ImGuiCol_Text, OnSurfaceDisabled());
ImGui::TextUnformatted(TR("lite_console_empty"));
ImGui::PopStyleColor();
} else {
for (const auto& line : s_lines) {
ImGui::PushStyleColor(ImGuiCol_Text, line.color);
ImGui::TextUnformatted(line.text.c_str()); // not format-interpreted — safe for any content
ImGui::PopStyleColor();
}
}
if (s_scrollToBottom && s_autoScroll) ImGui::SetScrollHereY(1.0f);
else if (s_autoScroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY()) ImGui::SetScrollHereY(1.0f);
s_scrollToBottom = false;
ImGui::PopFont();
ImGui::EndChild();
ImGui::PopStyleColor();
// ── Command input row ────────────────────────────────────────────────────────
const bool busy = lw && lw->consoleCommandInProgress();
auto submit = [&]() {
std::string cmd = s_input;
// trim
const std::size_t b = cmd.find_first_not_of(" \t");
if (b == std::string::npos) { s_input[0] = '\0'; return; }
cmd = cmd.substr(b);
while (!cmd.empty() && (cmd.back() == ' ' || cmd.back() == '\t')) cmd.pop_back();
s_lines.push_back({"> " + cmd, Primary()});
AppendConsoleHistory(s_history, cmd);
s_historyIndex = -1;
const std::string first = lowerFirstToken(cmd);
if (first == "clear" || first == "cls") {
// The backend's `clear` wipes wallet tx history — NOT the screen. Intercept it as a
// view-clear (what the user expects) so a stray "clear" can't destroy history.
s_lines.clear();
s_diagGen = diag.generation();
} else if (first == "quit" || first == "exit") {
appendLines(TR("lite_console_quit_note"), OnSurfaceMedium());
} else if (!lw) {
appendLines("Lite backend not linked in this build.", Error());
} else if (!lw->runConsoleCommand(cmd)) {
appendLines(TR("lite_console_busy"), Warning());
}
s_input[0] = '\0';
s_scrollToBottom = true;
s_focusInput = true; // keep focus for the next command
};
ImGui::PushFont(Type().caption());
Type().textColored(TypeStyle::Caption, busy ? Warning() : Primary(),
busy ? TR("lite_console_running") : "\xE2\x80\xBA"); // running… / ""
ImGui::SameLine();
ImGui::SetNextItemWidth(-FLT_MIN);
if (busy) ImGui::BeginDisabled();
const ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue |
ImGuiInputTextFlags_CallbackHistory;
if (s_focusInput) { ImGui::SetKeyboardFocusHere(); s_focusInput = false; }
if (ImGui::InputTextWithHint("##LiteConsoleInput", TR("lite_console_input_hint"),
s_input, sizeof(s_input), flags, inputCallback)) {
submit();
}
if (busy) ImGui::EndDisabled();
ImGui::PopFont();
}
} // namespace ui
} // namespace dragonx

View File

@@ -1,18 +0,0 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Lite-wallet "Console" tab: a read-only, terminal-styled view of the lite diagnostics log
// (server connection attempts, wallet open/create/restore, sync milestones, errors). Lite
// builds only. See src/ui/windows/lite_console_tab.cpp.
#pragma once
namespace dragonx {
class App;
namespace ui {
void RenderLiteConsoleTab(App* app);
} // namespace ui
} // namespace dragonx

View File

@@ -7,6 +7,7 @@
#include "../../config/version.h"
#include "../../data/wallet_state.h"
#include "../../config/settings.h"
#include "../../data/portfolio.h"
#include "../../data/exchange_info.h"
#include "../../util/i18n.h"
#include "../schema/ui_schema.h"
@@ -36,20 +37,21 @@ static double s_last_refresh_time = 0.0;
// Exchange / pair selection
static int s_exchange_idx = 0;
static int s_pair_idx = 0;
static float s_pair_scroll = 0.0f;
static float s_pair_scroll_target = 0.0f;
static bool s_pair_dragging = false;
static float s_pair_drag_start_x = 0.0f;
static float s_pair_drag_start_scroll = 0.0f;
static bool s_market_state_loaded = false;
// The effective exchange registry: the live CoinGecko list when available, else the
// compiled-in fallback.
static const std::vector<data::ExchangeInfo>& EffectiveRegistry(const MarketInfo& market)
{
return market.exchanges.empty() ? data::getExchangeRegistry() : market.exchanges;
}
// Helper: load selected exchange/pair from settings
static void LoadMarketState(config::Settings* settings)
static void LoadMarketState(config::Settings* settings, const std::vector<data::ExchangeInfo>& registry)
{
if (s_market_state_loaded || !settings) return;
s_market_state_loaded = true;
const auto& registry = data::getExchangeRegistry();
std::string savedExchange = settings->getSelectedExchange();
std::string savedPair = settings->getSelectedPair();
@@ -88,6 +90,134 @@ static std::string FormatPrice(double price)
return std::string(buf);
}
// ---- Portfolio editor (modal) state ----
static bool s_portfolio_editor_open = false;
static int s_pf_editing = -1; // -1 = list mode; -2 = adding new; >=0 = editing that index
static char s_pf_label[64] = {0};
static std::vector<std::string> s_pf_addrs;
// Populate the working buffers for a new (index < 0) or existing entry.
static void PortfolioBeginEdit(App* app, int index)
{
const auto& entries = app->settings()->getPortfolioEntries();
s_pf_editing = index;
if (index >= 0 && index < (int)entries.size()) {
snprintf(s_pf_label, sizeof(s_pf_label), "%s", entries[index].label.c_str());
s_pf_addrs = entries[index].addresses;
} else {
s_pf_label[0] = '\0';
s_pf_addrs.clear();
}
}
// The "Manage portfolio" modal: list/add/edit/delete entries, each a label tied to a group
// of wallet addresses. Persists to Settings on every mutation (like the address book).
static void RenderPortfolioEditor(App* app)
{
if (!s_portfolio_editor_open) return;
float dialogW = std::min(560.0f, ImGui::GetMainViewport()->Size.x * 0.9f);
if (!material::BeginOverlayDialog(TR("portfolio_manage_title"), &s_portfolio_editor_open, dialogW)) {
return;
}
config::Settings* settings = app->settings();
const auto& state = app->getWalletState();
auto persist = [&](const std::vector<config::Settings::PortfolioEntry>& entries) {
settings->setPortfolioEntries(entries);
settings->save();
};
if (s_pf_editing == -1) {
// ---- LIST MODE ----
const auto& entries = settings->getPortfolioEntries();
if (entries.empty()) {
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), TR("portfolio_no_entries"));
}
for (int i = 0; i < (int)entries.size(); i++) {
ImGui::PushID(i);
double bal = data::SumPortfolioBalance(entries[i].addresses, state.addresses);
char row[160];
snprintf(row, sizeof(row), "%s \xE2\x80\x94 %.4f %s (%d)",
entries[i].label.c_str(), bal, DRAGONX_TICKER, (int)entries[i].addresses.size());
ImGui::TextUnformatted(row);
ImGui::SameLine();
float bw = 72.0f;
float avail = ImGui::GetContentRegionAvail().x;
if (avail > bw * 2 + Layout::spacingSm())
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + avail - bw * 2 - Layout::spacingSm());
if (ImGui::Button(TR("portfolio_edit"), ImVec2(bw, 0))) PortfolioBeginEdit(app, i);
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_delete"), ImVec2(bw, 0))) {
auto e = entries; e.erase(e.begin() + i); persist(e);
ImGui::PopID();
break;
}
ImGui::PopID();
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
if (ImGui::Button(TR("portfolio_add_entry"))) PortfolioBeginEdit(app, -2);
} else {
// ---- EDIT MODE ----
ImGui::TextUnformatted(TR("portfolio_label"));
ImGui::SetNextItemWidth(-1);
ImGui::InputText("##pfLabel", s_pf_label, sizeof(s_pf_label));
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Quick group selectors.
if (ImGui::Button(TR("portfolio_all_shielded"))) {
for (const auto& a : state.addresses)
if (a.type == "shielded") data::PortfolioEntryAdd(s_pf_addrs, a.address);
}
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_all_transparent"))) {
for (const auto& a : state.addresses)
if (a.type == "transparent") data::PortfolioEntryAdd(s_pf_addrs, a.address);
}
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_clear_sel"))) s_pf_addrs.clear();
char selc[48];
snprintf(selc, sizeof(selc), TR("portfolio_addresses_sel"), (int)s_pf_addrs.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), selc);
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
// Address checklist.
ImGui::BeginChild("##pfAddrList", ImVec2(0, -ImGui::GetFrameHeightWithSpacing() * 1.5f), true);
for (const auto& a : state.addresses) {
bool inSet = data::PortfolioEntryContains(s_pf_addrs, a.address);
char line[192];
snprintf(line, sizeof(line), "%s (%.4f %s)", a.address.c_str(), a.balance, DRAGONX_TICKER);
ImGui::PushID(a.address.c_str());
if (ImGui::Checkbox(line, &inSet)) {
if (inSet) data::PortfolioEntryAdd(s_pf_addrs, a.address);
else data::PortfolioEntryRemove(s_pf_addrs, a.address);
}
ImGui::PopID();
}
ImGui::EndChild();
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
bool canSave = s_pf_label[0] != '\0';
ImGui::BeginDisabled(!canSave);
if (ImGui::Button(TR("portfolio_save"))) {
auto entries = settings->getPortfolioEntries();
config::Settings::PortfolioEntry e;
e.label = s_pf_label;
e.addresses = s_pf_addrs;
if (s_pf_editing >= 0 && s_pf_editing < (int)entries.size()) entries[s_pf_editing] = e;
else entries.push_back(e);
persist(entries);
s_pf_editing = -1;
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button(TR("portfolio_cancel"))) s_pf_editing = -1;
}
material::EndOverlayDialog();
}
void RenderMarketTab(App* app)
{
auto& S = schema::UI();
@@ -104,11 +234,14 @@ void RenderMarketTab(App* app)
const auto& state = app->getWalletState();
const auto& market = state.market;
// Load persisted exchange/pair on first frame
LoadMarketState(app->settings());
// Kick a once-per-session live exchange-list fetch (self-guarded), then source the
// registry from it (falling back to the compiled-in list until it arrives).
app->refreshExchanges();
const auto& registry = EffectiveRegistry(market);
// Load persisted exchange/pair on first frame
LoadMarketState(app->settings(), registry);
// Exchange registry
const auto& registry = data::getExchangeRegistry();
if (s_exchange_idx >= (int)registry.size()) s_exchange_idx = 0;
const auto& currentExchange = registry[s_exchange_idx];
if (s_pair_idx >= (int)currentExchange.pairs.size()) s_pair_idx = 0;
@@ -143,7 +276,6 @@ void RenderMarketTab(App* app)
+ ImGui::GetStyle().ItemSpacing.y * 2.0f;
float mkGapOver = gap + ImGui::GetStyle().ItemSpacing.y;
float mkOverhead = 3.0f * (mkSHdr + mkGapOver) + 2.0f * mkGapOver;
float pairBarH = S.drawElement("tabs.market", "pair-bar-height").height;
float mkCardBudget = std::max(200.0f, marketAvail.y - mkOverhead);
Layout::SectionBudget mb(mkCardBudget);
@@ -159,7 +291,11 @@ void RenderMarketTab(App* app)
float statsRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + pad;
float cardH = std::max(heroMinH + statsRowH + pad, (S.drawElement("tabs.market", "hero-card-height").size + statsRowH + pad) * vs);
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
// Combined hero + chart: draw ONE glass panel spanning the price/stats header AND the
// chart below it. The chart block no longer draws its own panel or an inter-card gap.
float mergedChartH = std::max(60.0f, chartElem.height * vs);
ImVec2 mergedMax(cardMin.x + availWidth, cardMax.y + mergedChartH);
DrawGlassPanel(dl, cardMin, mergedMax, glassSpec);
float cx = cardMin.x + Layout::spacingLg();
float cy = cardMin.y + Layout::spacingLg();
@@ -198,20 +334,19 @@ void RenderMarketTab(App* app)
ImVec2(cardMax.x - rnd * 0.5f, sepY),
WithAlpha(OnSurface(), 15), 1.0f * dp);
// ---- STATS ROW (BTC Price | Volume | Market Cap) ----
// ---- STATS ROW (24h Volume | Market Cap) ----
// (BTC-price column + the "updated Ns ago" staleness line were dropped to
// simplify; freshness is still shown by the attribution "Updated" line.)
float statsY = sepY + Layout::spacingSm();
float colW = (availWidth - Layout::spacingLg() * 2) / 3.0f;
float colW = (availWidth - Layout::spacingLg() * 2) / 2.0f;
struct StatItem { const char* label; std::string value; ImU32 valueCol; };
StatItem stats[3] = {
{TR("market_btc_price"), "", OnSurface()},
StatItem stats[2] = {
{TR("market_24h_volume"), FormatCompactUSD(market.volume_24h), OnSurface()},
{TR("market_cap"), FormatCompactUSD(market.market_cap), OnSurface()},
};
snprintf(buf, sizeof(buf), "%.10f", market.price_btc);
stats[0].value = buf;
for (int i = 0; i < 3; i++) {
for (int i = 0; i < 2; i++) {
float sx = cardMin.x + Layout::spacingLg() + i * colW;
float centerX = sx + colW * 0.5f;
@@ -227,26 +362,6 @@ void RenderMarketTab(App* app)
ImVec2(centerX - valSz.x * 0.5f, valY), stats[i].valueCol, stats[i].value.c_str());
}
// ---- STALENESS INDICATOR ----
{
auto fetchTime = market.last_fetch_time;
if (fetchTime.time_since_epoch().count() > 0) {
auto elapsed = std::chrono::steady_clock::now() - fetchTime;
int ageSecs = (int)std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
bool stale = ageSecs > 300; // 5 minutes
if (ageSecs < 60)
snprintf(buf, sizeof(buf), "%s %ds %s", stale ? ICON_MD_WARNING : "", ageSecs, TR("ago"));
else
snprintf(buf, sizeof(buf), "%s %dm %s", stale ? ICON_MD_WARNING : "", ageSecs / 60, TR("ago"));
ImFont* staleFont = capFont;
ImU32 staleCol = stale ? Warning() : WithAlpha(OnSurface(), 100);
float staleY = statsY + ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm();
ImVec2 staleSz = staleFont->CalcTextSizeA(staleFont->LegacySize, FLT_MAX, 0, buf);
dl->AddText(staleFont, staleFont->LegacySize,
ImVec2(cardMin.x + Layout::spacingLg(), staleY), staleCol, buf);
}
}
// ---- TRADE BUTTON (top-right of card) ----
if (!currentExchange.pairs.empty()) {
const char* pairName = currentExchange.pairs[s_pair_idx].displayName.c_str();
@@ -312,13 +427,13 @@ void RenderMarketTab(App* app)
}
}
// No inter-card gap — the chart is drawn immediately below, inside the same panel.
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// PRICE CHART — Custom drawn inside glass panel (matches app design)
// PRICE CHART — drawn inside the combined hero card's glass panel (above)
// ================================================================
{
// Plot the REAL accumulated price history (one point per refresh, oldest→newest).
@@ -327,11 +442,11 @@ void RenderMarketTab(App* app)
s_price_history = market.price_history;
s_history_initialized = true;
// Chart height from schema
// Chart height from schema. No glass panel here — it shares the combined hero+chart
// card's panel drawn in PRICE SUMMARY above (mergedChartH uses this same height).
float chartH = std::max(60.0f, chartElem.height * vs);
ImVec2 chartMin = ImGui::GetCursorScreenPos();
ImVec2 chartMax(chartMin.x + availWidth, chartMin.y + chartH);
DrawGlassPanel(dl, chartMin, chartMax, glassSpec);
if (!s_price_history.empty() && s_price_history.size() >= 2) {
float chartPad = pad;
@@ -513,250 +628,90 @@ void RenderMarketTab(App* app)
}
// ================================================================
// EXCHANGE SELECTOR — Combo dropdown + attribution
// PAIR SELECTOR — every trading pair across all exchanges shown as
// round buttons (flat; no exchange dropdown). Wraps to multiple rows.
// ================================================================
ImGui::Dummy(ImVec2(0, S.drawElement("tabs.market", "exchange-top-gap").size));
{
float comboW = S.drawElement("tabs.market", "exchange-combo-width").size;
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
float innerGap = Layout::spacingSm();
float sidePad = Layout::spacingMd();
ImGui::PushFont(body2);
ImGui::PushItemWidth(comboW);
if (ImGui::BeginCombo("##ExchangeCombo", currentExchange.name.c_str())) {
for (int i = 0; i < (int)registry.size(); i++) {
bool selected = (i == s_exchange_idx);
if (ImGui::Selectable(registry[i].name.c_str(), selected)) {
if (i != s_exchange_idx) {
s_exchange_idx = i;
s_pair_idx = 0;
s_pair_scroll = 0.0f;
s_pair_scroll_target = 0.0f;
ImVec2 origin = ImGui::GetCursorScreenPos();
float left = origin.x;
float right = origin.x + availWidth;
float cx = left;
float cy = origin.y;
int btnCounter = 0;
for (int ex = 0; ex < (int)registry.size(); ex++) {
for (int pr = 0; pr < (int)registry[ex].pairs.size(); pr++) {
const std::string& pairName = registry[ex].pairs[pr].displayName;
const std::string& exName = registry[ex].name;
float pairW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pairName.c_str()).x;
float exW = ovFont->CalcTextSizeA(ovFont->LegacySize, FLT_MAX, 0, exName.c_str()).x;
float cw = sidePad * 2.0f + pairW + innerGap + exW;
// Wrap to the next row when the pill would overflow (never on an empty row).
if (cx + cw > right && cx > left) {
cx = left;
cy += chipH + chipSpacing;
}
ImVec2 cMin(cx, cy);
ImVec2 cMax(cx + cw, cy + chipH);
bool selected = (ex == s_exchange_idx && pr == s_pair_idx);
bool hov = material::IsRectHovered(cMin, cMax);
ImU32 chipBg = selected ? WithAlpha(Primary(), 200)
: (hov ? WithAlpha(OnSurface(), 35) : WithAlpha(OnSurface(), 20));
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
ImU32 pairCol = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
ImU32 exCol = selected ? WithAlpha(IM_COL32(255, 255, 255, 255), 190) : OnSurfaceDisabled();
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
float tx = cx + sidePad;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(tx, cy + (chipH - capFont->LegacySize) * 0.5f), pairCol, pairName.c_str());
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(tx + pairW + innerGap, cy + (chipH - ovFont->LegacySize) * 0.5f),
exCol, exName.c_str());
ImGui::SetCursorScreenPos(cMin);
snprintf(buf, sizeof(buf), "##PairBtn%d", btnCounter++);
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
if (ex != s_exchange_idx || pr != s_pair_idx) {
s_exchange_idx = ex;
s_pair_idx = pr;
s_history_initialized = false;
app->settings()->setSelectedExchange(registry[i].name);
if (!registry[i].pairs.empty())
app->settings()->setSelectedPair(registry[i].pairs[0].displayName);
app->settings()->setSelectedExchange(exName);
app->settings()->setSelectedPair(pairName);
app->settings()->save();
app->refreshMarketData();
}
}
if (selected) ImGui::SetItemDefaultFocus();
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
cx += cw + chipSpacing;
}
ImGui::EndCombo();
}
ImGui::PopItemWidth();
// Attribution
ImGui::SameLine(0, Layout::spacingLg());
// Advance the cursor past the wrapped rows.
ImGui::SetCursorScreenPos(ImVec2(left, cy + chipH));
ImGui::Dummy(ImVec2(availWidth, 0));
// Attribution (moved here from the removed exchange-selector row).
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("market_attribution"));
if (!market.last_updated.empty()) {
ImGui::SameLine(0, 12);
snprintf(buf, sizeof(buf), " \xc2\xb7 Updated %s", market.last_updated.c_str());
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), buf);
}
ImGui::PopFont();
ImGui::Dummy(ImVec2(0, gap));
}
// ================================================================
// PAIR BAR — Horizontally scrolling chip selector (always visible)
// ================================================================
{
float chipH = S.drawElement("tabs.market", "pair-chip-height").height;
float chipR = S.drawElement("tabs.market", "pair-chip-radius").radius;
float chipSpacing = S.drawElement("tabs.market", "pair-chip-spacing").size;
float fadeW = S.drawElement("tabs.market", "pair-bar-fade-width").size;
float arrowSz = S.drawElement("tabs.market", "pair-bar-arrow-size").size;
ImVec2 barOrigin = ImGui::GetCursorScreenPos();
float barW = availWidth;
float barH = pairBarH;
// Compute total content width of all chips
float totalChipW = 0.0f;
std::vector<float> chipWidths;
for (const auto& pair : currentExchange.pairs) {
float tw = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, pair.displayName.c_str()).x;
float cw = tw + Layout::spacingLg() * 2.0f;
chipWidths.push_back(cw);
totalChipW += cw + chipSpacing;
}
totalChipW -= chipSpacing; // no trailing spacing
float scrollableW = barW - arrowSz * 2.0f - Layout::spacingSm() * 2.0f;
float maxScroll = std::max(0.0f, totalChipW - scrollableW);
// Smooth scroll lerp
s_pair_scroll += (s_pair_scroll_target - s_pair_scroll) * 0.15f;
if (std::abs(s_pair_scroll - s_pair_scroll_target) < 0.5f)
s_pair_scroll = s_pair_scroll_target;
// Clamp
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
s_pair_scroll = std::clamp(s_pair_scroll, 0.0f, maxScroll);
// Left arrow button
float arrowY = barOrigin.y + (barH - arrowSz) * 0.5f;
bool canScrollLeft = s_pair_scroll_target > 0.01f;
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, arrowY));
ImGui::BeginDisabled(!canScrollLeft);
ImGui::PushFont(material::Typography::instance().iconSmall());
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
if (ImGui::Button(ICON_MD_CHEVRON_LEFT "##PairLeft", ImVec2(arrowSz, arrowSz))) {
// Scroll left by average chip width
float avgChipW = totalChipW / currentExchange.pairs.size();
s_pair_scroll_target -= avgChipW + chipSpacing;
if (s_pair_scroll_target < 0) s_pair_scroll_target = 0;
}
ImGui::PopStyleVar();
ImGui::PopFont();
ImGui::EndDisabled();
// Chip area with clipping
float chipAreaLeft = barOrigin.x + arrowSz + Layout::spacingSm();
float chipAreaRight = barOrigin.x + barW - arrowSz - Layout::spacingSm();
float chipY = barOrigin.y + (barH - chipH) * 0.5f;
dl->PushClipRect(ImVec2(chipAreaLeft, barOrigin.y),
ImVec2(chipAreaRight, barOrigin.y + barH), true);
// Render chips
float cx = chipAreaLeft - s_pair_scroll;
bool anyClicked = false;
for (int i = 0; i < (int)currentExchange.pairs.size(); i++) {
float cw = chipWidths[i];
ImVec2 cMin(cx, chipY);
ImVec2 cMax(cx + cw, chipY + chipH);
bool selected = (i == s_pair_idx);
ImU32 chipBg = selected ? WithAlpha(Primary(), 200) : WithAlpha(OnSurface(), 20);
ImU32 chipBorder = selected ? Primary() : WithAlpha(OnSurface(), 40);
ImU32 chipText = selected ? IM_COL32(255, 255, 255, 255) : OnSurface();
dl->AddRectFilled(cMin, cMax, chipBg, chipR);
dl->AddRect(cMin, cMax, chipBorder, chipR, 0, 1.0f);
ImVec2 textSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0,
currentExchange.pairs[i].displayName.c_str());
float textX = cx + (cw - textSz.x) * 0.5f;
float textY = chipY + (chipH - textSz.y) * 0.5f;
dl->AddText(capFont, capFont->LegacySize, ImVec2(textX, textY), chipText,
currentExchange.pairs[i].displayName.c_str());
// Click detection via invisible button
ImGui::SetCursorScreenPos(cMin);
snprintf(buf, sizeof(buf), "##PairChip%d", i);
if (ImGui::InvisibleButton(buf, ImVec2(cw, chipH))) {
if (!s_pair_dragging || std::abs(ImGui::GetIO().MousePos.x - s_pair_drag_start_x) < 4.0f) {
s_pair_idx = i;
anyClicked = true;
app->settings()->setSelectedPair(currentExchange.pairs[i].displayName);
app->settings()->save();
s_history_initialized = false;
app->refreshMarketData();
}
}
cx += cw + chipSpacing;
}
dl->PopClipRect();
// Fade overlays on edges
ImU32 bgCol = IM_COL32(0, 0, 0, 0);
ImU32 surfaceCol = Surface();
if (s_pair_scroll > 1.0f) {
// Left fade
dl->AddRectFilledMultiColor(
ImVec2(chipAreaLeft, barOrigin.y), ImVec2(chipAreaLeft + fadeW, barOrigin.y + barH),
surfaceCol, bgCol, bgCol, surfaceCol);
}
if (s_pair_scroll < maxScroll - 1.0f) {
// Right fade
dl->AddRectFilledMultiColor(
ImVec2(chipAreaRight - fadeW, barOrigin.y), ImVec2(chipAreaRight, barOrigin.y + barH),
bgCol, surfaceCol, surfaceCol, bgCol);
}
// Right arrow button
bool canScrollRight = s_pair_scroll_target < maxScroll - 0.01f;
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x + barW - arrowSz, arrowY));
ImGui::BeginDisabled(!canScrollRight);
ImGui::PushFont(material::Typography::instance().iconSmall());
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2));
if (ImGui::Button(ICON_MD_CHEVRON_RIGHT "##PairRight", ImVec2(arrowSz, arrowSz))) {
float avgChipW = totalChipW / currentExchange.pairs.size();
s_pair_scroll_target += avgChipW + chipSpacing;
if (s_pair_scroll_target > maxScroll) s_pair_scroll_target = maxScroll;
}
ImGui::PopStyleVar();
ImGui::PopFont();
ImGui::EndDisabled();
// Mouse wheel horizontal scroll when hovering pair bar
ImVec2 mPos = ImGui::GetIO().MousePos;
if (mPos.x >= barOrigin.x && mPos.x <= barOrigin.x + barW &&
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
float wheel = ImGui::GetIO().MouseWheel;
if (wheel != 0.0f) {
float avgChipW = totalChipW / currentExchange.pairs.size();
s_pair_scroll_target -= wheel * (avgChipW + chipSpacing);
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
}
}
// Mouse drag scrolling
if (ImGui::IsMouseClicked(0) && mPos.x >= chipAreaLeft && mPos.x <= chipAreaRight &&
mPos.y >= barOrigin.y && mPos.y <= barOrigin.y + barH) {
s_pair_dragging = true;
s_pair_drag_start_x = mPos.x;
s_pair_drag_start_scroll = s_pair_scroll_target;
}
if (s_pair_dragging) {
if (ImGui::IsMouseDown(0)) {
float dx = mPos.x - s_pair_drag_start_x;
s_pair_scroll_target = std::clamp(s_pair_drag_start_scroll - dx, 0.0f, maxScroll);
} else {
s_pair_dragging = false;
}
}
// Arrow key navigation
if (ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows)) {
if (ImGui::IsKeyPressed(ImGuiKey_LeftArrow) && s_pair_idx > 0) {
s_pair_idx--;
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
app->settings()->save();
s_history_initialized = false;
app->refreshMarketData();
anyClicked = true;
}
if (ImGui::IsKeyPressed(ImGuiKey_RightArrow) && s_pair_idx < (int)currentExchange.pairs.size() - 1) {
s_pair_idx++;
app->settings()->setSelectedPair(currentExchange.pairs[s_pair_idx].displayName);
app->settings()->save();
s_history_initialized = false;
app->refreshMarketData();
anyClicked = true;
}
}
// Auto-scroll to keep selected chip visible
if (anyClicked) {
float chipLeft = 0;
for (int i = 0; i < s_pair_idx; i++)
chipLeft += chipWidths[i] + chipSpacing;
float chipRight = chipLeft + chipWidths[s_pair_idx];
if (chipLeft < s_pair_scroll_target)
s_pair_scroll_target = chipLeft - chipSpacing;
if (chipRight > s_pair_scroll_target + scrollableW)
s_pair_scroll_target = chipRight - scrollableW + chipSpacing;
s_pair_scroll_target = std::clamp(s_pair_scroll_target, 0.0f, maxScroll);
}
// Advance cursor past the bar
ImGui::SetCursorScreenPos(ImVec2(barOrigin.x, barOrigin.y + barH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
@@ -765,14 +720,31 @@ void RenderMarketTab(App* app)
// ================================================================
{
Type().textColored(TypeStyle::Overline, OnSurfaceMedium(), TR("market_portfolio"));
// "Manage…" button, right-aligned on the header row — opens the portfolio editor.
{
const char* ml = TR("portfolio_manage");
float mBtnW = body2->CalcTextSizeA(body2->LegacySize, FLT_MAX, 0, ml).x + Layout::spacingMd() * 2;
ImGui::SameLine();
float availX = ImGui::GetContentRegionAvail().x;
if (availX > mBtnW) ImGui::SetCursorPosX(ImGui::GetCursorPosX() + availX - mBtnW);
if (ImGui::Button(ml, ImVec2(mBtnW, 0))) s_portfolio_editor_open = true;
}
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
double total_balance = state.totalBalance;
double private_balance = state.privateBalance;
double transparent_balance = state.transparentBalance;
// Custom portfolio entries render below the "all funds" summary; grow the card to fit.
const auto& pfEntries = app->settings()->getPortfolioEntries();
float entryRowH = body2->LegacySize + Layout::spacingSm();
float entriesBlockH = pfEntries.empty()
? 0.0f
: (Layout::spacingSm() * 2.0f + (float)pfEntries.size() * entryRowH);
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float cardH = std::max(50.0f, portfolioBudgetH);
float baseH = std::max(60.0f, portfolioBudgetH);
float cardH = baseH + entriesBlockH;
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
@@ -787,6 +759,17 @@ void RenderMarketTab(App* app)
snprintf(buf, sizeof(buf), "$%.6f USD", portfolio_usd);
DrawTextShadow(dl, sub1, sub1->LegacySize, ImVec2(cx, cy), Success(), buf);
// 24h change on the holdings (the coin's 24h % applies to the value), shown
// beside the USD figure and colored by direction.
if (market.change_24h != 0.0) {
float usdW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
char chg[48];
snprintf(chg, sizeof(chg), "%+.2f%% %s", market.change_24h, TR("market_24h_change"));
ImU32 chgCol = market.change_24h >= 0 ? Success() : Error();
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cx + usdW + Layout::spacingMd(), cy + 3), chgCol, chg);
}
double portfolio_btc = total_balance * market.price_btc;
snprintf(buf, sizeof(buf), "%.10f BTC", portfolio_btc);
float valW = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, buf).x;
@@ -839,13 +822,36 @@ void RenderMarketTab(App* app)
ImVec2(cx, cy + barH + 2), OnSurfaceDisabled(), buf);
}
float actualCardH = (total_balance > 0) ? std::max(60.0f, portfolioBudgetH) : cardH;
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + actualCardH));
// ---- CUSTOM PORTFOLIO ENTRIES (below the all-funds summary) ----
if (!pfEntries.empty()) {
float ey = cardMin.y + baseH - Layout::spacingSm();
dl->AddLine(ImVec2(cardMin.x + glassSpec.rounding * 0.5f, ey),
ImVec2(cardMax.x - glassSpec.rounding * 0.5f, ey), WithAlpha(OnSurface(), 20), 1.0f);
ey += Layout::spacingSm();
for (const auto& e : pfEntries) {
double bal = data::SumPortfolioBalance(e.addresses, state.addresses);
dl->AddText(body2, body2->LegacySize, ImVec2(cx, ey), OnSurface(), e.label.c_str());
char amt[96];
if (market.price_usd > 0)
snprintf(amt, sizeof(amt), "%.4f %s ($%.2f)", bal, DRAGONX_TICKER, bal * market.price_usd);
else
snprintf(amt, sizeof(amt), "%.4f %s", bal, DRAGONX_TICKER);
float amtW = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, amt).x;
dl->AddText(capFont, capFont->LegacySize,
ImVec2(cardMax.x - amtW - pad, ey + 1), OnSurfaceMedium(), amt);
ey += entryRowH;
}
}
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMin.y + cardH));
ImGui::Dummy(ImVec2(availWidth, 0));
ImGui::Dummy(ImVec2(0, gap));
}
ImGui::EndChild(); // ##MarketScroll
// Portfolio editor modal (rendered outside the scroll child so it overlays the page).
RenderPortfolioEditor(app);
}
} // namespace ui

View File

@@ -528,7 +528,18 @@ void RenderMiningControls(App* app, const WalletState& state, const MiningInfo&
// --- Miner update: "Update <latest>" button + current version (left of benchmark) ---
if (s_pool_mode && !benchRunning) {
const std::string curVer = app->settings()->getXmrigVersion();
// "Current" prefers the live version reported by the running miner (so a
// bundled miner with no persisted release tag still shows a version).
// Remembered for the session so it persists after the miner stops; falls
// back to the persisted release tag (set by the updater), else "none".
static std::string s_live_miner_ver;
if (!state.pool_mining.version.empty())
s_live_miner_ver = state.pool_mining.version;
// Live running version → detected installed version (before mining) →
// persisted release tag (updater) → "none".
std::string curVer = s_live_miner_ver;
if (curVer.empty()) curVer = app->poolMiningInstalledVersion();
if (curVer.empty()) curVer = app->settings()->getXmrigVersion();
char xbtn[64];
if (!s_xmrig_latest_tag.empty())
snprintf(xbtn, sizeof(xbtn), "%s %s", TR("xmrig_update_short"), s_xmrig_latest_tag.c_str());

View File

@@ -10,6 +10,7 @@
#include "../../config/version.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
#include "../../util/pool_registry.h"
#include "../../util/platform.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
@@ -148,6 +149,18 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
float inputsStartX = tMax.x + Layout::spacingLg();
ImGui::SetCursorScreenPos(ImVec2(inputsStartX, tMin.y + vertOff));
// Manual vs Auto-balance pool selection. In Auto mode the wallet picks the
// pool by hashrate (App::updatePoolAutoBalance), so the URL group is shown
// disabled and mirrors the auto-selected pool.
const bool autoMode = app->settings()->getPoolSelectMode()
== config::Settings::PoolSelectMode::AutoBalance;
if (autoMode) {
const std::string cur = app->settings()->getPoolUrl();
if (cur != s_pool_url) {
strncpy(s_pool_url, cur.c_str(), sizeof(s_pool_url) - 1);
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
}
}
ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, 4.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(8 * dp, 4 * dp));
@@ -168,7 +181,8 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
float urlGroupStartY = ImGui::GetCursorScreenPos().y;
float urlGroupW = urlW + perGroupExtra;
// === Pool URL input ===
// === Pool URL input === (disabled in Auto mode — the wallet picks the pool)
if (autoMode) ImGui::BeginDisabled();
ImGui::SetNextItemWidth(urlW);
if (ImGui::InputTextWithHint("##PoolURL", TR("mining_pool_url"), s_pool_url, sizeof(s_pool_url))) {
s_pool_settings_dirty = true;
@@ -240,6 +254,45 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 2 * dp));
ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(0, 0));
if (ImGui::BeginPopup("##SavedPoolsPopup")) {
// Suggested (official) pools — one click to select, shown above the
// user's saved favorites.
{
const float sPopupInnerW = ImGui::GetContentRegionAvail().x;
const float sRowH = ImGui::GetFrameHeight();
const float sTextPadX = 8 * dp;
ImGui::SetCursorPosX(sTextPadX);
ImGui::PushFont(Type().caption());
ImGui::TextDisabled("%s", TR("mining_suggested_pools"));
ImGui::PopFont();
ImFont* sRowFont = ImGui::GetFont();
const float sRowFontSz = ImGui::GetFontSize();
for (const auto& kp : util::knownPools()) {
ImGui::PushID(kp.id.c_str());
ImVec2 rowMin = ImGui::GetCursorScreenPos();
ImVec2 rowMax(rowMin.x + sPopupInnerW, rowMin.y + sRowH);
ImGui::InvisibleButton("##sugg", ImVec2(sPopupInnerW, sRowH));
bool rowHov = ImGui::IsItemHovered();
bool rowClk = ImGui::IsItemClicked();
bool isCurrent = (std::string(s_pool_url) == kp.stratum);
ImDrawList* pdl = ImGui::GetWindowDrawList();
if (rowHov) pdl->AddRectFilled(rowMin, rowMax, StateHover());
float textY = rowMin.y + (sRowH - sRowFontSz) * 0.5f;
pdl->AddText(sRowFont, sRowFontSz, ImVec2(rowMin.x + sTextPadX, textY),
isCurrent ? Primary() : OnSurface(), kp.stratum.c_str());
if (rowHov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", kp.label.c_str());
}
if (rowClk) {
strncpy(s_pool_url, kp.stratum.c_str(), sizeof(s_pool_url) - 1);
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
s_pool_settings_dirty = true;
ImGui::CloseCurrentPopup();
}
ImGui::PopID();
}
ImGui::Separator();
}
const auto& savedUrls = app->settings()->getSavedPoolUrls();
if (savedUrls.empty()) {
ImGui::SetCursorPosX(8 * dp);
@@ -338,6 +391,7 @@ void RenderMiningModeToggle(App* app, const WalletState& state, const MiningInfo
ImGui::EndPopup();
}
ImGui::PopStyleVar(3); // WindowRounding, WindowPadding, ItemSpacing for URL popup
if (autoMode) ImGui::EndDisabled();
ImGui::SameLine(0, Layout::spacingSm());
float wrkGroupStartX = ImGui::GetCursorScreenPos().x;
float wrkGroupStartY = ImGui::GetCursorScreenPos().y;

View File

@@ -5,7 +5,10 @@
#include "mining_stats.h"
#include "mining_tab_helpers.h" // FormatHashrate, EstimateHoursToBlock
#include "../../app.h"
#include "../../config/settings.h"
#include "../../util/i18n.h"
#include "../../util/pool_registry.h"
#include "../schema/ui_schema.h"
#include "../material/type.h"
#include "../material/draw_helpers.h"
@@ -17,6 +20,7 @@
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>
@@ -29,297 +33,495 @@ using namespace material;
static bool s_show_pool_log = false;
static bool s_show_solo_log = false;
// ---------------------------------------------------------------------------
// Sparkline chart of a hashrate history, drawn inside [regionMin, regionMax]
// (the plot area within a card, already inset by padding). Extracted so both
// the solo full-width card and the pool split card share one implementation.
// ---------------------------------------------------------------------------
static void DrawHashrateSparkline(ImDrawList* dl, ImVec2 regionMin, ImVec2 regionMax,
const std::vector<double>& chartHistory,
ImFont* capFont, float dp)
{
if (chartHistory.size() < 2) return;
const float chartBot = regionMax.y;
double yMin = *std::min_element(chartHistory.begin(), chartHistory.end());
double yMax = *std::max_element(chartHistory.begin(), chartHistory.end());
if (yMax <= yMin) { yMax = yMin + 1.0; }
double yRange = yMax - yMin;
double yPad2 = yRange * 0.1;
yMin -= yPad2;
yMax += yPad2;
const float plotLeft = regionMin.x;
const float plotRight = regionMax.x;
const float plotTop = regionMin.y + capFont->LegacySize + 4 * dp;
const float plotBottom = chartBot - capFont->LegacySize * 2 - 16 * dp;
const float plotW = plotRight - plotLeft;
const float plotH = std::max(1.0f, plotBottom - plotTop);
size_t n = chartHistory.size();
float stepW = (n > 1) ? plotW / (float)(n - 1) : plotW;
std::vector<ImVec2> rawPts(n);
for (size_t i = 0; i < n; i++) {
float x = plotLeft + (float)i * stepW;
float y = plotBottom - (float)((chartHistory[i] - yMin) / (yMax - yMin)) * plotH;
rawPts[i] = ImVec2(x, y);
}
// Catmull-Rom spline interpolation for a smooth curve (adaptive subdivision).
std::vector<ImVec2> points;
if (n <= 2) {
points = rawPts;
} else {
points.reserve(n * 4);
for (size_t i = 0; i + 1 < n; i++) {
ImVec2 p0 = rawPts[i > 0 ? i - 1 : 0];
ImVec2 p1 = rawPts[i];
ImVec2 p2 = rawPts[i + 1];
ImVec2 p3 = rawPts[i + 2 < n ? i + 2 : n - 1];
float dx = p2.x - p1.x, dy = p2.y - p1.y;
float dist = sqrtf(dx * dx + dy * dy);
int subdivs = std::clamp((int)(dist / 3.0f), 1, 16);
for (int s = 0; s < subdivs; s++) {
float t = (float)s / (float)subdivs;
float t2 = t * t;
float t3 = t2 * t;
float q0 = -t3 + 2.0f * t2 - t;
float q1 = 3.0f * t3 - 5.0f * t2 + 2.0f;
float q2 = -3.0f * t3 + 4.0f * t2 + t;
float q3 = t3 - t2;
float sx = 0.5f * (p0.x * q0 + p1.x * q1 + p2.x * q2 + p3.x * q3);
float sy = 0.5f * (p0.y * q0 + p1.y * q1 + p2.y * q2 + p3.y * q3);
sy = std::clamp(sy, plotTop, plotBottom);
points.push_back(ImVec2(sx, sy));
}
}
points.push_back(rawPts[n - 1]);
}
if (points.size() >= 2) {
for (size_t i = 0; i < points.size(); i++)
dl->PathLineTo(points[i]);
dl->PathLineTo(ImVec2(points.back().x, plotBottom));
dl->PathLineTo(ImVec2(points.front().x, plotBottom));
dl->PathFillConcave(WithAlpha(Success(), 25));
}
dl->AddPolyline(points.data(), (int)points.size(), WithAlpha(Success(), 200),
ImDrawFlags_None,
schema::UI().drawElement("tabs.mining", "chart-line-thickness").size);
// Y-axis labels
std::string yMaxStr = FormatHashrate(yMax);
std::string yMinStr = FormatHashrate(yMin);
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotTop - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(), yMaxStr.c_str());
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotBottom + 4 * dp), OnSurfaceDisabled(), yMinStr.c_str());
// X-axis labels
dl->AddText(capFont, capFont->LegacySize,
ImVec2(plotLeft, chartBot - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(),
chartHistory.size() >= 300 ? TR("mining_chart_5m_ago") :
chartHistory.size() >= 60 ? TR("mining_chart_1m_ago") : TR("mining_chart_start"));
std::string nowLbl = TR("mining_chart_now");
ImVec2 nowSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, nowLbl.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(plotRight - nowSz.x, chartBot - capFont->LegacySize - 2 * dp),
OnSurfaceDisabled(), nowLbl.c_str());
}
// ---------------------------------------------------------------------------
// Read-only, selectable/copyable log view filling [regionMin, size].
// ---------------------------------------------------------------------------
static void DrawMiningLogView(ImVec2 regionMin, float regionW, float regionH,
const std::vector<std::string>& logLines, const char* inputId)
{
static std::string s_log_buf;
s_log_buf.clear();
for (const auto& line : logLines)
if (!line.empty()) { s_log_buf += line; s_log_buf += '\n'; }
ImGui::SetCursorScreenPos(regionMin);
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0));
ImGui::PushFont(Type().body2());
ImGui::InputTextMultiline(inputId, const_cast<char*>(s_log_buf.c_str()), s_log_buf.size() + 1,
ImVec2(regionW, regionH), ImGuiInputTextFlags_ReadOnly);
ImGui::PopFont();
ImGui::PopStyleColor(2);
}
// ---------------------------------------------------------------------------
// The left pool card (pool mode only): Manual/Auto toggle, a metric stack, and
// a fixed-height scrolling pool list. Pool rows are clickable to select in
// Manual mode (routed through the parent's dirty flag → save + restart) and
// show a "mining here" dot in Auto mode. The list scrolls, so any number of
// pools keeps the card — and everything below it — at a constant height.
// ---------------------------------------------------------------------------
static void RenderLeftPoolCard(App* app, const WalletState& state, ImDrawList* dl,
ImFont* capFont, ImFont* sub1, ImFont* ovFont,
float dp, float gap, float pad,
ImVec2 cardMin, ImVec2 cardMax,
char (&s_pool_url)[256], bool& s_pool_settings_dirty)
{
const bool autoMode = app->settings()->getPoolSelectMode()
== config::Settings::PoolSelectMode::AutoBalance;
const float x = cardMin.x + pad;
const float w = (cardMax.x - cardMin.x) - pad * 2;
float y = cardMin.y + pad * 0.75f;
const float toggleH = sub1->LegacySize + 10 * dp;
const float rowH = sub1->LegacySize + 7 * dp;
// --- Manual | Auto-balance toggle ---
{
ImFont* segFont = Type().caption();
const char* segA = TR("mining_select_manual");
const char* segB = TR("mining_select_auto");
const float rnd = 4 * dp;
const float half = w * 0.5f;
const ImVec2 aMin(x, y), aMax(x + half, y + toggleH);
const ImVec2 bMin(x + half, y), bMax(x + w, y + toggleH);
dl->AddRectFilled(aMin, bMax, WithAlpha(OnSurface(), 15), rnd);
dl->AddRect(aMin, bMax, WithAlpha(OnSurface(), 40), rnd);
auto seg = [&](const ImVec2& mn, const ImVec2& mx, const char* label, bool selected) {
if (selected) dl->AddRectFilled(mn, mx, WithAlpha(Primary(), 180), rnd);
else if (material::IsRectHovered(mn, mx)) dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 20), rnd);
ImVec2 sz = segFont->CalcTextSizeA(segFont->LegacySize, FLT_MAX, 0, label);
dl->AddText(segFont, segFont->LegacySize,
ImVec2(mn.x + (mx.x - mn.x - sz.x) * 0.5f, mn.y + (mx.y - mn.y - sz.y) * 0.5f),
selected ? IM_COL32(255, 255, 255, 230) : OnSurfaceMedium(), label);
};
seg(aMin, aMax, segA, !autoMode);
seg(bMin, bMax, segB, autoMode);
ImGui::SetCursorScreenPos(aMin);
ImGui::InvisibleButton("##CardSelManual", ImVec2(half, toggleH));
if (ImGui::IsItemClicked() && autoMode) {
app->settings()->setPoolSelectMode(config::Settings::PoolSelectMode::Manual);
app->settings()->save();
}
if (material::IsRectHovered(aMin, aMax)) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_manual_desc"));
}
ImGui::SetCursorScreenPos(bMin);
ImGui::InvisibleButton("##CardSelAuto", ImVec2(w - half, toggleH));
if (ImGui::IsItemClicked() && !autoMode) {
app->settings()->setPoolSelectMode(config::Settings::PoolSelectMode::AutoBalance);
app->settings()->save();
app->requestPoolBalanceRefresh();
}
if (material::IsRectHovered(bMin, bMax)) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_auto_balance_desc"));
}
}
y += toggleH + gap;
// --- Metric stack (label left, value right) ---
auto metricRow = [&](const char* label, const std::string& val, ImU32 valCol) {
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(x, y + (rowH - ovFont->LegacySize) * 0.5f), OnSurfaceMedium(), label);
ImVec2 vSz = sub1->CalcTextSizeA(sub1->LegacySize, FLT_MAX, 0, val.c_str());
dl->AddText(sub1, sub1->LegacySize,
ImVec2(x + w - vSz.x, y + (rowH - sub1->LegacySize) * 0.5f), valCol, val.c_str());
y += rowH;
};
const auto& pm = state.pool_mining;
metricRow(TR("mining_local_hashrate"), FormatHashrate(pm.hashrate_10s),
pm.xmrig_running ? Success() : OnSurfaceDisabled());
metricRow(TR("mining_pool_hashrate"), FormatHashrate(pm.pool_hashrate),
pm.pool_hashrate > 0 ? OnSurface() : OnSurfaceDisabled());
{
char sharesBuf[64];
snprintf(sharesBuf, sizeof(sharesBuf), "%lld / %lld",
(long long)pm.accepted, (long long)pm.rejected);
metricRow(TR("mining_shares"), sharesBuf, OnSurface());
}
{
char uptBuf[64];
int64_t up = pm.uptime_sec;
if (up <= 0) snprintf(uptBuf, sizeof(uptBuf), "N/A");
else if (up < 3600) snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
else snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
metricRow(TR("mining_uptime"), uptBuf, OnSurface());
}
y += gap * 0.5f;
// --- POOLS (N) header + Refresh ---
{
char hdr[48];
snprintf(hdr, sizeof(hdr), "%s (%d)", TR("mining_pools_header"),
(int)util::knownPools().size());
dl->AddText(ovFont, ovFont->LegacySize, ImVec2(x, y), OnSurfaceMedium(), hdr);
ImFont* icoFont = Type().iconSmall();
const char* icon = ICON_MD_REFRESH;
ImVec2 iSz = icoFont->CalcTextSizeA(icoFont->LegacySize, FLT_MAX, 0, icon);
float btnS = ovFont->LegacySize + 6 * dp;
ImVec2 rbMin(x + w - btnS, y - 2 * dp);
ImVec2 rbMax(x + w, y - 2 * dp + btnS);
ImGui::SetCursorScreenPos(rbMin);
ImGui::InvisibleButton("##PoolRefresh", ImVec2(btnS, btnS));
bool rhov = ImGui::IsItemHovered();
if (rhov) {
dl->AddRectFilled(rbMin, rbMax, StateHover(), 3 * dp);
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("mining_refresh"));
}
dl->AddText(icoFont, icoFont->LegacySize,
ImVec2((rbMin.x + rbMax.x - iSz.x) * 0.5f, (rbMin.y + rbMax.y - iSz.y) * 0.5f),
OnSurfaceMedium(), icon);
if (ImGui::IsItemClicked()) app->requestPoolBalanceRefresh();
}
y += ovFont->LegacySize + 4 * dp;
// --- Scrollable pool list (fixed height → constant card height for any N) ---
const float listH = std::max(rowH, cardMax.y - y - pad * 0.75f);
ImGui::SetCursorScreenPos(ImVec2(x, y));
ImGui::BeginChild("##PoolList", ImVec2(w, listH), false, ImGuiWindowFlags_NoBackground);
{
ImDrawList* cdl = ImGui::GetWindowDrawList();
const auto snap = app->poolStatsSnapshot();
const util::KnownPool* current = util::findKnownPoolByUrl(app->settings()->getPoolUrl());
const float childW = ImGui::GetContentRegionAvail().x;
const float listRowH = capFont->LegacySize + 10 * dp;
for (const auto& kp : util::knownPools()) {
ImGui::PushID(kp.id.c_str());
const bool isCurrent = current && current->id == kp.id;
const auto it = snap.byId.find(kp.id);
const bool haveHr = (it != snap.byId.end() && it->second.ok);
ImVec2 rMin = ImGui::GetCursorScreenPos();
ImVec2 rMax(rMin.x + childW, rMin.y + listRowH);
bool hov = false, clk = false;
if (!autoMode) {
ImGui::InvisibleButton("##prow", ImVec2(childW, listRowH));
hov = ImGui::IsItemHovered();
clk = ImGui::IsItemClicked();
} else {
ImGui::Dummy(ImVec2(childW, listRowH));
}
if (isCurrent) cdl->AddRectFilled(rMin, rMax, WithAlpha(Primary(), 30), 3 * dp);
else if (hov) cdl->AddRectFilled(rMin, rMax, StateHover(), 3 * dp);
const float cy = rMin.y + listRowH * 0.5f;
cdl->AddCircleFilled(ImVec2(rMin.x + 6 * dp, cy), 3.0f * dp,
isCurrent ? Success() : OnSurfaceDisabled());
const float textY = rMin.y + (listRowH - capFont->LegacySize) * 0.5f;
// Show the short host label (the narrow card can't fit host:port); the
// full stratum URL is in the hover tooltip.
cdl->AddText(capFont, capFont->LegacySize, ImVec2(rMin.x + 16 * dp, textY),
isCurrent ? Primary() : OnSurface(), kp.label.c_str());
char right[64];
std::string hrStr = haveHr ? FormatHashrate(it->second.hashrateHs) : std::string("");
snprintf(right, sizeof(right), "%s %.0f%%", hrStr.c_str(), kp.feePercent);
ImVec2 rSz = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, right);
cdl->AddText(capFont, capFont->LegacySize,
ImVec2(rMax.x - rSz.x - 6 * dp, textY), OnSurfaceMedium(), right);
if (!autoMode && hov) {
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", kp.stratum.c_str());
}
if (clk) {
strncpy(s_pool_url, kp.stratum.c_str(), sizeof(s_pool_url) - 1);
s_pool_url[sizeof(s_pool_url) - 1] = '\0';
s_pool_settings_dirty = true;
}
ImGui::PopID();
}
}
ImGui::EndChild();
}
void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
ImDrawList* dl, ImFont* capFont, ImFont* sub1, ImFont* ovFont,
float dp, float vs, float gap, float pad, float availWidth,
const GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode)
const GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode,
App* app, char (&s_pool_url)[256], bool& s_pool_settings_dirty)
{
const bool s_pool_mode = poolMode; // alias: keep the moved body's identifier verbatim
(void)vs;
const bool s_pool_mode = poolMode;
ImU32 greenCol = Success();
// Determine view mode first
bool showLogView = s_pool_mode
? (s_show_pool_log && !state.pool_mining.log_lines.empty())
: (s_show_solo_log && !mining.log_lines.empty());
bool hasLogContent = s_pool_mode
? !state.pool_mining.log_lines.empty()
: !mining.log_lines.empty();
// Use pool hashrate history when in pool mode, solo otherwise
const std::vector<double>& chartHistory = s_pool_mode
? state.pool_mining.hashrate_history
: mining.hashrate_history;
bool hasChartContent = chartHistory.size() >= 2;
// Stat row height (single line: overline + value)
float statRowH = ovFont->LegacySize + Layout::spacingXs() + sub1->LegacySize + Layout::spacingSm();
float totalCardH = statRowH + chartBudgetH + pad;
bool& showLogFlag = s_pool_mode ? s_show_pool_log : s_show_solo_log;
// ================================================================
// POOL MODE — split card: [ left pool card ][ chart / log ]
// ================================================================
if (s_pool_mode) {
// Left card height driven by its content so the pool list always fits;
// the whole row is at least as tall as the old stats+chart card.
const float toggleH = sub1->LegacySize + 10 * dp;
const float mRowH = sub1->LegacySize + 7 * dp;
const float listMinH = (capFont->LegacySize + 10 * dp) * 2.6f; // ~23 rows before scroll
float leftContentH = pad * 0.75f + toggleH + gap
+ mRowH * 4.0f + gap * 0.5f
+ (ovFont->LegacySize + 4 * dp) + listMinH + pad * 0.75f;
float cardH = std::max(leftContentH, statRowH + chartBudgetH + pad);
ImVec2 cardMin = ImGui::GetCursorScreenPos();
float leftW = std::clamp(availWidth * 0.25f, 180.0f * dp, availWidth * 0.45f);
float colGap = gap;
float rightW = availWidth - leftW - colGap;
ImVec2 leftMin = cardMin;
ImVec2 leftMax(cardMin.x + leftW, cardMin.y + cardH);
ImVec2 rightMin(leftMax.x + colGap, cardMin.y);
ImVec2 rightMax(cardMin.x + availWidth, cardMin.y + cardH);
DrawGlassPanel(dl, leftMin, leftMax, glassSpec);
DrawGlassPanel(dl, rightMin, rightMax, glassSpec);
RenderLeftPoolCard(app, state, dl, capFont, sub1, ovFont, dp, gap, pad,
leftMin, leftMax, s_pool_url, s_pool_settings_dirty);
// Right panel: live log (if toggled + available) else the sparkline.
if (showLogView) {
float logPad = pad * 0.5f;
DrawMiningLogView(ImVec2(rightMin.x + logPad, rightMin.y + logPad),
rightW - logPad * 2, cardH - logPad * 2,
state.pool_mining.log_lines, "##PoolLogText");
} else if (hasChartContent) {
DrawHashrateSparkline(dl,
ImVec2(rightMin.x + pad, rightMin.y + statRowH * 0.5f),
ImVec2(rightMax.x - pad, rightMax.y),
chartHistory, capFont, dp);
} else {
const char* hint = TR("mining_chart_start");
ImVec2 hs = capFont->CalcTextSizeA(capFont->LegacySize, FLT_MAX, 0, hint);
dl->AddText(capFont, capFont->LegacySize,
ImVec2((rightMin.x + rightMax.x - hs.x) * 0.5f,
(rightMin.y + rightMax.y - hs.y) * 0.5f),
OnSurfaceDisabled(), hint);
}
// Chart/log toggle button, top-right of the RIGHT panel.
if (hasLogContent || hasChartContent) {
ImFont* iconFont = Type().iconSmall();
const char* toggleIcon = showLogFlag ? ICON_MD_SHOW_CHART : ICON_MD_ARTICLE;
const char* toggleTip = showLogFlag ? TR("mining_show_chart") : TR("mining_show_log");
ImVec2 iconSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, toggleIcon);
float btnSize = iconSz.y + 8 * dp;
ImVec2 btnMin(rightMax.x - pad - btnSize, rightMin.y + pad * 0.5f);
ImVec2 btnMax(btnMin.x + btnSize, btnMin.y + btnSize);
ImVec2 btnCenter((btnMin.x + btnMax.x) * 0.5f, (btnMin.y + btnMax.y) * 0.5f);
bool hov = IsRectHovered(btnMin, btnMax);
if (hov) {
dl->AddCircleFilled(btnCenter, btnSize * 0.5f, StateHover());
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", toggleTip);
}
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(btnCenter.x - iconSz.x * 0.5f, btnCenter.y - iconSz.y * 0.5f),
OnSurfaceMedium(), toggleIcon);
if (hov && ImGui::IsMouseClicked(0)) showLogFlag = !showLogFlag;
}
ImGui::SetCursorScreenPos(cardMin);
ImGui::Dummy(ImVec2(availWidth, cardH));
ImGui::Dummy(ImVec2(0, gap));
return;
}
// ================================================================
// SOLO MODE — full-width card: stat columns on top, chart/log below
// ================================================================
float totalCardH = statRowH + chartBudgetH + pad;
ImVec2 cardMin = ImGui::GetCursorScreenPos();
ImVec2 cardMax(cardMin.x + availWidth, cardMin.y + totalCardH);
DrawGlassPanel(dl, cardMin, cardMax, glassSpec);
bool& showLogFlag = s_pool_mode ? s_show_pool_log : s_show_solo_log;
if (showLogView) {
// --- Full-card log view (selectable + copyable) ---
const std::vector<std::string>& logLines = s_pool_mode
? state.pool_mining.log_lines
: mining.log_lines;
// Build a single string buffer for InputTextMultiline
static std::string s_log_buf;
s_log_buf.clear();
for (const auto& line : logLines) {
if (!line.empty()) {
s_log_buf += line;
s_log_buf += '\n';
}
}
float logPad = pad * 0.5f;
float logW = availWidth - logPad * 2;
float logH = totalCardH - logPad * 2;
ImGui::SetCursorScreenPos(ImVec2(cardMin.x + logPad, cardMin.y + logPad));
ImGui::PushStyleColor(ImGuiCol_Text, ImGui::ColorConvertU32ToFloat4(OnSurface()));
ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0, 0, 0, 0));
ImFont* monoFont = Type().body2();
ImGui::PushFont(monoFont);
const char* inputId = s_pool_mode ? "##PoolLogText" : "##SoloLogText";
ImGui::InputTextMultiline(inputId,
const_cast<char*>(s_log_buf.c_str()), s_log_buf.size() + 1,
ImVec2(logW, logH),
ImGuiInputTextFlags_ReadOnly);
ImGui::PopFont();
ImGui::PopStyleColor(2);
// Reset cursor to end of card after the input
DrawMiningLogView(ImVec2(cardMin.x + logPad, cardMin.y + logPad),
availWidth - logPad * 2, totalCardH - logPad * 2,
mining.log_lines, "##SoloLogText");
ImGui::SetCursorScreenPos(ImVec2(cardMin.x, cardMax.y));
ImGui::Dummy(ImVec2(availWidth, 0)); // Register position with layout system
ImGui::Dummy(ImVec2(availWidth, 0));
} else {
// --- Stats + Chart view ---
double est_hours = EstimateHoursToBlock(mining.localHashrate, mining.networkHashrate, mining.difficulty);
// Pool vs Solo stats — different columns
std::string col1Str, col2Str, col3Str, col4Str;
const char* col1Label;
const char* col2Label;
const char* col3Label;
const char* col4Label = nullptr;
ImU32 col1Col, col2Col, col3Col, col4Col = OnSurface();
const char* col1Label = TR("mining_local_hashrate");
std::string col1Str = FormatHashrate(mining.localHashrate);
ImU32 col1Col = mining.generate ? greenCol : OnSurfaceDisabled();
const char* col2Label = TR("mining_network");
std::string col2Str = FormatHashrate(mining.networkHashrate);
const char* col3Label = TR("mining_est_block");
std::string col3Str = FormatEstTime(est_hours);
int numStats = 3;
if (s_pool_mode) {
col1Label = TR("mining_local_hashrate");
col1Str = FormatHashrate(state.pool_mining.hashrate_10s);
col1Col = state.pool_mining.xmrig_running ? greenCol : OnSurfaceDisabled();
float statColW = (availWidth - pad * 2) / (float)numStats;
float sy = cardMin.y + pad * 0.5f;
struct StatEntry { const char* label; std::string value; ImU32 col; };
StatEntry stats[] = {
{ col1Label, col1Str, col1Col },
{ col2Label, col2Str, OnSurface() },
{ col3Label, col3Str, OnSurface() },
};
for (int si = 0; si < numStats; si++) {
float sx = cardMin.x + pad + si * statColW;
float centerX = sx + statColW * 0.5f;
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[si].label);
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(centerX - lblSz.x * 0.5f, sy), OnSurfaceMedium(), stats[si].label);
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[si].value.c_str());
float valY = sy + ovFont->LegacySize + Layout::spacingXs();
dl->AddText(sub1, sub1->LegacySize,
ImVec2(centerX - valSz.x * 0.5f, valY), stats[si].col, stats[si].value.c_str());
col2Label = TR("mining_pool_hashrate");
col2Str = FormatHashrate(state.pool_mining.pool_hashrate);
col2Col = state.pool_mining.pool_hashrate > 0 ? OnSurface() : OnSurfaceDisabled();
col3Label = TR("mining_shares");
char sharesBuf[64];
snprintf(sharesBuf, sizeof(sharesBuf), "%lld / %lld",
(long long)state.pool_mining.accepted,
(long long)state.pool_mining.rejected);
col3Str = sharesBuf;
col3Col = OnSurface();
col4Label = TR("mining_uptime");
int64_t up = state.pool_mining.uptime_sec;
char uptBuf[64];
if (up <= 0)
snprintf(uptBuf, sizeof(uptBuf), "N/A");
else if (up < 3600)
snprintf(uptBuf, sizeof(uptBuf), "%lldm %llds", (long long)(up / 60), (long long)(up % 60));
else
snprintf(uptBuf, sizeof(uptBuf), "%lldh %lldm", (long long)(up / 3600), (long long)((up % 3600) / 60));
col4Str = uptBuf;
col4Col = OnSurface();
numStats = 4;
} else {
double est_hours = EstimateHoursToBlock(mining.localHashrate, mining.networkHashrate, mining.difficulty);
col1Label = TR("mining_local_hashrate");
col1Str = FormatHashrate(mining.localHashrate);
col1Col = mining.generate ? greenCol : OnSurfaceDisabled();
col2Label = TR("mining_network");
col2Str = FormatHashrate(mining.networkHashrate);
col2Col = OnSurface();
col3Label = TR("mining_est_block");
col3Str = FormatEstTime(est_hours);
col3Col = OnSurface();
}
// Draw stat values as inline columns at top of card
{
float statColW = (availWidth - pad * 2) / (float)numStats;
float sy = cardMin.y + pad * 0.5f;
struct StatEntry { const char* label; const char* value; ImU32 col; };
char c1Buf[64], c2Buf[64], c3Buf[64], c4Buf[64];
snprintf(c1Buf, sizeof(c1Buf), "%s", col1Str.c_str());
snprintf(c2Buf, sizeof(c2Buf), "%s", col2Str.c_str());
snprintf(c3Buf, sizeof(c3Buf), "%s", col3Str.c_str());
if (numStats > 3) snprintf(c4Buf, sizeof(c4Buf), "%s", col4Str.c_str());
StatEntry stats[] = {
{ col1Label, c1Buf, col1Col },
{ col2Label, c2Buf, col2Col },
{ col3Label, c3Buf, col3Col },
{ col4Label ? col4Label : "", c4Buf, col4Col },
};
for (int si = 0; si < numStats; si++) {
float sx = cardMin.x + pad + si * statColW;
float centerX = sx + statColW * 0.5f;
ImVec2 lblSz = ovFont->CalcTextSizeA(ovFont->LegacySize, 10000, 0, stats[si].label);
dl->AddText(ovFont, ovFont->LegacySize,
ImVec2(centerX - lblSz.x * 0.5f, sy), OnSurfaceMedium(), stats[si].label);
ImVec2 valSz = sub1->CalcTextSizeA(sub1->LegacySize, 10000, 0, stats[si].value);
float valY = sy + ovFont->LegacySize + Layout::spacingXs();
dl->AddText(sub1, sub1->LegacySize,
ImVec2(centerX - valSz.x * 0.5f, valY), stats[si].col, stats[si].value);
// Trend arrow for hashrate (first column only)
if (si == 0 && chartHistory.size() >= 6) {
size_t hn = chartHistory.size();
double recent = (chartHistory[hn-1] + chartHistory[hn-2] + chartHistory[hn-3]) / 3.0;
double older = (chartHistory[hn-4] + chartHistory[hn-5] + chartHistory[hn-6]) / 3.0;
ImFont* iconFont = Type().iconSmall();
float arrowX = centerX + valSz.x * 0.5f + Layout::spacingSm();
if (recent > older * 1.02) {
const char* icon = ICON_MD_TRENDING_UP;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(arrowX, valY + sub1->LegacySize * 0.5f - iSz.y * 0.5f),
WithAlpha(Success(), 220), icon);
} else if (recent < older * 0.98) {
const char* icon = ICON_MD_TRENDING_DOWN;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(arrowX, valY + sub1->LegacySize * 0.5f - iSz.y * 0.5f),
WithAlpha(Error(), 220), icon);
}
if (si == 0 && chartHistory.size() >= 6) {
size_t hn = chartHistory.size();
double recent = (chartHistory[hn-1] + chartHistory[hn-2] + chartHistory[hn-3]) / 3.0;
double older = (chartHistory[hn-4] + chartHistory[hn-5] + chartHistory[hn-6]) / 3.0;
ImFont* iconFont = Type().iconSmall();
float arrowX = centerX + valSz.x * 0.5f + Layout::spacingSm();
if (recent > older * 1.02) {
const char* icon = ICON_MD_TRENDING_UP;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(arrowX, valY + sub1->LegacySize * 0.5f - iSz.y * 0.5f),
WithAlpha(Success(), 220), icon);
} else if (recent < older * 0.98) {
const char* icon = ICON_MD_TRENDING_DOWN;
ImVec2 iSz = iconFont->CalcTextSizeA(iconFont->LegacySize, 1000.0f, 0.0f, icon);
dl->AddText(iconFont, iconFont->LegacySize,
ImVec2(arrowX, valY + sub1->LegacySize * 0.5f - iSz.y * 0.5f),
WithAlpha(Error(), 220), icon);
}
}
}
// Sparkline chart below stats
if (hasChartContent) {
float chartTop = cardMin.y + statRowH;
float chartBot = cardMax.y;
// Compute Y range
double yMin = *std::min_element(chartHistory.begin(), chartHistory.end());
double yMax = *std::max_element(chartHistory.begin(), chartHistory.end());
if (yMax <= yMin) { yMax = yMin + 1.0; }
double yRange = yMax - yMin;
double yPad2 = yRange * 0.1;
yMin -= yPad2;
yMax += yPad2;
float plotLeft = cardMin.x + pad;
float plotRight = cardMax.x - pad;
float plotTop = chartTop + capFont->LegacySize + 4 * dp;
float plotBottom = chartBot - capFont->LegacySize * 2 - 16 * dp;
float plotW = plotRight - plotLeft;
float plotH = std::max(1.0f, plotBottom - plotTop);
// Build raw data points — evenly spaced across the plot.
// No smooth-scroll animation: the chart updates in-place
// when new data arrives without any interim compression.
size_t n = chartHistory.size();
float stepW = (n > 1) ? plotW / (float)(n - 1) : plotW;
std::vector<ImVec2> rawPts(n);
for (size_t i = 0; i < n; i++) {
float x = plotLeft + (float)i * stepW;
float y = plotBottom - (float)((chartHistory[i] - yMin) / (yMax - yMin)) * plotH;
rawPts[i] = ImVec2(x, y);
}
// Catmull-Rom spline interpolation for smooth curve
// Subdivisions are adaptive: more when points are far apart,
// none when points are already sub-2px apart.
std::vector<ImVec2> points;
if (n <= 2) {
points = rawPts;
} else {
points.reserve(n * 4); // conservative estimate
for (size_t i = 0; i + 1 < n; i++) {
ImVec2 p0 = rawPts[i > 0 ? i - 1 : 0];
ImVec2 p1 = rawPts[i];
ImVec2 p2 = rawPts[i + 1];
ImVec2 p3 = rawPts[i + 2 < n ? i + 2 : n - 1];
// Adaptive subdivision: ~1 segment per 3px of distance
float dx = p2.x - p1.x, dy = p2.y - p1.y;
float dist = sqrtf(dx * dx + dy * dy);
int subdivs = std::clamp((int)(dist / 3.0f), 1, 16);
for (int s = 0; s < subdivs; s++) {
float t = (float)s / (float)subdivs;
float t2 = t * t;
float t3 = t2 * t;
float q0 = -t3 + 2.0f * t2 - t;
float q1 = 3.0f * t3 - 5.0f * t2 + 2.0f;
float q2 = -3.0f * t3 + 4.0f * t2 + t;
float q3 = t3 - t2;
float sx = 0.5f * (p0.x * q0 + p1.x * q1 + p2.x * q2 + p3.x * q3);
float sy = 0.5f * (p0.y * q0 + p1.y * q1 + p2.y * q2 + p3.y * q3);
// Clamp Y to plot bounds to prevent Catmull-Rom overshoot
sy = std::clamp(sy, plotTop, plotBottom);
points.push_back(ImVec2(sx, sy));
}
}
points.push_back(rawPts[n - 1]); // final point
}
// Fill under curve (single concave polygon to avoid AA seam shimmer)
if (points.size() >= 2) {
for (size_t i = 0; i < points.size(); i++)
dl->PathLineTo(points[i]);
dl->PathLineTo(ImVec2(points.back().x, plotBottom));
dl->PathLineTo(ImVec2(points.front().x, plotBottom));
dl->PathFillConcave(WithAlpha(Success(), 25));
}
// Green line
dl->AddPolyline(points.data(), (int)points.size(),
WithAlpha(Success(), 200), ImDrawFlags_None, schema::UI().drawElement("tabs.mining", "chart-line-thickness").size);
// Y-axis labels
std::string yMaxStr = FormatHashrate(yMax);
std::string yMinStr = FormatHashrate(yMin);
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotTop - capFont->LegacySize - 2 * dp), OnSurfaceDisabled(), yMaxStr.c_str());
dl->AddText(capFont, capFont->LegacySize, ImVec2(plotLeft + 2 * dp, plotBottom + 4 * dp), OnSurfaceDisabled(), yMinStr.c_str());
// X-axis labels
dl->AddText(capFont, capFont->LegacySize,
ImVec2(plotLeft, chartBot - capFont->LegacySize - 2 * dp),
OnSurfaceDisabled(),
chartHistory.size() >= 300 ? TR("mining_chart_5m_ago") :
chartHistory.size() >= 60 ? TR("mining_chart_1m_ago") : TR("mining_chart_start"));
std::string nowLbl = TR("mining_chart_now");
ImVec2 nowSz = capFont->CalcTextSizeA(capFont->LegacySize, 10000, 0, nowLbl.c_str());
dl->AddText(capFont, capFont->LegacySize,
ImVec2(plotRight - nowSz.x, chartBot - capFont->LegacySize - 2 * dp),
OnSurfaceDisabled(), nowLbl.c_str());
DrawHashrateSparkline(dl,
ImVec2(cardMin.x + pad, cardMin.y + statRowH),
ImVec2(cardMax.x - pad, cardMax.y),
chartHistory, capFont, dp);
}
// Advance cursor past the card (stats/chart view only)
ImGui::Dummy(ImVec2(availWidth, totalCardH));
}
// --- Toggle button in top-right corner ---
// Rendered after content so the Hand cursor takes priority over
// the InputTextMultiline text-cursor when hovering the button.
if (hasLogContent || hasChartContent) {
ImFont* iconFont = Type().iconSmall();
const char* toggleIcon = showLogFlag ? ICON_MD_SHOW_CHART : ICON_MD_ARTICLE;
@@ -331,7 +533,6 @@ void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
ImVec2 btnMin(btnX, btnY);
ImVec2 btnMax(btnX + btnSize, btnY + btnSize);
ImVec2 btnCenter((btnMin.x + btnMax.x) * 0.5f, (btnMin.y + btnMax.y) * 0.5f);
bool hov = IsRectHovered(btnMin, btnMax);
if (hov) {
dl->AddCircleFilled(btnCenter, btnSize * 0.5f, StateHover());

View File

@@ -9,16 +9,20 @@
#include "imgui.h"
namespace dragonx {
class App;
namespace ui {
// Renders the mining "Hashrate + Stats" card (stat values on top, hashrate chart or live log
// below). Extracted verbatim from the monolithic mining tab; the immediate-mode layout context is
// passed in so the rendering flows in sequence exactly as before. `poolMode` is the parent's
// solo/pool toggle.
// Renders the mining "Hashrate + Stats" card. In SOLO mode this is a full-width card (stat values
// on top, hashrate chart or live log below). In POOL mode it splits into a left card (Manual/Auto
// toggle + pool metric stack + a fixed-height scrolling pool list) and a right region (chart or
// log) — so toggling Manual/Auto or adding pools never reflows the tab. `poolMode` is the parent's
// solo/pool toggle; `s_pool_url` / `s_pool_settings_dirty` are the parent's pool-selection buffer
// and dirty flag (a pool-row click writes them, reusing the parent's save+restart path).
void RenderMiningStats(const WalletState& state, const MiningInfo& mining,
ImDrawList* dl, ImFont* capFont, ImFont* sub1, ImFont* ovFont,
float dp, float vs, float gap, float pad, float availWidth,
const material::GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode);
const material::GlassPanelSpec& glassSpec, float chartBudgetH, bool poolMode,
App* app, char (&s_pool_url)[256], bool& s_pool_settings_dirty);
} // namespace ui
} // namespace dragonx

View File

@@ -278,7 +278,8 @@ static void RenderMiningTabContent(App* app)
// (Or full-card log view when toggled in pool mode)
// ================================================================
RenderMiningStats(state, mining, dl, capFont, sub1, ovFont, dp, vs, gap, pad,
availWidth, glassSpec, chartBudgetH, s_pool_mode);
availWidth, glassSpec, chartBudgetH, s_pool_mode,
app, s_pool_url, s_pool_settings_dirty);
// ================================================================
// EARNINGS — Horizontal row card (Today | Yesterday | All Time | Est. Daily)

View File

@@ -257,6 +257,7 @@ void I18n::loadBuiltinEnglish()
strings_["setup_wizard"] = "Run Setup Wizard...";
// RPC / Explorer settings
strings_["rpc_connection"] = "RPC Connection...";
strings_["rpc_host"] = "Host";
strings_["rpc_port"] = "Port";
strings_["rpc_user"] = "Username";
@@ -403,6 +404,8 @@ void I18n::loadBuiltinEnglish()
strings_["tt_open_dir"] = "Click to open in file explorer";
strings_["settings_open_data_dir"] = "Open data folder";
strings_["tt_open_data_dir"] = "Open the folder with your wallet and blockchain data in the file manager";
strings_["settings_open_app_dir"] = "Open app folder";
strings_["tt_open_app_dir"] = "Open the ObsidianDragon folder (settings, themes, logs) in the file manager";
strings_["tt_rpc_host"] = "Hostname of the DragonX daemon";
strings_["tt_rpc_user"] = "RPC authentication username";
strings_["tt_rpc_port"] = "Port for daemon RPC connections";
@@ -947,6 +950,8 @@ void I18n::loadBuiltinEnglish()
// --- Console Tab ---
strings_["console_auto_scroll"] = "Auto-scroll";
strings_["console_available_commands"] = "Available commands:";
strings_["console_quit_note"] = "'quit'/'exit' aren't needed here — just close the window.";
strings_["lite_console_help_passthrough"] = "Any other input runs as a lite-wallet console command.";
strings_["console_capturing_output"] = "Capturing daemon output...";
strings_["console_clear"] = "Clear";
strings_["console_clear_console"] = "Clear Console";
@@ -980,6 +985,8 @@ void I18n::loadBuiltinEnglish()
strings_["console_help_setgenerate"] = " setgenerate - Control mining";
strings_["console_help_stop"] = " stop - Stop the daemon";
strings_["console_line_count"] = "%zu lines";
strings_["console_matches"] = "matches";
strings_["console_copy_value"] = "Copy";
strings_["console_new_lines"] = "%d new lines";
strings_["console_no_daemon"] = "No daemon";
strings_["console_not_connected"] = "Error: Not connected to daemon";
@@ -1052,13 +1059,31 @@ void I18n::loadBuiltinEnglish()
strings_["market_24h"] = "24h";
strings_["market_24h_volume"] = "24H VOLUME";
strings_["market_6h"] = "6h";
strings_["market_attribution"] = "Price data from NonKYC";
strings_["market_attribution"] = "Price data from CoinGecko";
strings_["market_btc_price"] = "BTC PRICE";
strings_["market_no_history"] = "No price history available";
strings_["market_no_price"] = "No price data";
strings_["market_now"] = "Now";
strings_["market_pct_shielded"] = "%.0f%% Shielded";
strings_["market_portfolio"] = "PORTFOLIO";
strings_["market_portfolio"] = "MY DRGX";
strings_["portfolio_all_funds"] = "All funds";
strings_["portfolio_manage"] = "Manage\xE2\x80\xA6";
strings_["portfolio_manage_title"] = "Manage portfolio";
strings_["portfolio_add_entry"] = "Add entry";
strings_["portfolio_new_entry"] = "New entry";
strings_["portfolio_label"] = "Label";
strings_["portfolio_addresses_sel"] = "%d selected";
strings_["portfolio_all_shielded"] = "All shielded";
strings_["portfolio_all_transparent"] = "All transparent";
strings_["portfolio_clear_sel"] = "Clear";
strings_["portfolio_no_entries"] = "No custom entries yet. Add one to track a group of addresses.";
strings_["portfolio_edit"] = "Edit";
strings_["portfolio_delete"] = "Delete";
strings_["portfolio_save"] = "Save";
strings_["portfolio_cancel"] = "Cancel";
strings_["portfolio_add_to"] = "Add to portfolio";
strings_["portfolio_remove_from"] = "Remove from portfolio";
strings_["market_24h_change"] = "24h";
strings_["market_price_loading"] = "Loading price data...";
strings_["market_price_unavailable"] = "Price data unavailable";
strings_["market_refresh_price"] = "Refresh price data";
@@ -1110,6 +1135,17 @@ void I18n::loadBuiltinEnglish()
strings_["mining_pool"] = "Pool";
strings_["mining_pool_hashrate"] = "Pool Hashrate";
strings_["mining_pool_url"] = "Pool URL";
// Pool selection mode + auto-balance (mining tab).
strings_["mining_select_manual"] = "Manual";
strings_["mining_select_auto"] = "Auto-balance";
strings_["mining_manual_desc"] = "Manual: mine to the pool you pick from the list below.";
strings_["mining_auto_balance_desc"] = "Spreads miners across the official pools by hashrate to help decentralize the network. Checks each pool periodically.";
strings_["mining_suggested_pools"] = "Suggested pools";
strings_["mining_pools_header"] = "POOLS";
strings_["mining_pool_fee"] = "Fee";
strings_["mining_mining_here"] = "mining here";
strings_["mining_refresh"] = "Refresh";
strings_["mining_auto_balanced_to"] = "Auto-balanced mining to";
strings_["mining_recent_blocks"] = "RECENT BLOCKS";
strings_["mining_recent_payouts"] = "RECENT POOL PAYOUTS";
strings_["mining_remove"] = "Remove";
@@ -1207,17 +1243,6 @@ void I18n::loadBuiltinEnglish()
strings_["daemon_update_downgrade_note"] = "Older versions may be incompatible with your current chain data. Installing a different version takes effect after a daemon restart.";
// --- Lite Network tab (server browser) ---
strings_["lite_console_title"] = "Console";
strings_["lite_console_intro"] = "Diagnostic log + interactive console. Type a command (e.g. info, balance, list) and press Enter; type help for the full list.";
strings_["lite_console_status"] = "Status:";
strings_["lite_console_clear"] = "Clear";
strings_["lite_console_copy"] = "Copy";
strings_["lite_console_autoscroll"] = "Auto-scroll";
strings_["lite_console_empty"] = "No output yet. Type a command (try 'help') and press Enter.";
strings_["lite_console_input_hint"] = "Type a command (help, info, balance, list, sync, new zs)…";
strings_["lite_console_running"] = "running…";
strings_["lite_console_busy"] = "A command is already running — wait for it to finish.";
strings_["lite_console_quit_note"] = "'quit'/'exit' don't apply here — the embedded backend stays running with the app.";
strings_["lite_net_title"] = "Lite Servers";
strings_["lite_net_intro"] = "Pick a server to use, or let the wallet choose one at random. Changes apply immediately.";
strings_["lite_net_use_random"] = "Use a random server each time";

81
src/util/pool_registry.h Normal file
View File

@@ -0,0 +1,81 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// PoolRegistry — the small table of official DragonX mining pools plus the pure
// (no-I/O) helpers used to compare their hashrate and pick one for auto-balance.
//
// A pool is more than a URL: the two official pools speak DIFFERENT stats APIs
// (pool.dragonx.is = a custom /api/stats; pool.dragonx.cc = Miningcore /api/pools)
// and mine with DIFFERENT algo strings (rx/hush vs rx/dragonx), so each entry
// carries how to read its hashrate and which algo xmrig must be told to use.
//
// Everything here is pure and unit-tested (see tests/test_phase4.cpp); the live
// HTTP fetch lives in pool_stats_service.{h,cpp}.
#pragma once
#include <random>
#include <string>
#include <vector>
namespace dragonx {
namespace util {
// How to read a pool's total hashrate from its public stats JSON.
enum class PoolStatsSchema {
DragonXIs, // GET /api/stats -> pools.<name>.hashrate (custom)
Miningcore, // GET /api/pools -> pools[id==poolId].poolStats.poolHashrate (Miningcore)
};
struct KnownPool {
std::string id; // stable internal id, e.g. "dragonx-cc-pplns"
std::string label; // human label for the UI (host, sans port)
std::string stratum; // host:port the miner connects to
std::string algo; // xmrig algo string for THIS pool
std::string statsUrl; // absolute https URL of the stats endpoint
PoolStatsSchema schema = PoolStatsSchema::DragonXIs;
std::string miningcorePoolId; // Miningcore pool id (empty for DragonXIs)
double feePercent = 0.0; // pool fee, for display only
bool official = true;
};
// A pool's live total hashrate sample (H/s). ok=false when the fetch/parse failed.
struct PoolHashrate {
std::string id;
double hashrateHs = 0.0;
bool ok = false;
};
// The built-in official pools (PPLNS only — never a SOLO pool, whose hashrate is
// meaningless to balance against). Stable order.
const std::vector<KnownPool>& knownPools();
// The known pool whose stratum matches `url` (host, and port when both specify one),
// or nullptr. `url` may be a bare host, host:port, or carry a scheme/userinfo/path.
const KnownPool* findKnownPoolByUrl(const std::string& url);
// The algo xmrig must use for `url`: the matching known pool's algo, else `fallback`.
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback);
// Parse a pool's total hashrate (H/s) out of its stats JSON per `schema`.
// `miningcorePoolId` selects the pool entry for the Miningcore schema (ignored
// otherwise). Sets ok=false and returns 0 on any parse failure / missing field.
double parsePoolHashrate(PoolStatsSchema schema, const std::string& json,
const std::string& miningcorePoolId, bool& ok);
// Weighted-random pick among the usable (ok==true) pools: probability is inversely
// proportional to hashrate (smaller pools favored), so miners spread out instead of
// all stampeding to the single lowest pool. The current pool (`currentId`, may be
// empty) gets a stickiness multiplier so re-balancing rarely churns the miner.
// Returns the chosen pool id; returns `currentId` (or the sole usable id) when fewer
// than two pools are usable.
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
const std::string& currentId,
std::mt19937& rng);
// Stickiness applied to the incumbent pool's weight in chooseWeightedPool.
inline constexpr double kIncumbentStayBias = 2.0;
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,208 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// Pure (no-I/O) implementation of the pool registry + hashrate helpers. Links
// nlohmann_json but NOT libcurl, so it can be exercised directly from the test
// binary (mirrors util/xmrig_updater_core.cpp).
#include "pool_registry.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cctype>
#include <cstddef>
namespace dragonx {
namespace util {
using json = nlohmann::json;
const std::vector<KnownPool>& knownPools()
{
// NOTE: only PPLNS (shared) pools belong here. The .cc SOLO pool
// (dragonx-solo, ports 5555/6666) is intentionally omitted — its hashrate
// is not comparable for network balancing.
static const std::vector<KnownPool> pools = {
KnownPool{
"dragonx-is", "pool.dragonx.is", "pool.dragonx.is:3433", "rx/hush",
"https://pool.dragonx.is/api/stats", PoolStatsSchema::DragonXIs,
/*miningcorePoolId=*/"", /*feePercent=*/0.0, /*official=*/true,
},
KnownPool{
// The mining (stratum) host is us.dragonx.cc — pool.dragonx.cc is the
// Cloudflare-proxied web/API host and does NOT accept stratum on :3333.
// Stats still come from pool.dragonx.cc/api/pools (proxied HTTP is fine).
"dragonx-cc-pplns", "pool.dragonx.cc", "us.dragonx.cc:3333", "rx/dragonx",
"https://pool.dragonx.cc/api/pools", PoolStatsSchema::Miningcore,
/*miningcorePoolId=*/"dragonx-pplns", /*feePercent=*/3.0, /*official=*/true,
},
};
return pools;
}
namespace {
std::string trimmed(const std::string& s)
{
const auto b = s.find_first_not_of(" \t\r\n");
if (b == std::string::npos) return {};
const auto e = s.find_last_not_of(" \t\r\n");
return s.substr(b, e - b + 1);
}
// Extract "host[:port]" from a URL that may carry a scheme, userinfo, path or query.
// Our pools are plain host:port endpoints (no IPv6 literals), so a last-colon split
// for the port is sufficient.
std::string hostPortOf(const std::string& url)
{
std::string s = trimmed(url);
if (const auto scheme = s.find("://"); scheme != std::string::npos)
s = s.substr(scheme + 3);
if (const auto at = s.find('@'); at != std::string::npos)
s = s.substr(at + 1);
if (const auto cut = s.find_first_of("/?#"); cut != std::string::npos)
s = s.substr(0, cut);
return s;
}
void splitHostPort(const std::string& hostport, std::string& host, std::string& port)
{
if (const auto colon = hostport.rfind(':'); colon == std::string::npos) {
host = hostport;
port.clear();
} else {
host = hostport.substr(0, colon);
port = hostport.substr(colon + 1);
}
std::transform(host.begin(), host.end(), host.begin(),
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
}
// Same endpoint when hosts match and (either side omits a port, or the ports match).
bool sameEndpoint(const std::string& a, const std::string& b)
{
std::string ha, pa, hb, pb;
splitHostPort(hostPortOf(a), ha, pa);
splitHostPort(hostPortOf(b), hb, pb);
if (ha.empty() || ha != hb) return false;
if (pa.empty() || pb.empty()) return true;
return pa == pb;
}
} // namespace
const KnownPool* findKnownPoolByUrl(const std::string& url)
{
for (const auto& p : knownPools())
if (sameEndpoint(p.stratum, url)) return &p;
return nullptr;
}
std::string resolvePoolAlgo(const std::string& url, const std::string& fallback)
{
if (const KnownPool* p = findKnownPoolByUrl(url)) return p->algo;
return fallback;
}
double parsePoolHashrate(PoolStatsSchema schema, const std::string& jsonStr,
const std::string& miningcorePoolId, bool& ok)
{
ok = false;
try {
const json j = json::parse(jsonStr);
if (schema == PoolStatsSchema::DragonXIs) {
// { "pools": { "dragonx": { "hashrate": <num>, ... }, ... } }
if (j.contains("pools") && j["pools"].is_object()) {
const auto& pools = j["pools"];
auto readHr = [&](const json& pool, double& out) -> bool {
if (pool.is_object() && pool.contains("hashrate") &&
pool["hashrate"].is_number()) {
out = pool["hashrate"].get<double>();
return true;
}
return false;
};
double hr = 0.0;
if (pools.contains("dragonx") && readHr(pools["dragonx"], hr)) {
ok = true;
return hr;
}
for (auto it = pools.begin(); it != pools.end(); ++it) {
if (readHr(it.value(), hr)) {
ok = true;
return hr;
}
}
}
} else { // Miningcore
// { "pools": [ { "id": "...", "poolStats": { "poolHashrate": <num> } }, ... ] }
if (j.contains("pools") && j["pools"].is_array()) {
const json* chosen = nullptr;
for (const auto& pool : j["pools"]) {
if (!pool.is_object()) continue;
if (!miningcorePoolId.empty()) {
if (pool.value("id", std::string{}) == miningcorePoolId) {
chosen = &pool;
break;
}
} else if (!chosen) {
chosen = &pool; // first pool when no id requested
}
}
if (chosen && chosen->contains("poolStats") &&
(*chosen)["poolStats"].is_object() &&
(*chosen)["poolStats"].contains("poolHashrate") &&
(*chosen)["poolStats"]["poolHashrate"].is_number()) {
ok = true;
return (*chosen)["poolStats"]["poolHashrate"].get<double>();
}
}
}
} catch (...) {
// fall through — ok stays false
}
return 0.0;
}
std::string chooseWeightedPool(const std::vector<PoolHashrate>& pools,
const std::string& currentId,
std::mt19937& rng)
{
std::vector<const PoolHashrate*> usable;
usable.reserve(pools.size());
for (const auto& p : pools)
if (p.ok) usable.push_back(&p);
if (usable.empty()) return currentId;
if (usable.size() == 1) return usable.front()->id;
// weight = 1/(hr + eps): smaller pools get more weight; eps keeps a zero-hashrate
// pool finite (and most-favored). The incumbent gets a stickiness multiplier so a
// periodic re-balance only rarely restarts the miner.
constexpr double kEps = 1.0; // H/s
std::vector<double> weights;
weights.reserve(usable.size());
double total = 0.0;
for (const auto* p : usable) {
double w = 1.0 / (std::max(0.0, p->hashrateHs) + kEps);
if (!currentId.empty() && p->id == currentId) w *= kIncumbentStayBias;
weights.push_back(w);
total += w;
}
if (total <= 0.0) return usable.front()->id;
std::uniform_real_distribution<double> dist(0.0, total);
const double roll = dist(rng);
double cum = 0.0;
for (std::size_t i = 0; i < usable.size(); ++i) {
cum += weights[i];
if (roll <= cum) return usable[i]->id;
}
return usable.back()->id;
}
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,113 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
#include "pool_stats_service.h"
#include <curl/curl.h>
namespace dragonx {
namespace util {
namespace {
size_t writeStringCb(void* contents, size_t size, size_t nmemb, void* userp)
{
static_cast<std::string*>(userp)->append(static_cast<char*>(contents), size * nmemb);
return size * nmemb;
}
// Returning non-zero asks libcurl to abort the transfer — used so shutdown doesn't
// block on an in-flight fetch.
int xferInfoCb(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t)
{
const auto* self = static_cast<const PoolStatsService*>(clientp);
return (self && self->cancelRequested()) ? 1 : 0;
}
} // namespace
PoolStatsService::~PoolStatsService()
{
cancel_requested_ = true;
if (worker_.joinable()) worker_.join();
}
void PoolStatsService::refresh(const std::vector<KnownPool>& pools)
{
if (worker_running_.exchange(true)) return; // already refreshing
if (worker_.joinable()) worker_.join(); // reap the previous finished worker
worker_ = std::thread([this, pools]() {
run(pools);
worker_running_ = false;
});
}
std::string PoolStatsService::httpGet(const std::string& url)
{
CURL* curl = curl_easy_init();
if (!curl) return {};
std::string result;
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeStringCb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &result);
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
// pool.dragonx.cc sits behind Cloudflare and 403s odd User-Agents — present a
// browser-like UA and accept compressed responses.
curl_easy_setopt(curl, CURLOPT_USERAGENT,
"Mozilla/5.0 (compatible; ObsidianDragon)");
curl_easy_setopt(curl, CURLOPT_ACCEPT_ENCODING, "");
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 8L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 5L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L);
curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L);
curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferInfoCb);
curl_easy_setopt(curl, CURLOPT_XFERINFODATA, this);
struct curl_slist* hdrs = nullptr;
hdrs = curl_slist_append(hdrs, "Accept: application/json");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, hdrs);
const CURLcode res = curl_easy_perform(curl);
long httpCode = 0;
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode);
curl_slist_free_all(hdrs);
curl_easy_cleanup(curl);
if (res != CURLE_OK || httpCode < 200 || httpCode >= 300) return {};
return result;
}
void PoolStatsService::run(std::vector<KnownPool> pools)
{
std::map<std::string, PoolHashrate> results;
for (const auto& p : pools) {
if (cancel_requested_.load()) break;
PoolHashrate hr;
hr.id = p.id;
const std::string body = httpGet(p.statsUrl);
if (!body.empty()) {
bool ok = false;
const double v = parsePoolHashrate(p.schema, body, p.miningcorePoolId, ok);
hr.ok = ok;
hr.hashrateHs = ok ? v : 0.0;
}
results[p.id] = hr;
}
std::lock_guard<std::mutex> lk(mutex_);
snapshot_.byId = std::move(results);
snapshot_.ready = true;
}
PoolStatsService::Snapshot PoolStatsService::snapshot() const
{
std::lock_guard<std::mutex> lk(mutex_);
return snapshot_;
}
} // namespace util
} // namespace dragonx

View File

@@ -0,0 +1,59 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// PoolStatsService — fetch every known pool's live total hashrate on a background
// thread and expose a thread-safe snapshot. Modelled on util/XmrigUpdater: a
// std::thread + atomics + a mutex-guarded result, polled once per frame by the UI /
// the auto-balance driver. Schema-aware parsing is delegated to pool_registry.
#pragma once
#include "pool_registry.h"
#include <atomic>
#include <map>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
namespace dragonx {
namespace util {
class PoolStatsService {
public:
struct Snapshot {
std::map<std::string, PoolHashrate> byId; // keyed by KnownPool.id
bool ready = false; // at least one refresh completed
};
PoolStatsService() = default;
~PoolStatsService();
PoolStatsService(const PoolStatsService&) = delete;
PoolStatsService& operator=(const PoolStatsService&) = delete;
// Fetch every pool's hashrate on a background thread. No-op if a refresh is
// already in flight.
void refresh(const std::vector<KnownPool>& pools);
bool busy() const { return worker_running_.load(); }
Snapshot snapshot() const;
// Internal: consulted by the libcurl xferinfo callback so an in-flight fetch can
// abort promptly on shutdown. Public only so the C callback can reach it.
bool cancelRequested() const { return cancel_requested_.load(); }
private:
void run(std::vector<KnownPool> pools);
std::string httpGet(const std::string& url);
mutable std::mutex mutex_;
Snapshot snapshot_;
std::atomic<bool> worker_running_{false};
std::atomic<bool> cancel_requested_{false};
std::thread worker_;
};
} // namespace util
} // namespace dragonx

View File

@@ -13,8 +13,12 @@
#include "ui/windows/balance_address_list.h"
#include "ui/windows/balance_recent_tx.h"
#include "ui/windows/console_input_model.h"
#include "ui/windows/console_model.h"
#include "ui/windows/console_output_model.h"
#include "ui/windows/console_scroll_controller.h"
#include "ui/windows/console_selection_controller.h"
#include "ui/windows/console_tab_helpers.h"
#include "ui/windows/console_text_layout.h"
#include "ui/windows/mining_benchmark.h"
#include "ui/windows/mining_pool_panel.h"
#include "ui/windows/mining_tab_helpers.h"
@@ -23,6 +27,7 @@
#include "util/payment_uri.h"
#include "util/platform.h"
#include "util/xmrig_updater.h"
#include "util/pool_registry.h"
#include "util/daemon_updater.h"
#include "util/lite_server_probe.h"
#include "wallet/lite_connection_service.h"
@@ -34,6 +39,7 @@
#include "wallet/lite_wallet_state_mapper.h"
#include "config/settings.h"
#include "data/wallet_state.h"
#include "data/portfolio.h"
#include "fake_lite_backend.h"
#include <chrono>
@@ -2281,6 +2287,424 @@ void testDaemonLifecycleAdapters()
fs::remove_all(dataDir);
}
// Pure console wrap layout / hit-test / selection (console_text_layout.h), driven by a
// fixed-width measurement stub so the byte-offset selection math is unit-testable.
void testConsoleTextLayout()
{
using namespace dragonx::ui;
struct StubMeasure : ConsoleTextMeasure {
float charW = 10.0f;
float width(const char* b, const char* e) const override {
return static_cast<float>(e - b) * charW;
}
const char* wrapPosition(const char* b, const char* e, float w) const override {
int maxChars = static_cast<int>(w / charW);
if (maxChars < 1) maxChars = 1;
return (e - b) <= maxChars ? e : b + maxChars;
}
} stub;
std::vector<std::string> lines = { "", "abc", "abcdefghij" }; // 0, 3, 10 chars
auto get = [&lines](int i) -> const std::string& { return lines[i]; };
const float lineH = 12.0f, gap = 2.0f;
// wrapWidth 50 (charW 10 -> 5 chars/row)
ConsoleLayout L = BuildConsoleLayout(static_cast<int>(lines.size()), get, 50.0f, lineH, gap, stub);
EXPECT_EQ(L.segments.size(), static_cast<size_t>(3));
EXPECT_EQ(L.segments[0].size(), static_cast<size_t>(1)); // empty -> one empty seg
EXPECT_EQ(L.segments[0][0].byteEnd, 0);
EXPECT_NEAR(L.heights[0], lineH + gap, 0.01);
EXPECT_EQ(L.segments[1].size(), static_cast<size_t>(1)); // "abc" fits
EXPECT_EQ(L.segments[1][0].byteEnd, 3);
EXPECT_EQ(L.segments[2].size(), static_cast<size_t>(2)); // 10 chars, 5/row -> 2 rows
EXPECT_EQ(L.segments[2][0].byteEnd, 5);
EXPECT_EQ(L.segments[2][1].byteStart, 5);
EXPECT_EQ(L.segments[2][1].byteEnd, 10);
EXPECT_NEAR(L.heights[2], 2 * lineH + gap, 0.01);
EXPECT_NEAR(L.cumulativeY[2], 2 * (lineH + gap), 0.01);
EXPECT_NEAR(L.totalHeight, 3 * (lineH + gap) + lineH, 0.01);
// Hit-test: second wrap row of line 2, column 7.
ConsoleHit hit = HitTestConsoleLayout(L, 3, get, /*relX*/ 20.0f, /*relY*/ 45.0f, stub);
EXPECT_EQ(hit.visibleRow, 2);
EXPECT_EQ(hit.col, 7);
// Past the bottom clamps to the last line's end.
ConsoleHit last = HitTestConsoleLayout(L, 3, get, 1000.0f, 9999.0f, stub);
EXPECT_EQ(last.visibleRow, 2);
EXPECT_EQ(last.col, 10);
// Selection extraction (multi-line, no filter, then filtered to line 0 only).
std::vector<std::string> lines2 = { "hello", "world" };
auto get2 = [&lines2](int i) -> const std::string& { return lines2[i]; };
EXPECT_EQ(ExtractConsoleSelection(2, get2, {}, {0, 1}, {1, 3}), std::string("ello\nwor"));
EXPECT_EQ(ExtractConsoleSelection(2, get2, std::vector<int>{0}, {0, 1}, {1, 3}), std::string("ello"));
}
// ConsoleScrollController — auto-scroll state machine (pin, wheel-up cooldown, backlog).
void testConsoleScrollController()
{
using dragonx::ui::ConsoleScrollController;
// Starts pinned to the bottom; new lines while pinned are not counted as a backlog.
{
ConsoleScrollController s;
EXPECT_TRUE(s.autoScroll());
s.onLinesAdded(5);
EXPECT_EQ(s.newLines(), 0);
}
// Wheel-up detaches, starts the cooldown, and begins counting backlog lines.
{
ConsoleScrollController s;
s.onUserScrolledUp();
EXPECT_FALSE(s.autoScroll());
s.onLinesAdded(3);
s.onLinesAdded(2);
EXPECT_EQ(s.newLines(), 5);
// At-bottom is ignored until the cooldown elapses.
s.considerReenable(true);
EXPECT_FALSE(s.autoScroll());
s.tickCooldown(ConsoleScrollController::kScrollUpCooldownSeconds + 0.01f);
// Elapsed, but only re-pins when actually at the bottom.
s.considerReenable(false);
EXPECT_FALSE(s.autoScroll());
s.considerReenable(true);
EXPECT_TRUE(s.autoScroll());
EXPECT_EQ(s.newLines(), 0); // re-pinning clears the backlog
}
// Checkbox toggle: enabling clears the backlog, disabling preserves state.
{
ConsoleScrollController s;
s.onUserScrolledUp();
s.onLinesAdded(4);
EXPECT_EQ(s.newLines(), 4);
s.setAutoScroll(true);
EXPECT_TRUE(s.autoScroll());
EXPECT_EQ(s.newLines(), 0);
s.setAutoScroll(false);
EXPECT_FALSE(s.autoScroll());
}
// Jump-to-bottom pill re-pins and clears the backlog.
{
ConsoleScrollController s;
s.onUserScrolledUp();
s.onLinesAdded(9);
s.jumpToBottom();
EXPECT_TRUE(s.autoScroll());
EXPECT_EQ(s.newLines(), 0);
}
}
// ConsoleSelectionController — anchor/caret state, ordering, select-all, eviction shift.
void testConsoleSelectionController()
{
using dragonx::ui::ConsoleSelectionController;
using dragonx::ui::ConsoleTextPos;
// Ordering helper.
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 2}, {1, 5}));
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({1, 5}, {2, 0}));
EXPECT_TRUE(ConsoleSelectionController::isBeforeOrEqual({3, 4}, {3, 4}));
EXPECT_FALSE(ConsoleSelectionController::isBeforeOrEqual({2, 0}, {1, 9}));
// Drag lifecycle: press starts a drag but is not yet a real selection.
{
ConsoleSelectionController sel;
sel.beginDrag({2, 3});
EXPECT_TRUE(sel.dragging());
EXPECT_FALSE(sel.active());
sel.updateDrag({2, 3}); // no movement -> still not active
EXPECT_FALSE(sel.active());
sel.updateDrag({4, 1}); // moved -> real selection
EXPECT_TRUE(sel.active());
sel.endDrag({4, 1});
EXPECT_FALSE(sel.dragging());
EXPECT_TRUE(sel.active());
// Ordered range is anchor..caret regardless of drag direction.
EXPECT_EQ(sel.rangeStart().line, 2);
EXPECT_EQ(sel.rangeStart().col, 3);
EXPECT_EQ(sel.rangeEnd().line, 4);
EXPECT_EQ(sel.rangeEnd().col, 1);
}
// Upward drag: caret before anchor -> range is still ordered low..high.
{
ConsoleSelectionController sel;
sel.beginDrag({5, 2});
sel.updateDrag({1, 0});
EXPECT_EQ(sel.rangeStart().line, 1);
EXPECT_EQ(sel.rangeEnd().line, 5);
}
// Select-all + clear.
{
ConsoleSelectionController sel;
sel.selectAll(0, 0); // empty buffer -> no-op
EXPECT_FALSE(sel.active());
sel.selectAll(3, 7); // lines 0..2, last line 7 bytes
EXPECT_TRUE(sel.active());
EXPECT_EQ(sel.rangeStart().line, 0);
EXPECT_EQ(sel.rangeStart().col, 0);
EXPECT_EQ(sel.rangeEnd().line, 2);
EXPECT_EQ(sel.rangeEnd().col, 7);
sel.clear();
EXPECT_FALSE(sel.active());
EXPECT_FALSE(sel.dragging());
}
// Eviction shift: partial (top of selection clamped), full (dropped), and no-op.
{
ConsoleSelectionController sel;
sel.selectAll(10, 4); // [0,0]..[9,4]
sel.shiftForEviction(3); // 3 lines fell off the top
EXPECT_TRUE(sel.active());
EXPECT_EQ(sel.rangeStart().line, 0); // was line 0 -> clamped to 0
EXPECT_EQ(sel.rangeStart().col, 0);
EXPECT_EQ(sel.rangeEnd().line, 6); // 9 - 3
EXPECT_EQ(sel.rangeEnd().col, 4);
sel.shiftForEviction(0); // no-op
EXPECT_EQ(sel.rangeEnd().line, 6);
sel.shiftForEviction(100); // whole selection evicted -> dropped
EXPECT_FALSE(sel.active());
}
// extract(): inactive -> "", active -> the joined selected text.
{
std::vector<std::string> lines = {"hello", "world"};
auto get = [&lines](int i) -> const std::string& { return lines[i]; };
ConsoleSelectionController sel;
EXPECT_EQ(sel.extract(2, get, {}), std::string(""));
sel.beginDrag({0, 1});
sel.updateDrag({1, 3});
EXPECT_EQ(sel.extract(2, get, {}), std::string("ello\nwor"));
// Filtered so only line 0 is visible -> selection clips to it.
EXPECT_EQ(sel.extract(2, get, std::vector<int>{0}), std::string("ello"));
}
}
// ConsoleModel — thread-safe ingest queue drained on the main thread, with a line cap.
void testConsoleModel()
{
using dragonx::ui::ConsoleModel;
using dragonx::ui::ConsoleChannel;
// Basic ingest -> drain: order + channels preserved, counts reported.
{
ConsoleModel m;
m.ingest("one", ConsoleChannel::Command);
m.ingest("two", ConsoleChannel::Info);
m.ingest("three", ConsoleChannel::Error);
auto dr = m.drain();
EXPECT_EQ(dr.added, static_cast<size_t>(3));
EXPECT_EQ(dr.popped, static_cast<size_t>(0));
EXPECT_EQ(m.size(), static_cast<size_t>(3));
EXPECT_EQ(m[0].text, std::string("one"));
EXPECT_TRUE(m[0].channel == ConsoleChannel::Command);
EXPECT_TRUE(m[2].channel == ConsoleChannel::Error);
EXPECT_EQ(m.back().text, std::string("three"));
// Draining again with nothing pending is a no-op.
auto empty = m.drain();
EXPECT_EQ(empty.added, static_cast<size_t>(0));
EXPECT_EQ(empty.popped, static_cast<size_t>(0));
EXPECT_EQ(m.size(), static_cast<size_t>(3));
}
// Line cap: front lines are evicted, popped is reported for selection adjustment.
{
ConsoleModel m(3); // cap = 3
for (int i = 0; i < 5; ++i) m.ingest(std::to_string(i), ConsoleChannel::None);
auto dr = m.drain();
EXPECT_EQ(dr.added, static_cast<size_t>(5));
EXPECT_EQ(dr.popped, static_cast<size_t>(2));
EXPECT_EQ(m.size(), static_cast<size_t>(3));
EXPECT_EQ(m[0].text, std::string("2")); // "0" and "1" evicted
EXPECT_EQ(m[2].text, std::string("4"));
}
// clear() drops the visible lines but not lines still queued for the next drain.
{
ConsoleModel m;
m.ingest("a", ConsoleChannel::None);
m.drain();
EXPECT_EQ(m.size(), static_cast<size_t>(1));
m.ingest("b", ConsoleChannel::None); // queued, not yet visible
m.clear();
EXPECT_EQ(m.size(), static_cast<size_t>(0));
m.drain();
EXPECT_EQ(m.size(), static_cast<size_t>(1)); // "b" survived the clear
EXPECT_EQ(m[0].text, std::string("b"));
}
// Concurrency: many producer threads ingest while the main thread drains; no line lost.
{
ConsoleModel m(1000000); // large cap so nothing is evicted
const int kThreads = 8, kPerThread = 2000;
std::atomic<bool> go{false};
std::vector<std::thread> producers;
for (int t = 0; t < kThreads; ++t) {
producers.emplace_back([&m, &go, t]() {
while (!go.load()) { /* spin until start */ }
for (int i = 0; i < kPerThread; ++i)
m.ingest("x", ConsoleChannel::None);
});
}
go.store(true);
// Interleave drains on this thread while producers run.
size_t drained = 0;
while (drained < static_cast<size_t>(kThreads * kPerThread)) {
drained += m.drain().added;
}
for (auto& th : producers) th.join();
drained += m.drain().added; // final sweep for anything queued after the last drain
EXPECT_EQ(drained, static_cast<size_t>(kThreads * kPerThread));
EXPECT_EQ(m.size(), static_cast<size_t>(kThreads * kPerThread));
}
// JSON fold state: ingest carries a fold span; toggleCollapsed flips only openers.
{
ConsoleModel m;
m.ingest("{", ConsoleChannel::JsonBrace, 3);
m.ingest(" \"a\": 1", ConsoleChannel::JsonKey, 0);
m.ingest("}", ConsoleChannel::JsonBrace, 0);
m.drain();
EXPECT_EQ(m[0].foldSpan, 3);
EXPECT_FALSE(m[0].collapsed);
EXPECT_TRUE(m.toggleCollapsed(0)); // opener -> collapsed
EXPECT_TRUE(m[0].collapsed);
EXPECT_FALSE(m.toggleCollapsed(0)); // -> expanded
EXPECT_FALSE(m[0].collapsed);
EXPECT_FALSE(m.toggleCollapsed(1)); // not an opener -> no-op
EXPECT_FALSE(m.toggleCollapsed(99)); // out of range -> no-op
}
}
// Portfolio helpers — sum/query a labeled address group against per-address balances.
void testPortfolioHelpers()
{
using dragonx::AddressInfo;
using dragonx::data::SumPortfolioBalance;
using dragonx::data::PortfolioEntryContains;
using dragonx::data::PortfolioEntryAdd;
using dragonx::data::PortfolioEntryRemove;
std::vector<AddressInfo> wallet;
auto mk = [](const char* addr, double bal, const char* type) {
AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
};
wallet.push_back(mk("R-t1", 1.5, "transparent"));
wallet.push_back(mk("R-t2", 2.0, "transparent"));
wallet.push_back(mk("zs-s1", 10.0, "shielded"));
wallet.push_back(mk("zs-s2", 0.25, "shielded"));
// Empty group sums to 0.
EXPECT_NEAR(SumPortfolioBalance({}, wallet), 0.0, 1e-9);
// A group of two known addresses.
EXPECT_NEAR(SumPortfolioBalance({"R-t1", "zs-s1"}, wallet), 11.5, 1e-9);
// Unknown addresses contribute 0 (not present in the wallet).
EXPECT_NEAR(SumPortfolioBalance({"R-t2", "does-not-exist"}, wallet), 2.0, 1e-9);
// All shielded.
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "zs-s2"}, wallet), 10.25, 1e-9);
// Membership + add/remove semantics.
std::vector<std::string> group = {"R-t1"};
EXPECT_TRUE(PortfolioEntryContains(group, "R-t1"));
EXPECT_FALSE(PortfolioEntryContains(group, "R-t2"));
EXPECT_TRUE(PortfolioEntryAdd(group, "R-t2")); // added
EXPECT_FALSE(PortfolioEntryAdd(group, "R-t2")); // already present -> no-op
EXPECT_EQ(group.size(), static_cast<size_t>(2));
EXPECT_TRUE(PortfolioEntryRemove(group, "R-t1")); // removed
EXPECT_FALSE(PortfolioEntryRemove(group, "R-t1"));// gone -> no-op
EXPECT_EQ(group.size(), static_cast<size_t>(1));
EXPECT_EQ(group[0], std::string("R-t2"));
}
// Regression: the Market-tab portfolio (and other consumers) read the combined
// WalletState::addresses view, which the full-node refresh path builds from the
// authoritative z/t lists via rebuildAddressList(). Before the fix that rebuild
// was never called, so `addresses` stayed empty and every portfolio group summed
// to 0 and showed no addresses to pick. Guard that the rebuilt view carries the
// z/t union with balances, so SumPortfolioBalance resolves right-click-added
// addresses.
void testWalletStateAddressListRebuild()
{
using dragonx::AddressInfo;
using dragonx::data::SumPortfolioBalance;
dragonx::WalletState state;
auto mk = [](const char* addr, double bal, const char* type) {
AddressInfo a; a.address = addr; a.balance = bal; a.type = type; return a;
};
state.z_addresses.push_back(mk("zs-s1", 10.0, "shielded"));
state.z_addresses.push_back(mk("zs-s2", 0.25, "shielded"));
state.t_addresses.push_back(mk("R-t1", 1.5, "transparent"));
// Before the rebuild the combined view is empty -> the reported bug: a portfolio
// group of real addresses sums to 0 because none are found in state.addresses.
EXPECT_EQ(state.addresses.size(), static_cast<size_t>(0));
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 0.0, 1e-9);
// After the rebuild the combined view is the z/t union with balances.
state.rebuildAddressList();
EXPECT_EQ(state.addresses.size(), static_cast<size_t>(3));
// z entries come first, then t entries (rebuild order).
EXPECT_EQ(state.addresses.front().address, std::string("zs-s1"));
EXPECT_EQ(state.addresses.back().address, std::string("R-t1"));
// A group spanning both pools now resolves its balances.
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 11.5, 1e-9);
// In-place z/t balance mutation (pending-send delta) is reflected on the next rebuild.
state.z_addresses[0].balance = 4.0;
state.rebuildAddressList();
EXPECT_NEAR(SumPortfolioBalance({"zs-s1", "R-t1"}, state.addresses), 5.5, 1e-9);
}
// ComputeConsoleFoldSpans — brace/bracket matching over pretty-printed JSON lines.
void testConsoleFoldSpans()
{
using dragonx::ui::ComputeConsoleFoldSpans;
// Nested object: outer '{' spans the whole block; inner "obj": { spans its two-line body.
std::vector<std::string> obj = {
"{", // 0 -> closer at 6, span 6
" \"a\": 1,", // 1
" \"obj\": {", // 2 -> closer at 4, span 2
" \"b\": 2", // 3
" },", // 4 (closer for 2)
" \"c\": 3", // 5
"}" // 6 (closer for 0)
};
auto spans = ComputeConsoleFoldSpans(obj);
EXPECT_EQ(spans.size(), static_cast<size_t>(7));
EXPECT_EQ(spans[0], 6);
EXPECT_EQ(spans[1], 0);
EXPECT_EQ(spans[2], 2);
EXPECT_EQ(spans[3], 0);
EXPECT_EQ(spans[4], 0); // a closer is not an opener
EXPECT_EQ(spans[5], 0);
EXPECT_EQ(spans[6], 0);
// Array opener '['.
std::vector<std::string> arr = { "\"outputs\": [", " 1,", " 2", "]" };
EXPECT_EQ(ComputeConsoleFoldSpans(arr)[0], 3);
// Empty block (opener immediately followed by closer) is not worth folding (span < 2).
std::vector<std::string> empty = { "{", "}" };
EXPECT_EQ(ComputeConsoleFoldSpans(empty)[0], 0);
// Plain non-JSON lines: no openers.
std::vector<std::string> plain = { "hello", "world" };
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[0], 0);
EXPECT_EQ(ComputeConsoleFoldSpans(plain)[1], 0);
// A string value merely containing a brace does not open a block (last char isn't '{').
std::vector<std::string> str = { "\"note\": \"a { brace\",", "\"x\": 1" };
EXPECT_EQ(ComputeConsoleFoldSpans(str)[0], 0);
}
void testRendererHelpers()
{
EXPECT_NEAR(dragonx::ui::ComputeConsoleInputHeight(20.0f, 4.0f, 8.0f, 2.0f, 1.0f),
@@ -2299,26 +2723,34 @@ void testRendererHelpers()
EXPECT_FALSE(dragonx::ui::IsPoolMiningActive(true, false, true));
EXPECT_TRUE(dragonx::ui::IsPoolMiningActive(false, false, true));
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true, 10, 20, 30};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("daemon line", 10, filter));
// The output filter keys off the line's semantic ConsoleChannel (not a color / prefix).
using CC = dragonx::ui::ConsoleChannel;
// {text, daemonMessagesEnabled, errorsOnly, rpcTraceEnabled, appMessagesEnabled}
dragonx::ui::ConsoleOutputFilter filter{"error", false, false, false, true};
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("RPC Error", CC::Error, filter)); // substring "error"
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Daemon line", CC::Daemon, filter)); // no "error" substring
filter.text.clear();
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
// Daemon/Xmrig channels hidden while daemonMessagesEnabled=false.
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("hashrate 1000", CC::Xmrig, filter));
// Rpc channel hidden while rpcTraceEnabled=false, shown once enabled.
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
filter.rpcTraceEnabled = true;
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] History -> listtransactions", 30, filter));
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("History -> listtransactions", CC::Rpc, filter));
filter.daemonMessagesEnabled = true;
// Errors-only keeps only the Error channel.
filter.errorsOnly = true;
filter.text.clear();
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", 20, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", 30, filter));
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("error line", CC::Error, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("info line", CC::Info, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("node log", CC::Daemon, filter));
// "[app] ..." lines are filtered by prefix (they share the info color), not by color.
// App channel is toggled by appMessagesEnabled; other channels are unaffected by it.
filter.errorsOnly = false;
filter.appMessagesEnabled = false;
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("[app] Price updated", 30, filter));
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[rpc] trace", 30, filter));
EXPECT_FALSE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("trace", CC::Rpc, filter));
filter.appMessagesEnabled = true;
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("[app] Price updated", 30, filter));
EXPECT_TRUE(dragonx::ui::consoleLinePassesFilter("Price updated", CC::App, filter));
std::vector<dragonx::AddressInfo> poolAddresses;
poolAddresses.push_back({"R-transparent", 0.0, "transparent", true});
@@ -2490,6 +2922,27 @@ void testBalanceAddressListModel()
EXPECT_EQ(rows.size(), static_cast<size_t>(1));
EXPECT_EQ(rows[0].info->address, std::string("zs-high"));
// Mining-flagged addresses stay visible at 0 balance even with hide-zero on.
dragonx::AddressInfo tMine;
tMine.address = "R-miner"; tMine.label = "Payout"; tMine.balance = 0.0; tMine.type = "transparent";
std::vector<dragonx::ui::AddressListInput> mineInputs = {
{&addresses[0], true, false, false, false, "Vault", "", -1}, // funded
{&tMine, false, false, false, /*mining=*/true, "Payout", "", -1}, // 0-balance mining
};
auto mineRows = dragonx::ui::BuildAddressListRows(mineInputs, "", /*hideZero=*/true, false);
EXPECT_EQ(mineRows.size(), static_cast<size_t>(2));
bool sawMiner = false;
for (const auto& r : mineRows) if (r.info->address == std::string("R-miner")) sawMiner = true;
EXPECT_TRUE(sawMiner);
// Explicit sort order overrides starred: a non-favorite ordered row outranks a favorite.
std::vector<dragonx::ui::AddressListInput> ordInputs = {
{&addresses[0], true, false, false, false, "Vault", "", 0}, // ordered (0)
{&addresses[2], false, false, true, false, "Favorite", "", -1}, // favorite, unordered
};
auto ordRows = dragonx::ui::BuildAddressListRows(ordInputs, "", false, false);
EXPECT_EQ(ordRows[0].info->address, std::string("zs-high"));
auto layout = dragonx::ui::ComputeAddressRowLayout(10.0f, 20.0f, 300.0f, 50.0f,
12.0f, 16.0f, 6.0f, 4.0f, 2.0f);
EXPECT_NEAR(layout.contentStartX, 22.0, 0.0001);
@@ -4826,6 +5279,97 @@ void testXmrigLiveInstall()
std::filesystem::remove_all(dir, ec);
}
// Mining pool registry: URL matching + per-pool algo resolution.
void testPoolRegistryLookup()
{
using namespace dragonx::util;
EXPECT_EQ(knownPools().size(), static_cast<std::size_t>(2));
// The algo follows the pool. Note pool.dragonx.cc's stratum host is us.dragonx.cc
// (the .cc domain is only the Cloudflare-proxied web/API host).
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc:3333", "rx/hush"), std::string("rx/dragonx"));
EXPECT_EQ(resolvePoolAlgo("pool.dragonx.is:3433", "rx/dragonx"), std::string("rx/hush"));
// Bare host (no port) still matches; scheme + path are tolerated.
EXPECT_EQ(resolvePoolAlgo("us.dragonx.cc", "rx/hush"), std::string("rx/dragonx"));
EXPECT_EQ(resolvePoolAlgo("stratum+tcp://us.dragonx.cc:3333/x", "rx/hush"),
std::string("rx/dragonx"));
// Unknown host -> fallback algo.
EXPECT_EQ(resolvePoolAlgo("my.pool.example:1234", "rx/hush"), std::string("rx/hush"));
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:3333") != nullptr);
EXPECT_TRUE(findKnownPoolByUrl("unknown.host:1") == nullptr);
// A mismatched explicit port must NOT match a known pool.
EXPECT_TRUE(findKnownPoolByUrl("us.dragonx.cc:9999") == nullptr);
}
// Schema-aware pool hashrate parsing (the two pools speak different APIs).
void testPoolHashrateParsing()
{
using namespace dragonx::util;
bool ok = false;
// pool.dragonx.is custom schema: pools.dragonx.hashrate
const std::string isJson =
R"({"pools":{"dragonx":{"hashrate":27670.14,"workerCount":12}},"global":{"hashrate":30000.0}})";
double hr = parsePoolHashrate(PoolStatsSchema::DragonXIs, isJson, "", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 27670.14, 0.01);
// Miningcore schema: the requested pool id selects the entry (not just the first).
const std::string ccJson =
R"({"pools":[)"
R"({"id":"dragonx-solo","poolStats":{"poolHashrate":88780.0}},)"
R"({"id":"dragonx-pplns","poolStats":{"poolHashrate":1585.9}}]})";
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-pplns", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 1585.9, 0.01);
hr = parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "dragonx-solo", ok);
EXPECT_TRUE(ok);
EXPECT_NEAR(hr, 88780.0, 0.01);
// Missing pool id / malformed / wrong-schema input all fail closed (no silent 0).
parsePoolHashrate(PoolStatsSchema::Miningcore, ccJson, "does-not-exist", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::DragonXIs, "not json", "", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::Miningcore, "{}", "dragonx-pplns", ok);
EXPECT_FALSE(ok);
parsePoolHashrate(PoolStatsSchema::DragonXIs, ccJson, "", ok); // .cc JSON, .is parser
EXPECT_FALSE(ok);
}
// Weighted-random pool selection: smaller pools favored, incumbent sticky, fails safe.
void testPoolWeightedSelection()
{
using namespace dragonx::util;
std::mt19937 rng(12345u); // fixed seed -> deterministic
std::vector<PoolHashrate> pools = {
{"big", 100000.0, true}, // large -> low weight
{"small", 1000.0, true}, // small -> high weight (favored)
};
int smallCount = 0, bigCount = 0;
for (int i = 0; i < 2000; ++i) {
const std::string pick = chooseWeightedPool(pools, /*currentId=*/"", rng);
if (pick == "small") ++smallCount;
else if (pick == "big") ++bigCount;
}
EXPECT_TRUE(smallCount > bigCount * 5); // ~100:1 by weight
// Only one usable pool -> always chosen; no usable pool -> keep current.
std::vector<PoolHashrate> one = {{"a", 5.0, true}, {"b", 1.0, false}};
EXPECT_EQ(chooseWeightedPool(one, "", rng), std::string("a"));
std::vector<PoolHashrate> none = {{"a", 5.0, false}, {"b", 1.0, false}};
EXPECT_EQ(chooseWeightedPool(none, "keepme", rng), std::string("keepme"));
// Incumbent stickiness: with equal hashrates the current pool wins the majority.
std::vector<PoolHashrate> equal = {{"x", 1000.0, true}, {"y", 1000.0, true}};
int stay = 0;
for (int i = 0; i < 2000; ++i)
if (chooseWeightedPool(equal, "x", rng) == "x") ++stay;
EXPECT_TRUE(stay > 1000); // >50% thanks to kIncumbentStayBias
}
} // namespace
int main()
@@ -4846,6 +5390,13 @@ int main()
testDaemonShutdownPolicy();
testDaemonLifecycleExecution();
testDaemonLifecycleAdapters();
testConsoleTextLayout();
testConsoleModel();
testPortfolioHelpers();
testWalletStateAddressListRebuild();
testConsoleFoldSpans();
testConsoleScrollController();
testConsoleSelectionController();
testRendererHelpers();
testConsoleInputModel();
testMiningBenchmarkModel();
@@ -4903,6 +5454,9 @@ int main()
testDaemonReleaseListParsing();
testLiteServerHostParsing();
testLiteOfficialServerDetection();
testPoolRegistryLookup();
testPoolHashrateParsing();
testPoolWeightedSelection();
testAtomicFileWrite();
testAddressChecksumValidation();
testLiteServerProbeLive();