24 Commits

Author SHA1 Message Date
6f0f95f4fb fix(image-picker): avoid shutdown UB in the async decode counter
Adversarial review of the off-thread decode found the one real defect: a
detached decode worker decremented the static s_animInFlight counter, which at
process exit could run after static destruction begins (shutdown UB). Make the
counter a heap std::shared_ptr<atomic> the worker co-owns, so it safely outlives
teardown; the worker now touches only heap objects it holds a share of. Also
noted s_animatingThisFrame is intentionally main-thread-only.

(Review confirmed the rest: worker never touches the thumb map, done
release/acquire publishes the frames, GL upload stays on the UI thread, stb is
thread_local + libwebp per-instance so concurrent decodes are safe, the
in-flight slot is never leaked, and growth is bounded/cleared on navigate.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:55:11 -05:00
a7c50ff19b perf(image-picker): decode hover animations off-thread (no UI hang)
Hovering an animated GIF/WebP previously decoded ALL frames + uploaded every
texture synchronously on the UI thread — a big/long animation froze the UI for
that first hover (stb's GIF decode is monolithic).

Now the decode runs on a detached background worker (bounded to 2 concurrent),
and frames are uploaded to GPU textures a few per UI frame; the still thumbnail
keeps showing until the sequence is ready, then it animates. A shared_ptr job
keeps the worker's result alive if the thumbnail is destroyed mid-decode (e.g.
navigating away), so nothing blocks. Only images the cheap badge-probe already
flagged as animated ever spawn a worker.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:50:27 -05:00
e7f2d2e3c7 feat(image-picker): animated-image badge on GIF/WebP thumbnails
Animated thumbnails now show a small play-arrow badge (bottom-right) so users can
spot which images move before hovering; it's hidden while the image plays on
hover.

Detection is cheap — a new util::IsAnimatedImageFile probes without a full
decode: animated WebP via WebPGetFeatures.has_animation, and a multi-frame GIF
via a lightweight image-descriptor block walk (stops at the 2nd frame). The
picker only probes .gif/.webp thumbnails and caches the result on the Thumb.

Verified: animated GIF + animated WebP report animated; still GIF/WebP/PNG do not.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:44:57 -05:00
137147921c fix(contacts): don't keep the app awake for off-screen animated avatars
Adversarial review found that the contact list/table loops (no clipper) call
drawContactAvatar for every row, so an animated avatar scrolled out of the
list/table viewport still flagged the render loop as animating — pinning the app
at vsync-rate redraw instead of idling (power drain).

currentAvatarFrame now takes an onScreen flag: off-screen it shows frame 0 and
does NOT set the keep-redrawing flag. The list and table pass ImGui::IsRectVisible
for the row/avatar; the preview passes true; the library grid already yields a
null texture for culled cells. (Two other findings — stb GIF peak host RAM and
the session texture cache — were reviewed and judged bounded/local-only.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:26:31 -05:00
fd0cab41f0 feat(image-picker): play animated thumbnails on hover
Hovering a GIF/WebP thumbnail in the picker now previews its animation:

- The picker's Thumb gains a lazily-loaded frame sequence (via LoadAnimatedRGBA)
  fetched the first time an animatable (.gif/.webp) thumbnail is hovered; still
  images and other formats keep their single static thumbnail (no re-decode).
- On hover the current frame is drawn on the ImGui clock; leaving the thumbnail
  returns it to the still frame-0 preview.
- A clear-on-read flag (ImagePicker::consumeAnimationActive) is OR'd into
  ConsumeContactsAvatarAnimation so the render loop keeps drawing while a hover
  preview plays and idles otherwise. clearThumbs frees all frame textures too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:22:57 -05:00
4b8d80b2fc feat(contacts): animated avatars (GIF/WebP) with a Settings toggle
Animate contact avatars end to end:

- texture_loader gains LoadAnimatedRGBA: decodes an image into a downscaled
  RGBA frame sequence + per-frame durations — animated GIF via stb
  (stbi_load_gif_from_memory) and animated WebP via libwebp's WebPAnimDecoder;
  stills (and APNG, which stb reads as one image) return a single frame. Frames
  are box-downscaled (smaller cap for animations) to bound VRAM, capped at 300.
- The contacts avatar cache now holds a frame sequence; currentAvatarFrame()
  advances animated avatars by the ImGui clock and is used everywhere avatars
  draw (list, cards, table, grid, preview). When a live animated frame is drawn
  it flags the render loop (ConsumeContactsAvatarAnimation, clear-on-read) so
  main.cpp keeps producing frames while animation plays and idles when it stops
  or the contacts view is hidden.
- New animate_avatars setting (default on) + a Settings appearance toggle
  ("Animate avatars"); off shows the first frame only. currentAvatarFrame
  honors it. +i18n (8 langs, CJK subset rebuilt for 帧/播/첫).

Verified: a 3-frame GIF and a 3-frame animated WebP both decode to 3 frames
with correct 120ms delays through the exact libwebp/stb calls used here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:11:06 -05:00
923d086092 feat(images): WebP decode via libwebp + more stb formats
Broaden avatar/image support:

- Add libwebp (FetchContent, static, decode-only) so WebP loads. Built from
  source for Linux / mingw-Windows / macOS-osxcross identically — the cross
  sysroots have no webp, so vendoring from source is the one portable path;
  encode/tool builds are disabled to avoid pulling in libpng/zlib. Linked as
  webp + webpdemux (the latter for animated WebP, wired next).
- texture_loader routes all decode through DecodeImageRGBA: sniffs the RIFF/
  WEBP header and uses libwebp (WebPDecodeRGBAInto into a free()-able buffer),
  else stb — so every existing caller (avatars, QR, thumbnails) gains WebP for
  free with no allocator mismatch.
- Enable stb's TGA / PSD / PNM / PIC decoders and add .webp/.tga/.psd/.pnm/
  .ppm/.pgm/.pic to the image-picker + avatar-library extension lists.

Verified: libwebp builds static and a real .webp decodes to correct RGBA.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:57:01 -05:00
6ac8f66644 fix(contacts): review fixes for the avatar library grid
From the adversarial review of the image-library grid:

- MED: the delete badge was an InvisibleButton positioned via SetCursorScreenPos,
  which left CursorPosPrevLine at the badge corner — the next cell's SameLine
  reads that, so a hovered/selected row's trailing cells jittered ~3px and their
  hit-rects overlapped. Hit-test the badge MANUALLY (no layout item, no cursor
  moves); it still takes click priority over selecting the thumbnail.
- MED: avatar textures were uploaded at full native resolution and cached for the
  session with no cap — a library of large photos could cost GBs of VRAM, and the
  contact list decoded every image avatar at once on tab open. Box-downscale to
  <=256px (an avatar renders at most ~112px) and budget decodes to a few per
  frame, so large libraries fill in progressively instead of stalling.

(A third finding — a symlink edge case in the delete guard — was reviewed and
judged below the bar; the weakly_canonical + parent-path guard already holds.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:33:51 -05:00
62fc202557 feat(contacts): image avatars are now a managed library grid
Redesign the Image avatar tab from a single-image chooser into a grid over a
persistent image library (mirrors the Icon tab):

- Images the user adds live in <config>/contact-avatars/ and PERSIST as a
  reusable, portable set — they travel with the wallet data dir, so avatars
  survive moving to another machine without remembering source paths.
- The grid's first cell is always the "+ add image" button (opens the picker,
  decode-verifies, copies into the library, auto-selects the new image).
- Each library image is a selectable thumbnail (Primary ring when selected)
  with a delete badge (top-right, red on hover) to remove it from the library;
  the delete is deferred past the grid loop and clears the selection if it
  pointed at the removed file. Contacts still referencing a deleted image fall
  back to their Z/T badge.
- Removes the previous auto-prune of "unused" images on edit/delete — images
  are only removed by the explicit delete badge now.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:22:35 -05:00
f8034b843a fix(contacts): stricter path guard in pruneOrphanAvatar
Compare the file's parent path to the managed dir instead of a string prefix,
so a sibling dir like contact-avatars-x can't false-match. Our avatar copies
always live directly in the dir, so this is both correct and safer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:09:00 -05:00
731d4e04ff feat(contacts): avatars in Table view + prune orphaned avatar images
- Table view now shows the same avatar (image / icon / Z-T badge) before each
  contact's label, drawn after the row Selectable so its highlight doesn't
  paint over it — visual parity with Cards/List.
- When a contact's custom image avatar is replaced (edit) or the contact is
  deleted, its now-unused file in <config>/contact-avatars/ is removed
  (pruneOrphanAvatar) — but only if no other contact still references it and
  the path is inside our managed dir, and its cached texture is dropped too.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:08:04 -05:00
9649654c3c fix(contacts): adversarial-review fixes — delete UB, JPEG decode, footer clip
From an adversarial review of the avatar edit-dialog + image picker:

- HIGH: the per-row Delete icon called doDelete() (which erases from
  book.entries()) INSIDE the loop iterating that same vector — out-of-bounds
  reads / wrong rows on the confirming click. Defer it until after the loop.
- HIGH: the decode stack was compiled PNG-only (STBI_ONLY_PNG) while the image
  picker accepts .jpg/.jpeg/.bmp/.gif, so picking a JPEG (the common photo
  case) silently produced a non-loading avatar + an orphaned copy on disk.
  Enable JPEG/BMP/GIF decoders, and guard the pick: verify the source decodes
  before copying/committing (new contact_avatar_bad_image string, 8 langs).
- MED: the fixed, non-scrolling edit card floored bodyH at 260*dp, which could
  push Save/Cancel below the card on short windows — floor lowered so the
  footer always stays inside.
- LOW: the live-preview panel rounding is now dp-scaled (10*dp) to match the
  real list card it mirrors.
- LOW: re-picking the same source path after its contents changed showed a
  stale cached texture — evict the avatar texture cache entry on re-pick.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:49:34 -05:00
e1df5ea798 feat(image-picker): thumbnails fill width at 6 per row
Fix the thumbnail grid to a constant 6 columns whose square cells scale to the
available width, instead of fixed 96px cells that left dead space on the right.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:43:23 -05:00
a53b13b6b6 feat(image-picker): smooth scroll, inset scrollbar, 2-column folder grid
- Smooth (lerped) wheel scrolling via ApplySmoothScroll on the list, matching
  the app's other modal lists.
- The list is now a bordered/rounded outer frame whose 6px padding insets the
  scrollbar so it clears the card's rounded corners; the inner scroll child is
  transparent (the frame draws the single background — no more box-in-a-box
  from the ChildBg being left on the stack across both BeginChild calls).
- Folders render as a 2-column grid of thin rounded rectangles (folder icon +
  name) instead of full-width rows, so more folders are visible at a glance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:31:48 -05:00
bc3c6037fc fix(image-picker): inset content from rounded card + center footer
- Wrap the picker body in a padded inner child so the filled directory list and
  thumbnail grid keep a clear margin from the card's rounded corners instead of
  running edge-to-edge past them.
- Center the Use image / Cancel buttons and drop the stray separator line above
  them (the faint artifact at the footer's left edge).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:09:18 -05:00
a338fac208 fix(contacts): edit-dialog layout polish + per-row edit + hover address
Address five reported issues:

- Address field is now full width with a centered Paste button beneath it
  (was a narrow field with Paste crammed alongside).
- The pinned "Show in every wallet" checkbox no longer clips at the column
  bottom — the Notes fill reserves a clear margin for it.
- Image mode: more spacing between the preview circle and the Choose/Remove
  row so the button isn't crowding the avatar.
- Contact list: hovering a row now un-collapses the address to its full form
  inline (clipped to the text column) instead of popping a tooltip.
- Per-row copy/edit/delete icons fire on the first click on an unselected row:
  the action lambdas validate s_selected_index freshly (via selValid())
  instead of the frame-top has_selection bool, which was stale in the same
  frame the icon set the selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:53:05 -05:00
9bc2a0b62a feat(contacts): tall edit dialog — fill vertical space, accent Save
The dialog was a squat auto-height box with a big dead zone below and an icon
grid that clipped at ~3 rows. Make it a fixed, tall card (up to 84% of the
viewport) whose body flexes to fill the height:

- The icon grid grows into the space — ~8–9 rows visible instead of 3.
- Notes expands to fill the left column above the now bottom-pinned Global
  checkbox, so the left side uses the height too.
- The image-mode preview circle is bigger (r44 → r56) and vertically centered,
  with glyphs scaled to it.
- Footer pins to the bottom; Save/Add is now accent-colored so the primary
  action reads above Cancel.

Notes/actionButton schema lookups that only fed the old fixed heights are gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:29:04 -05:00
1b3446e43c feat(contacts): richer edit-dialog avatar picker (preview, chips, image, icons)
Four refinements now that the two-column layout has room:

1. Image mode shows a large circular preview of the chosen picture (or a
   placeholder circle with an add-photo glyph, or a broken-image glyph if the
   file went missing), the filename, and centered Choose/Remove buttons —
   instead of a bare button.
2. The live-preview avatar is larger (r20 → r26) and the address is now
   middle-truncated (head + tail) so both ends read, like the real list row.
3. Badge mode shows the two actual chips — Z (shielded) and T (transparent) —
   with labels, making clear the badge is auto-picked from the address type,
   rather than a line of text.
4. The Badge/Icon/Image segmented control gains glyphs (badge / palette /
   image) beside its labels via a small two-font inline control (the shared
   SegmentedControl helper is single-font).

Adds contact_avatar_shielded / _transparent keys and rewords the badge hint
(the chips now carry the Z/T meaning); +8-language translations, all within
the existing CJK subset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:07:07 -05:00
19dea53ef8 refactor(contacts): two-column edit dialog — wider, shorter, roomier grid
The edit dialog was a tall, narrow single column that wasted ~half the
horizontal space and cramped the icon grid into ~2.5 clipped rows crowding
the footer. Rework it into the portfolio-editor two-column shape:

- Card widened 560 → 880 logical.
- Full-width live preview stays on top (now shows more of the address).
- Body is two fixed-height columns: form (label / address+paste / notes /
  global) on the left, avatar picker (segmented + icon grid / image / badge
  hint) on the right, each filling its column so the grid gets ~6 columns and
  ~4 rows instead of clipping mid-row.
- Overall modal is much shorter, so Save/Cancel no longer crowd the grid.
- Badge-mode hint re-centered relative to the current cursor (it now sits
  below the segmented control inside the shared column, not a fresh child).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:23:19 -05:00
3d2b734541 refactor(contacts): drop now-unused addrInput schema lookup
The revamped edit dialog derives its input widths from the card content
width, so the address-input schema config is no longer read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:10:57 -05:00
2d4ba89c00 feat(contacts): revamp edit dialog with live preview + avatar picker
Rebuild the add/edit contact dialog on the portfolio-editor design language:

- A live preview card at the top shows the contact exactly as it renders in the
  list (avatar + name + address), updating as you type and pick an avatar.
- An avatar picker (Badge / Icon / Image segmented control) lets you keep the
  default Z/T type badge, choose a Material wallet icon from a searchable grid,
  or set a custom image. The picker area is fixed-height so the modal doesn't
  jump when switching modes.
- Custom images go through a new in-app ImagePicker (image_picker.h): a
  Material overlay that browses the filesystem starting at the user's Pictures
  folder, shows a thumbnail grid (decoded to raw pixels, box-downscaled to a
  small texture, cached per directory and freed on navigate/close, budgeted a
  few decodes per frame so large folders don't hitch), and returns the chosen
  path. The chosen image is copied into <config>/contact-avatars/ (named by an
  FNV hash of the source path, so re-picking is idempotent) and stored as
  "img:<path>". Like FolderPicker, it takes over the modal surface while open.
- Paste is now available on both add and edit; buttons are content-sized.

Adds three sweep surfaces (contacts-edit-{icon,badge,image}) that open the
dialog on a seeded contact in each avatar mode, plus i18n keys (+ 8-language
translations; reworded two zh/ja strings to stay within the existing CJK
subset, so no font rebuild).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 07:10:00 -05:00
2249bc31d5 feat(contacts): contact avatars — custom image / Material icon / Z-T badge
Add an `avatar` field to AddressBookEntry ("" = default Z/T type badge,
"icon:<name>" = a Material wallet-icon, "img:<path>" = a custom image),
serialized additively in addressbook.json (only written when non-empty, so
existing books are untouched).

Render it in the Cards/List views via a new drawContactAvatar helper: custom
images are loaded once through a path-keyed texture cache and drawn
circular-cropped (centre-cropped UVs + a thin border ring); icons reuse the
project-icon set (incl. the special pickaxe font path) in a tinted circle;
everything else falls back to the existing Z/T badge (also the fallback when
an image fails to load or an icon name is unknown).

Seed one sweep contact with an icon avatar to exercise the icon-badge path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:49:52 -05:00
1ce37aa0fa feat(contacts): deselect on empty click, actions left of globe, right-click menu
Three interaction refinements to the address list:
- The globe badge now stays pinned far-right; the per-row copy/edit/delete actions
  appear to its LEFT on hover/selection instead of replacing it.
- A left-click on empty space in the Cards/List area clears the current selection
  (no row/action hovered -> deselect).
- Right-clicking a row (any view) selects it and opens a shared context menu
  (Copy address / Edit / Delete), rendered once after the list.

Build + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:36:50 -05:00
33317c4e78 feat(contacts): per-row hover actions + fix add/edit modal HiDPI widths
Two follow-ups from the contacts audit:

- Per-row actions: in the Cards/List views, copy/edit/delete icon buttons now appear
  on the right of a row on hover or selection (the globe badge shows otherwise). The
  row Selectable uses SetNextItemAllowOverlap so the action InvisibleButtons take
  click priority; whole-row hover (IsMouseHoveringRect) drives the highlight so it
  survives hovering an icon; the delete icon turns red while armed (two-click confirm);
  each has a tooltip. Trailing space is reserved so the text never reflows on hover,
  and the cursor is restored after the manual action layout.

- Add/edit modal HiDPI: the Layout::kDialog* helpers fold dpiScale() (physical px)
  while raw schema widths are logical, so the schema-path formW/actionW/actionGap/
  notesH were unscaled vs their scaled fallbacks. Scale the schema-path values; and
  since BeginOverlayDialog re-applies dpiScale to cardWidth, divide the already-scaled
  dialogW back out (it was double-scaled). No visible change at 100%; correct at 150%.

Build + ctest + hygiene clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 06:23:17 -05:00
23 changed files with 1797 additions and 65 deletions

View File

@@ -295,6 +295,29 @@ else()
set(CURL_INCLUDE_DIRS ${CURL_INCLUDE_DIR}) set(CURL_INCLUDE_DIRS ${CURL_INCLUDE_DIR})
endif() endif()
# libwebp - WebP decode (still + animated via WebPAnimDecoder). Built from source, static, decode-only
# so Linux / mingw-Windows / macOS-osxcross all build it identically (the mingw/osx sysroots have no
# webp). Encode tools are disabled to avoid pulling in libpng/zlib that the cross sysroots lack.
message(STATUS "Fetching libwebp (decode-only, static)...")
FetchContent_Declare(
libwebp
GIT_REPOSITORY https://github.com/webmproject/libwebp.git
GIT_TAG v1.4.0
GIT_SHALLOW TRUE
)
set(WEBP_LINK_STATIC ON CACHE BOOL "" FORCE)
set(WEBP_BUILD_ANIM_UTILS OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_CWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_DWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_GIF2WEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_IMG2WEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_VWEBP OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_WEBPINFO OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_LIBWEBPMUX OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_WEBPMUX OFF CACHE BOOL "" FORCE)
set(WEBP_BUILD_EXTRAS OFF CACHE BOOL "" FORCE)
FetchContent_MakeAvailable(libwebp)
# libsodium - platform-specific # libsodium - platform-specific
# Search order per platform: # Search order per platform:
# 1. Local pre-built in libs/libsodium{-mac,-win}/ (downloaded by scripts/fetch-libsodium.sh) # 1. Local pre-built in libs/libsodium{-mac,-win}/ (downloaded by scripts/fetch-libsodium.sh)
@@ -735,6 +758,7 @@ target_include_directories(ObsidianDragon PRIVATE
${GLAD_INCLUDE} ${GLAD_INCLUDE}
${CURL_INCLUDE_DIRS} ${CURL_INCLUDE_DIRS}
${MINIZ_DIR} ${MINIZ_DIR}
${libwebp_SOURCE_DIR}/src # <webp/decode.h>, <webp/demux.h> (FetchContent build tree)
) )
target_link_libraries(ObsidianDragon PRIVATE target_link_libraries(ObsidianDragon PRIVATE
@@ -744,6 +768,8 @@ target_link_libraries(ObsidianDragon PRIVATE
sqlite3_amalgamation sqlite3_amalgamation
${CURL_LIBRARIES} ${CURL_LIBRARIES}
${SODIUM_LIBRARY} ${SODIUM_LIBRARY}
webp
webpdemux # WebPAnimDecoder (animated WebP); transitively pulls in webp + sharpyuv
) )
if(DRAGONX_LITE_BACKEND_READY) if(DRAGONX_LITE_BACKEND_READY)

Binary file not shown.

View File

@@ -54,6 +54,7 @@
"amount_details": "BETRAGSDETAILS", "amount_details": "BETRAGSDETAILS",
"amount_exceeds_balance": "Betrag übersteigt Guthaben", "amount_exceeds_balance": "Betrag übersteigt Guthaben",
"amount_label": "Betrag:", "amount_label": "Betrag:",
"animate_avatars": "Avatare animieren",
"appearance": "ERSCHEINUNGSBILD", "appearance": "ERSCHEINUNGSBILD",
"auto_shield": "Mining automatisch abschirmen", "auto_shield": "Mining automatisch abschirmen",
"av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:", "av_intro": "Mining-Software wird oft als potenziell unerwünscht eingestuft. Führen Sie diese Schritte aus, um das Pool-Mining zu aktivieren:",
@@ -287,9 +288,23 @@
"console_welcome": "Willkommen bei ObsidianDragon Konsole", "console_welcome": "Willkommen bei ObsidianDragon Konsole",
"console_zoom_in": "Vergrößern", "console_zoom_in": "Vergrößern",
"console_zoom_out": "Verkleinern", "console_zoom_out": "Verkleinern",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Dieses Bild konnte nicht geladen werden.",
"contact_avatar_badge": "Abzeichen",
"contact_avatar_badge_hint": "Das Abzeichen wird automatisch anhand des Adresstyps gewählt.",
"contact_avatar_choose": "Bild auswählen…",
"contact_avatar_copy_failed": "Dieses Bild konnte nicht kopiert werden.",
"contact_avatar_icon": "Symbol",
"contact_avatar_image": "Bild",
"contact_avatar_image_hint": "Das Bild wird in die App kopiert, damit es verfügbar bleibt, wenn das Original verschoben wird.",
"contact_avatar_remove": "Entfernen",
"contact_avatar_shielded": "Abgeschirmt",
"contact_avatar_transparent": "Transparent",
"contact_global": "In jeder Wallet anzeigen (globaler Kontakt)", "contact_global": "In jeder Wallet anzeigen (globaler Kontakt)",
"contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar", "contact_global_badge_tt": "Globaler Kontakt — in jeder Wallet sichtbar",
"contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.", "contact_global_tt": "Ein: Dieser Kontakt bleibt sichtbar, egal welche Wallet Sie laden. Aus: Er gehört nur zur aktuellen Wallet.",
"contact_preview_addr": "Adresse erscheint hier",
"contact_preview_name": "Kontaktname",
"contacts": "Kontakte", "contacts": "Kontakte",
"contacts_search_no_match": "Keine passenden Kontakte", "contacts_search_no_match": "Keine passenden Kontakte",
"contacts_search_placeholder": "Kontakte durchsuchen...", "contacts_search_placeholder": "Kontakte durchsuchen...",
@@ -465,6 +480,12 @@
"hide_qr": "QR ausblenden", "hide_qr": "QR ausblenden",
"hide_zero_balances": "Nullsalden ausblenden", "hide_zero_balances": "Nullsalden ausblenden",
"history": "Verlauf", "history": "Verlauf",
"img_picker_count": "%d Bild(er) in diesem Ordner",
"img_picker_empty": "Dieser Ordner enthält keine Unterordner oder Bilder.",
"img_picker_none": "Keine Bilder in diesem Ordner",
"img_picker_pictures": "Bilderordner",
"img_picker_title": "Bild auswählen",
"img_picker_use": "Bild verwenden",
"immature_type": "Unreif", "immature_type": "Unreif",
"import": "Importieren", "import": "Importieren",
"import_key_address": "Adresse:", "import_key_address": "Adresse:",
@@ -1309,6 +1330,7 @@
"try_again": "Erneut versuchen", "try_again": "Erneut versuchen",
"tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer", "tt_addr_url": "Basis-URL zum Anzeigen von Adressen in einem Block-Explorer",
"tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten", "tt_address_book": "Gespeicherte Adressen für schnelles Senden verwalten",
"tt_animate_avatars": "Animierte Kontakt-Avatare (GIF / WebP) abspielen; aus zeigt nur das erste Bild",
"tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren", "tt_auto_lock": "Wallet nach dieser Inaktivitätszeit sperren",
"tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben", "tt_auto_shield": "Transparentes Guthaben automatisch an geschirmte Adressen für Datenschutz verschieben",
"tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen", "tt_backup": "Eine Sicherungskopie Ihrer wallet.dat erstellen",

View File

@@ -54,6 +54,7 @@
"amount_details": "DETALLES DE CANTIDAD", "amount_details": "DETALLES DE CANTIDAD",
"amount_exceeds_balance": "La cantidad excede el saldo", "amount_exceeds_balance": "La cantidad excede el saldo",
"amount_label": "Cantidad:", "amount_label": "Cantidad:",
"animate_avatars": "Animar avatares",
"appearance": "APARIENCIA", "appearance": "APARIENCIA",
"auto_shield": "Auto-proteger minería", "auto_shield": "Auto-proteger minería",
"av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:", "av_intro": "El software de minería suele marcarse como potencialmente no deseado. Sigue estos pasos para habilitar la minería en pool:",
@@ -287,9 +288,23 @@
"console_welcome": "Bienvenido a la Consola de ObsidianDragon", "console_welcome": "Bienvenido a la Consola de ObsidianDragon",
"console_zoom_in": "Acercar", "console_zoom_in": "Acercar",
"console_zoom_out": "Alejar", "console_zoom_out": "Alejar",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "No se pudo cargar esa imagen.",
"contact_avatar_badge": "Insignia",
"contact_avatar_badge_hint": "La insignia se elige automáticamente según el tipo de dirección.",
"contact_avatar_choose": "Elegir imagen…",
"contact_avatar_copy_failed": "No se pudo copiar esa imagen.",
"contact_avatar_icon": "Icono",
"contact_avatar_image": "Imagen",
"contact_avatar_image_hint": "La imagen se copia en la app para que siga disponible si el original se mueve.",
"contact_avatar_remove": "Quitar",
"contact_avatar_shielded": "Blindada",
"contact_avatar_transparent": "Transparente",
"contact_global": "Mostrar en todas las carteras (contacto global)", "contact_global": "Mostrar en todas las carteras (contacto global)",
"contact_global_badge_tt": "Contacto global — visible en todas las carteras", "contact_global_badge_tt": "Contacto global — visible en todas las carteras",
"contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.", "contact_global_tt": "Activado: este contacto permanece visible sin importar qué cartera cargues. Desactivado: pertenece solo a la cartera actual.",
"contact_preview_addr": "La dirección aparecerá aquí",
"contact_preview_name": "Nombre del contacto",
"contacts": "Contactos", "contacts": "Contactos",
"contacts_search_no_match": "No hay contactos coincidentes", "contacts_search_no_match": "No hay contactos coincidentes",
"contacts_search_placeholder": "Buscar contactos...", "contacts_search_placeholder": "Buscar contactos...",
@@ -465,6 +480,12 @@
"hide_qr": "Ocultar QR", "hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos 0", "hide_zero_balances": "Ocultar saldos 0",
"history": "Historial", "history": "Historial",
"img_picker_count": "%d imagen(es) en esta carpeta",
"img_picker_empty": "Esta carpeta no tiene subcarpetas ni imágenes.",
"img_picker_none": "No hay imágenes en esta carpeta",
"img_picker_pictures": "Carpeta de imágenes",
"img_picker_title": "Elegir una imagen",
"img_picker_use": "Usar imagen",
"immature_type": "Inmaduro", "immature_type": "Inmaduro",
"import": "Importar", "import": "Importar",
"import_key_address": "Dirección:", "import_key_address": "Dirección:",
@@ -1309,6 +1330,7 @@
"try_again": "Reintentar", "try_again": "Reintentar",
"tt_addr_url": "URL base para ver direcciones en un explorador de bloques", "tt_addr_url": "URL base para ver direcciones en un explorador de bloques",
"tt_address_book": "Administrar direcciones guardadas para envío rápido", "tt_address_book": "Administrar direcciones guardadas para envío rápido",
"tt_animate_avatars": "Reproduce avatares de contacto animados (GIF / WebP); desactivado muestra solo el primer fotograma",
"tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad", "tt_auto_lock": "Bloquear billetera después de este tiempo de inactividad",
"tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad", "tt_auto_shield": "Mover automáticamente el saldo transparente a direcciones blindadas para privacidad",
"tt_backup": "Crear una copia de seguridad de su wallet.dat", "tt_backup": "Crear una copia de seguridad de su wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "DÉTAILS DU MONTANT", "amount_details": "DÉTAILS DU MONTANT",
"amount_exceeds_balance": "Le montant dépasse le solde", "amount_exceeds_balance": "Le montant dépasse le solde",
"amount_label": "Montant :", "amount_label": "Montant :",
"animate_avatars": "Animer les avatars",
"appearance": "APPARENCE", "appearance": "APPARENCE",
"auto_shield": "Auto-blindage du minage", "auto_shield": "Auto-blindage du minage",
"av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :", "av_intro": "Les logiciels de minage sont souvent signalés comme potentiellement indésirables. Suivez ces étapes pour activer le minage en pool :",
@@ -287,9 +288,23 @@
"console_welcome": "Bienvenue dans la console ObsidianDragon", "console_welcome": "Bienvenue dans la console ObsidianDragon",
"console_zoom_in": "Agrandir", "console_zoom_in": "Agrandir",
"console_zoom_out": "Réduire", "console_zoom_out": "Réduire",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Cette image n'a pas pu être chargée.",
"contact_avatar_badge": "Badge",
"contact_avatar_badge_hint": "Le badge est choisi automatiquement selon le type d'adresse.",
"contact_avatar_choose": "Choisir une image…",
"contact_avatar_copy_failed": "Impossible de copier cette image.",
"contact_avatar_icon": "Icône",
"contact_avatar_image": "Image",
"contact_avatar_image_hint": "L'image est copiée dans l'application pour rester disponible si l'original est déplacé.",
"contact_avatar_remove": "Retirer",
"contact_avatar_shielded": "Blindée",
"contact_avatar_transparent": "Transparente",
"contact_global": "Afficher dans tous les portefeuilles (contact global)", "contact_global": "Afficher dans tous les portefeuilles (contact global)",
"contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles", "contact_global_badge_tt": "Contact global — visible dans tous les portefeuilles",
"contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.", "contact_global_tt": "Activé : ce contact reste visible quel que soit le portefeuille chargé. Désactivé : il appartient au portefeuille actuel uniquement.",
"contact_preview_addr": "L'adresse apparaîtra ici",
"contact_preview_name": "Nom du contact",
"contacts": "Contacts", "contacts": "Contacts",
"contacts_search_no_match": "Aucun contact correspondant", "contacts_search_no_match": "Aucun contact correspondant",
"contacts_search_placeholder": "Rechercher des contacts...", "contacts_search_placeholder": "Rechercher des contacts...",
@@ -465,6 +480,12 @@
"hide_qr": "Masquer le QR", "hide_qr": "Masquer le QR",
"hide_zero_balances": "Masquer les soldes à 0", "hide_zero_balances": "Masquer les soldes à 0",
"history": "Historique", "history": "Historique",
"img_picker_count": "%d image(s) dans ce dossier",
"img_picker_empty": "Ce dossier ne contient ni sous-dossiers ni images.",
"img_picker_none": "Aucune image dans ce dossier",
"img_picker_pictures": "Dossier Images",
"img_picker_title": "Choisir une image",
"img_picker_use": "Utiliser l'image",
"immature_type": "Immature", "immature_type": "Immature",
"import": "Importer", "import": "Importer",
"import_key_address": "Adresse :", "import_key_address": "Adresse :",
@@ -1309,6 +1330,7 @@
"try_again": "Réessayer", "try_again": "Réessayer",
"tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs", "tt_addr_url": "URL de base pour consulter les adresses dans un explorateur de blocs",
"tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide", "tt_address_book": "Gérer les adresses enregistrées pour un envoi rapide",
"tt_animate_avatars": "Lit les avatars de contact animés (GIF / WebP) ; désactivé n'affiche que la première image",
"tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité", "tt_auto_lock": "Verrouiller le portefeuille après cette durée d'inactivité",
"tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité", "tt_auto_shield": "Déplacer automatiquement le solde transparent vers des adresses blindées pour la confidentialité",
"tt_backup": "Créer une sauvegarde de votre wallet.dat", "tt_backup": "Créer une sauvegarde de votre wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "金額の詳細", "amount_details": "金額の詳細",
"amount_exceeds_balance": "金額が残高を超えています", "amount_exceeds_balance": "金額が残高を超えています",
"amount_label": "金額:", "amount_label": "金額:",
"animate_avatars": "アバターをアニメーション",
"appearance": "外観", "appearance": "外観",
"auto_shield": "マイニング自動シールド", "auto_shield": "マイニング自動シールド",
"av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。", "av_intro": "マイニングソフトウェアは、望ましくない可能性があるものとしてフラグが立てられることがよくあります。プールマイニングを有効にするには、次の手順に従ってください。",
@@ -287,9 +288,23 @@
"console_welcome": "ObsidianDragonコンソールへようこそ", "console_welcome": "ObsidianDragonコンソールへようこそ",
"console_zoom_in": "拡大", "console_zoom_in": "拡大",
"console_zoom_out": "縮小", "console_zoom_out": "縮小",
"contact_avatar": "アバター",
"contact_avatar_bad_image": "その画像を読み込めませんでした。",
"contact_avatar_badge": "バッジ",
"contact_avatar_badge_hint": "バッジはアドレスの種類に応じて自動的に選ばれます。",
"contact_avatar_choose": "画像を選択…",
"contact_avatar_copy_failed": "その画像をコピーできませんでした。",
"contact_avatar_icon": "アイコン",
"contact_avatar_image": "画像",
"contact_avatar_image_hint": "画像はアプリ内にコピーされ、元のファイルが移動しても利用できます。",
"contact_avatar_remove": "削除",
"contact_avatar_shielded": "シールド",
"contact_avatar_transparent": "透明",
"contact_global": "すべてのウォレットで表示(グローバル連絡先)", "contact_global": "すべてのウォレットで表示(グローバル連絡先)",
"contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示", "contact_global_badge_tt": "グローバル連絡先 — すべてのウォレットで表示",
"contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。", "contact_global_tt": "オン:この連絡先はどのウォレットを読み込んでも表示されます。オフ:現在のウォレットにのみ属します。",
"contact_preview_addr": "ここにアドレスが表示されます",
"contact_preview_name": "連絡先名",
"contacts": "連絡先", "contacts": "連絡先",
"contacts_search_no_match": "一致する連絡先がありません", "contacts_search_no_match": "一致する連絡先がありません",
"contacts_search_placeholder": "連絡先を検索...", "contacts_search_placeholder": "連絡先を検索...",
@@ -465,6 +480,12 @@
"hide_qr": "QRを非表示", "hide_qr": "QRを非表示",
"hide_zero_balances": "残高0を非表示", "hide_zero_balances": "残高0を非表示",
"history": "履歴", "history": "履歴",
"img_picker_count": "このフォルダの画像:%d",
"img_picker_empty": "このフォルダにはサブフォルダも画像もありません。",
"img_picker_none": "このフォルダに画像はありません",
"img_picker_pictures": "画像フォルダ",
"img_picker_title": "画像を選択",
"img_picker_use": "この画像を使用",
"immature_type": "未成熟", "immature_type": "未成熟",
"import": "インポート", "import": "インポート",
"import_key_address": "アドレス:", "import_key_address": "アドレス:",
@@ -1309,6 +1330,7 @@
"try_again": "再試行", "try_again": "再試行",
"tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL", "tt_addr_url": "ブロックエクスプローラーでアドレスを表示するためのベース URL",
"tt_address_book": "クイック送信用の保存済みアドレスを管理", "tt_address_book": "クイック送信用の保存済みアドレスを管理",
"tt_animate_avatars": "アニメーション連絡先アバターGIF / WebPを再生。オフでは最初のフレームのみ表示",
"tt_auto_lock": "この無操作時間後にウォレットをロック", "tt_auto_lock": "この無操作時間後にウォレットをロック",
"tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動", "tt_auto_shield": "プライバシーのため透明残高を自動的にシールドアドレスに移動",
"tt_backup": "wallet.dat のバックアップを作成", "tt_backup": "wallet.dat のバックアップを作成",

View File

@@ -54,6 +54,7 @@
"amount_details": "금액 상세", "amount_details": "금액 상세",
"amount_exceeds_balance": "금액이 잔액을 초과합니다", "amount_exceeds_balance": "금액이 잔액을 초과합니다",
"amount_label": "금액:", "amount_label": "금액:",
"animate_avatars": "아바타 애니메이션",
"appearance": "외관", "appearance": "외관",
"auto_shield": "채굴 자동 차폐", "auto_shield": "채굴 자동 차폐",
"av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:", "av_intro": "채굴 소프트웨어는 종종 잠재적으로 원치 않는 항목으로 표시됩니다. 풀 채굴을 활성화하려면 다음 단계를 따르세요:",
@@ -287,9 +288,23 @@
"console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다", "console_welcome": "ObsidianDragon 콘솔에 오신 것을 환영합니다",
"console_zoom_in": "확대", "console_zoom_in": "확대",
"console_zoom_out": "축소", "console_zoom_out": "축소",
"contact_avatar": "아바타",
"contact_avatar_bad_image": "그 이미지를 불러올 수 없습니다.",
"contact_avatar_badge": "배지",
"contact_avatar_badge_hint": "배지는 주소 유형에 따라 자동으로 선택됩니다.",
"contact_avatar_choose": "이미지 선택…",
"contact_avatar_copy_failed": "그 이미지를 복사할 수 없습니다.",
"contact_avatar_icon": "아이콘",
"contact_avatar_image": "이미지",
"contact_avatar_image_hint": "이미지는 앱에 복사되어 원본이 이동해도 계속 사용할 수 있습니다.",
"contact_avatar_remove": "제거",
"contact_avatar_shielded": "보호",
"contact_avatar_transparent": "투명",
"contact_global": "모든 지갑에 표시(전역 연락처)", "contact_global": "모든 지갑에 표시(전역 연락처)",
"contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨", "contact_global_badge_tt": "전역 연락처 — 모든 지갑에서 표시됨",
"contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.", "contact_global_tt": "켜짐: 어떤 지갑을 불러오든 이 연락처가 계속 표시됩니다. 꺼짐: 현재 지갑에만 속합니다.",
"contact_preview_addr": "여기에 주소가 표시됩니다",
"contact_preview_name": "연락처 이름",
"contacts": "연락처", "contacts": "연락처",
"contacts_search_no_match": "일치하는 연락처 없음", "contacts_search_no_match": "일치하는 연락처 없음",
"contacts_search_placeholder": "연락처 검색...", "contacts_search_placeholder": "연락처 검색...",
@@ -465,6 +480,12 @@
"hide_qr": "QR 숨기기", "hide_qr": "QR 숨기기",
"hide_zero_balances": "잔액 0 숨기기", "hide_zero_balances": "잔액 0 숨기기",
"history": "내역", "history": "내역",
"img_picker_count": "이 폴더에 이미지 %d개",
"img_picker_empty": "이 폴더에는 하위 폴더나 이미지가 없습니다.",
"img_picker_none": "이 폴더에 이미지가 없습니다",
"img_picker_pictures": "사진 폴더",
"img_picker_title": "이미지 선택",
"img_picker_use": "이미지 사용",
"immature_type": "미성숙", "immature_type": "미성숙",
"import": "가져오기", "import": "가져오기",
"import_key_address": "주소:", "import_key_address": "주소:",
@@ -1309,6 +1330,7 @@
"try_again": "다시 시도", "try_again": "다시 시도",
"tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL", "tt_addr_url": "블록 탐색기에서 주소를 보기 위한 기본 URL",
"tt_address_book": "빠른 전송을 위해 저장된 주소 관리", "tt_address_book": "빠른 전송을 위해 저장된 주소 관리",
"tt_animate_avatars": "애니메이션 연락처 아바타(GIF / WebP) 재생, 끄면 첫 프레임만 표시",
"tt_auto_lock": "이 비활성 시간 후 지갑 잠금", "tt_auto_lock": "이 비활성 시간 후 지갑 잠금",
"tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동", "tt_auto_shield": "개인 정보 보호를 위해 투명 잔액을 자동으로 차폐 주소로 이동",
"tt_backup": "wallet.dat 백업 만들기", "tt_backup": "wallet.dat 백업 만들기",

View File

@@ -54,6 +54,7 @@
"amount_details": "DETALHES DO VALOR", "amount_details": "DETALHES DO VALOR",
"amount_exceeds_balance": "Valor excede o saldo", "amount_exceeds_balance": "Valor excede o saldo",
"amount_label": "Valor:", "amount_label": "Valor:",
"animate_avatars": "Animar avatares",
"appearance": "APARÊNCIA", "appearance": "APARÊNCIA",
"auto_shield": "Auto-blindar mineração", "auto_shield": "Auto-blindar mineração",
"av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:", "av_intro": "Softwares de mineração costumam ser sinalizados como potencialmente indesejados. Siga estes passos para habilitar a mineração em pool:",
@@ -287,9 +288,23 @@
"console_welcome": "Bem-vindo ao Console ObsidianDragon", "console_welcome": "Bem-vindo ao Console ObsidianDragon",
"console_zoom_in": "Aumentar zoom", "console_zoom_in": "Aumentar zoom",
"console_zoom_out": "Diminuir zoom", "console_zoom_out": "Diminuir zoom",
"contact_avatar": "AVATAR",
"contact_avatar_bad_image": "Não foi possível carregar essa imagem.",
"contact_avatar_badge": "Selo",
"contact_avatar_badge_hint": "O selo é escolhido automaticamente conforme o tipo de endereço.",
"contact_avatar_choose": "Escolher imagem…",
"contact_avatar_copy_failed": "Não foi possível copiar essa imagem.",
"contact_avatar_icon": "Ícone",
"contact_avatar_image": "Imagem",
"contact_avatar_image_hint": "A imagem é copiada para o app para continuar disponível se o original for movido.",
"contact_avatar_remove": "Remover",
"contact_avatar_shielded": "Blindado",
"contact_avatar_transparent": "Transparente",
"contact_global": "Mostrar em todas as carteiras (contato global)", "contact_global": "Mostrar em todas as carteiras (contato global)",
"contact_global_badge_tt": "Contato global — visível em todas as carteiras", "contact_global_badge_tt": "Contato global — visível em todas as carteiras",
"contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.", "contact_global_tt": "Ativado: este contato permanece visível em qualquer carteira que você carregar. Desativado: ele pertence apenas à carteira atual.",
"contact_preview_addr": "O endereço aparecerá aqui",
"contact_preview_name": "Nome do contato",
"contacts": "Contatos", "contacts": "Contatos",
"contacts_search_no_match": "Nenhum contato correspondente", "contacts_search_no_match": "Nenhum contato correspondente",
"contacts_search_placeholder": "Pesquisar contatos...", "contacts_search_placeholder": "Pesquisar contatos...",
@@ -465,6 +480,12 @@
"hide_qr": "Ocultar QR", "hide_qr": "Ocultar QR",
"hide_zero_balances": "Ocultar saldos zero", "hide_zero_balances": "Ocultar saldos zero",
"history": "Histórico", "history": "Histórico",
"img_picker_count": "%d imagem(ns) nesta pasta",
"img_picker_empty": "Esta pasta não tem subpastas nem imagens.",
"img_picker_none": "Nenhuma imagem nesta pasta",
"img_picker_pictures": "Pasta de imagens",
"img_picker_title": "Escolher uma imagem",
"img_picker_use": "Usar imagem",
"immature_type": "Imaturo", "immature_type": "Imaturo",
"import": "Importar", "import": "Importar",
"import_key_address": "Endereço:", "import_key_address": "Endereço:",
@@ -1309,6 +1330,7 @@
"try_again": "Tentar novamente", "try_again": "Tentar novamente",
"tt_addr_url": "URL base para visualizar endereços em um explorador de blocos", "tt_addr_url": "URL base para visualizar endereços em um explorador de blocos",
"tt_address_book": "Gerenciar endereços salvos para envio rápido", "tt_address_book": "Gerenciar endereços salvos para envio rápido",
"tt_animate_avatars": "Reproduz avatares de contato animados (GIF / WebP); desativado mostra só o primeiro quadro",
"tt_auto_lock": "Bloquear carteira após este tempo de inatividade", "tt_auto_lock": "Bloquear carteira após este tempo de inatividade",
"tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade", "tt_auto_shield": "Mover automaticamente o saldo transparente para endereços blindados para privacidade",
"tt_backup": "Criar um backup do seu wallet.dat", "tt_backup": "Criar um backup do seu wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "ДЕТАЛИ СУММЫ", "amount_details": "ДЕТАЛИ СУММЫ",
"amount_exceeds_balance": "Сумма превышает баланс", "amount_exceeds_balance": "Сумма превышает баланс",
"amount_label": "Сумма:", "amount_label": "Сумма:",
"animate_avatars": "Анимировать аватары",
"appearance": "ВНЕШНИЙ ВИД", "appearance": "ВНЕШНИЙ ВИД",
"auto_shield": "Авто-экранирование майнинга", "auto_shield": "Авто-экранирование майнинга",
"av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:", "av_intro": "Программы для майнинга часто помечаются как потенциально нежелательные. Выполните эти шаги, чтобы включить пул-майнинг:",
@@ -287,9 +288,23 @@
"console_welcome": "Добро пожаловать в консоль ObsidianDragon", "console_welcome": "Добро пожаловать в консоль ObsidianDragon",
"console_zoom_in": "Увеличить", "console_zoom_in": "Увеличить",
"console_zoom_out": "Уменьшить", "console_zoom_out": "Уменьшить",
"contact_avatar": "АВАТАР",
"contact_avatar_bad_image": "Не удалось загрузить это изображение.",
"contact_avatar_badge": "Значок",
"contact_avatar_badge_hint": "Значок выбирается автоматически по типу адреса.",
"contact_avatar_choose": "Выбрать изображение…",
"contact_avatar_copy_failed": "Не удалось скопировать это изображение.",
"contact_avatar_icon": "Иконка",
"contact_avatar_image": "Изображение",
"contact_avatar_image_hint": "Изображение копируется в приложение, чтобы оставаться доступным, если оригинал переместят.",
"contact_avatar_remove": "Удалить",
"contact_avatar_shielded": "Защищённый",
"contact_avatar_transparent": "Прозрачный",
"contact_global": "Показывать во всех кошельках (глобальный контакт)", "contact_global": "Показывать во всех кошельках (глобальный контакт)",
"contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках", "contact_global_badge_tt": "Глобальный контакт — виден во всех кошельках",
"contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.", "contact_global_tt": "Вкл.: этот контакт остаётся видимым, какой бы кошелёк вы ни загрузили. Выкл.: он принадлежит только текущему кошельку.",
"contact_preview_addr": "Здесь появится адрес",
"contact_preview_name": "Имя контакта",
"contacts": "Контакты", "contacts": "Контакты",
"contacts_search_no_match": "Совпадающих контактов нет", "contacts_search_no_match": "Совпадающих контактов нет",
"contacts_search_placeholder": "Поиск контактов...", "contacts_search_placeholder": "Поиск контактов...",
@@ -465,6 +480,12 @@
"hide_qr": "Скрыть QR", "hide_qr": "Скрыть QR",
"hide_zero_balances": "Скрыть нулевые балансы", "hide_zero_balances": "Скрыть нулевые балансы",
"history": "История", "history": "История",
"img_picker_count": "Изображений в этой папке: %d",
"img_picker_empty": "В этой папке нет подпапок или изображений.",
"img_picker_none": "В этой папке нет изображений",
"img_picker_pictures": "Папка изображений",
"img_picker_title": "Выбрать изображение",
"img_picker_use": "Использовать изображение",
"immature_type": "Незрелая", "immature_type": "Незрелая",
"import": "Импорт", "import": "Импорт",
"import_key_address": "Адрес:", "import_key_address": "Адрес:",
@@ -1309,6 +1330,7 @@
"try_again": "Повторить", "try_again": "Повторить",
"tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков", "tt_addr_url": "Базовый URL для просмотра адресов в обозревателе блоков",
"tt_address_book": "Управление сохранёнными адресами для быстрой отправки", "tt_address_book": "Управление сохранёнными адресами для быстрой отправки",
"tt_animate_avatars": "Воспроизводить анимированные аватары контактов (GIF / WebP); при отключении показывается только первый кадр",
"tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия", "tt_auto_lock": "Заблокировать кошелёк после этого времени бездействия",
"tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности", "tt_auto_shield": "Автоматически перемещать прозрачный баланс на экранированные адреса для конфиденциальности",
"tt_backup": "Создать резервную копию вашего wallet.dat", "tt_backup": "Создать резервную копию вашего wallet.dat",

View File

@@ -54,6 +54,7 @@
"amount_details": "金额详情", "amount_details": "金额详情",
"amount_exceeds_balance": "金额超过余额", "amount_exceeds_balance": "金额超过余额",
"amount_label": "金额:", "amount_label": "金额:",
"animate_avatars": "动画头像",
"appearance": "外观", "appearance": "外观",
"auto_shield": "自动屏蔽挖矿", "auto_shield": "自动屏蔽挖矿",
"av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:", "av_intro": "挖矿软件经常被标记为潜在有害程序。请按照以下步骤启用矿池挖矿:",
@@ -287,9 +288,23 @@
"console_welcome": "欢迎使用 ObsidianDragon 控制台", "console_welcome": "欢迎使用 ObsidianDragon 控制台",
"console_zoom_in": "放大", "console_zoom_in": "放大",
"console_zoom_out": "缩小", "console_zoom_out": "缩小",
"contact_avatar": "头像",
"contact_avatar_bad_image": "无法加载该图片。",
"contact_avatar_badge": "标记",
"contact_avatar_badge_hint": "标记会根据地址类型自动选择。",
"contact_avatar_choose": "选择图片…",
"contact_avatar_copy_failed": "无法复制该图片。",
"contact_avatar_icon": "图标",
"contact_avatar_image": "图片",
"contact_avatar_image_hint": "图片会复制到应用中,即使原文件移动也能保持可用。",
"contact_avatar_remove": "移除",
"contact_avatar_shielded": "隐蔽",
"contact_avatar_transparent": "透明",
"contact_global": "在每个钱包中显示(全局联系人)", "contact_global": "在每个钱包中显示(全局联系人)",
"contact_global_badge_tt": "全局联系人——在每个钱包中可见", "contact_global_badge_tt": "全局联系人——在每个钱包中可见",
"contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。", "contact_global_tt": "开启:无论您加载哪个钱包,此联系人都保持可见。关闭:它仅属于当前钱包。",
"contact_preview_addr": "地址将显示在此处",
"contact_preview_name": "联系人名称",
"contacts": "联系人", "contacts": "联系人",
"contacts_search_no_match": "没有匹配的联系人", "contacts_search_no_match": "没有匹配的联系人",
"contacts_search_placeholder": "搜索联系人...", "contacts_search_placeholder": "搜索联系人...",
@@ -465,6 +480,12 @@
"hide_qr": "隐藏二维码", "hide_qr": "隐藏二维码",
"hide_zero_balances": "隐藏零余额", "hide_zero_balances": "隐藏零余额",
"history": "历史", "history": "历史",
"img_picker_count": "此文件夹中的图片:%d",
"img_picker_empty": "此文件夹没有子文件夹或图片。",
"img_picker_none": "此文件夹中没有图片",
"img_picker_pictures": "图片文件夹",
"img_picker_title": "选择图片",
"img_picker_use": "使用图片",
"immature_type": "未成熟", "immature_type": "未成熟",
"import": "导入", "import": "导入",
"import_key_address": "地址:", "import_key_address": "地址:",
@@ -1309,6 +1330,7 @@
"try_again": "重试", "try_again": "重试",
"tt_addr_url": "在区块浏览器中查看地址的基础 URL", "tt_addr_url": "在区块浏览器中查看地址的基础 URL",
"tt_address_book": "管理已保存的地址以快速发送", "tt_address_book": "管理已保存的地址以快速发送",
"tt_animate_avatars": "播放动画联系人头像GIF / WebP关闭时仅显示第一帧",
"tt_auto_lock": "在此不活动时间后锁定钱包", "tt_auto_lock": "在此不活动时间后锁定钱包",
"tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私", "tt_auto_shield": "自动将透明余额转移到屏蔽地址以增强隐私",
"tt_backup": "创建 wallet.dat 的备份", "tt_backup": "创建 wallet.dat 的备份",

View File

@@ -32,6 +32,7 @@
#include "ui/windows/shield_dialog.h" #include "ui/windows/shield_dialog.h"
#include "ui/windows/address_transfer_dialog.h" #include "ui/windows/address_transfer_dialog.h"
#include "ui/windows/key_export_dialog.h" #include "ui/windows/key_export_dialog.h"
#include "ui/windows/contacts_tab.h"
#include "ui/pages/settings_page.h" #include "ui/pages/settings_page.h"
#include "util/platform.h" #include "util/platform.h"
#include "wallet/wallet_capabilities.h" #include "wallet/wallet_capabilities.h"
@@ -100,6 +101,7 @@ void seedSweepContacts(App& a)
{ {
s_contactsSweepBackup = a.addressBook().entries(); s_contactsSweepBackup = a.addressBook().entries();
data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global data::AddressBookEntry e1("drgx pool payout address", kDemoZAddr, "mining pool payouts"); // Z, global
e1.avatar = "icon:account_balance"; // icon avatar (verifies the icon-badge render path)
data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T data::AddressBookEntry e2("exchange deposit", "t1DemoTransparentAddressForUiSweep00000", ""); // T
// Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet); // Scope to the active wallet so it's visible (the tab hides contacts not in the active wallet);
// non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix. // non-empty hash also keeps it non-global -> no globe badge, so the list shows a global/non-global mix.
@@ -407,6 +409,19 @@ void App::buildSweepCatalog()
add("contacts-table", ui::NavPage::Contacts, add("contacts-table", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); }, [](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(2); },
[](App& a) { restoreSweepContacts(a); }); [](App& a) { restoreSweepContacts(a); });
// Revamped edit dialog: live preview + avatar picker, one surface per avatar mode.
add("contacts-edit-icon", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(1); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("contacts-edit-badge", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(0); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("contacts-edit-image", ui::NavPage::Contacts,
[](App& a) { seedSweepContacts(a); if (a.settings()) a.settings()->setContactsViewMode(1);
ui::ContactsSweepOpenEditDialog(2); },
[](App& a) { ui::ContactsSweepCloseDialog(); restoreSweepContacts(a); });
add("modal-about", ui::NavPage::Overview, add("modal-about", ui::NavPage::Overview,
[](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; }); [](App& a) { a.show_about_ = true; }, [](App& a) { a.show_about_ = false; });
add("modal-settings", ui::NavPage::Settings, add("modal-settings", ui::NavPage::Settings,

View File

@@ -176,6 +176,7 @@ bool Settings::load(const std::string& path)
if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0; if (portfolio_style_ < 0 || portfolio_style_ > 2) portfolio_style_ = 0;
loadScalar(j, "contacts_view_mode", contacts_view_mode_); loadScalar(j, "contacts_view_mode", contacts_view_mode_);
if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0; if (contacts_view_mode_ < 0 || contacts_view_mode_ > 2) contacts_view_mode_ = 0;
loadScalar(j, "animate_avatars", animate_avatars_);
loadScalar(j, "scanline_enabled", scanline_enabled_); loadScalar(j, "scanline_enabled", scanline_enabled_);
loadScalar(j, "console_line_accents", console_line_accents_); loadScalar(j, "console_line_accents", console_line_accents_);
loadScalar(j, "console_text_color", console_text_color_); loadScalar(j, "console_text_color", console_text_color_);
@@ -431,6 +432,7 @@ bool Settings::save(const std::string& path)
j["balance_layout"] = balance_layout_; // saved as string ID j["balance_layout"] = balance_layout_; // saved as string ID
j["portfolio_style"] = portfolio_style_; j["portfolio_style"] = portfolio_style_;
j["contacts_view_mode"] = contacts_view_mode_; j["contacts_view_mode"] = contacts_view_mode_;
j["animate_avatars"] = animate_avatars_;
j["scanline_enabled"] = scanline_enabled_; j["scanline_enabled"] = scanline_enabled_;
j["console_line_accents"] = console_line_accents_; j["console_line_accents"] = console_line_accents_;
j["console_text_color"] = console_text_color_; j["console_text_color"] = console_text_color_;

View File

@@ -187,6 +187,9 @@ public:
// Contacts tab address-list view: 0 = cards, 1 = list, 2 = table. // Contacts tab address-list view: 0 = cards, 1 = list, 2 = table.
int getContactsViewMode() const { return contacts_view_mode_; } int getContactsViewMode() const { return contacts_view_mode_; }
void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; } void setContactsViewMode(int v) { contacts_view_mode_ = (v < 0 || v > 2) ? 0 : v; }
// Play animated contact avatars (GIF/WebP). Off = show the first frame only.
bool getAnimateAvatars() const { return animate_avatars_; }
void setAnimateAvatars(bool v) { animate_avatars_ = v; }
// Console scanline effect // Console scanline effect
bool getScanlineEnabled() const { return scanline_enabled_; } bool getScanlineEnabled() const { return scanline_enabled_; }
@@ -494,6 +497,7 @@ private:
std::string balance_layout_ = "classic"; std::string balance_layout_ = "classic";
int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero) int portfolio_style_ = 0; // Market portfolio row style (0 single / 1 two-line / 2 hero)
int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table) int contacts_view_mode_ = 0; // Contacts address-list view (0 cards / 1 list / 2 table)
bool animate_avatars_ = true; // play animated (GIF/WebP) contact avatars
bool scanline_enabled_ = true; bool scanline_enabled_ = true;
bool console_line_accents_ = true; // left color accent bars in console output bool console_line_accents_ = true; // left color accent bars in console output
bool console_text_color_ = true; // per-channel text coloring in console output bool console_text_color_ = true; // per-channel text coloring in console output

View File

@@ -54,6 +54,7 @@ bool AddressBook::load()
// Legacy entries (no "scope") migrate to "global" so nothing disappears when // Legacy entries (no "scope") migrate to "global" so nothing disappears when
// multi-wallet scoping lands — a contact you already had stays visible everywhere. // multi-wallet scoping lands — a contact you already had stays visible everywhere.
e.scope = entry.value("scope", "global"); e.scope = entry.value("scope", "global");
e.avatar = entry.value("avatar", "");
if (!e.address.empty()) { if (!e.address.empty()) {
entries_.push_back(e); entries_.push_back(e);
@@ -86,6 +87,7 @@ bool AddressBook::save()
e["address"] = entry.address; e["address"] = entry.address;
e["notes"] = entry.notes; e["notes"] = entry.notes;
e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope; e["scope"] = entry.scope.empty() ? std::string("global") : entry.scope;
if (!entry.avatar.empty()) e["avatar"] = entry.avatar;
j["entries"].push_back(e); j["entries"].push_back(e);
} }

View File

@@ -21,6 +21,9 @@ struct AddressBookEntry {
// (shown only when that wallet is the active one). Empty is treated as "global" so legacy // (shown only when that wallet is the active one). Empty is treated as "global" so legacy
// entries — written before multi-wallet scoping — keep showing everywhere. // entries — written before multi-wallet scoping — keep showing everywhere.
std::string scope = "global"; std::string scope = "global";
// Contact avatar: "" = default type badge (Z/T), "icon:<name>" = a Material wallet-icon,
// "img:<path>" = a custom image (copied into <config>/contact-avatars/).
std::string avatar;
AddressBookEntry() = default; AddressBookEntry() = default;
AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "", AddressBookEntry(const std::string& l, const std::string& a, const std::string& n = "",

View File

@@ -9,6 +9,7 @@
#include "ui/schema/ui_schema.h" #include "ui/schema/ui_schema.h"
#include "ui/effects/low_spec.h" #include "ui/effects/low_spec.h"
#include "ui/notifications.h" #include "ui/notifications.h"
#include "ui/windows/contacts_tab.h"
#include "ui/theme.h" #include "ui/theme.h"
#include "ui/material/color_theme.h" #include "ui/material/color_theme.h"
#include "ui/material/typography.h" #include "ui/material/typography.h"
@@ -1970,7 +1971,8 @@ int main(int argc, char* argv[])
|| app.isTransactionRefreshInProgress() || app.isTransactionRefreshInProgress()
|| dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation() || dragonx::ui::effects::ThemeEffects::instance().hasActiveAnimation()
|| dragonx::ui::Notifications::instance().hasActive() || dragonx::ui::Notifications::instance().hasActive()
|| dragonx::ui::material::SmoothScrollAnimating(); || dragonx::ui::material::SmoothScrollAnimating()
|| dragonx::ui::ConsumeContactsAvatarAnimation();
// If nothing is happening, allow the next iteration to idle // If nothing is happening, allow the next iteration to idle
needsRedraw = uiActive || animating; needsRedraw = uiActive || animating;
} }

View File

@@ -899,6 +899,16 @@ void RenderSettingsPage(App* app) {
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
ImGui::SameLine(0, Layout::spacingLg());
{
bool anim = app->settings()->getAnimateAvatars();
if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) {
app->settings()->setAnimateAvatars(anim);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars"));
}
// Console output color toggles (own row — no GPU cost, enabled even in low-spec). // Console output color toggles (own row — no GPU cost, enabled even in low-spec).
renderConsoleColorToggles(app); renderConsoleColorToggles(app);
@@ -1154,6 +1164,16 @@ void RenderSettingsPage(App* app) {
} }
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_theme_effects"));
ImGui::SameLine(0, Layout::spacingLg());
{
bool anim = app->settings()->getAnimateAvatars();
if (ImGui::Checkbox(TrId("animate_avatars", "animate_avatars").c_str(), &anim)) {
app->settings()->setAnimateAvatars(anim);
app->settings()->save();
}
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("tt_animate_avatars"));
}
// Console output color toggles (own row — no GPU cost, enabled even in low-spec). // Console output color toggles (own row — no GPU cost, enabled even in low-spec).
renderConsoleColorToggles(app); renderConsoleColorToggles(app);

View File

@@ -12,10 +12,18 @@
#include "../schema/ui_schema.h" #include "../schema/ui_schema.h"
#include "../material/draw_helpers.h" #include "../material/draw_helpers.h"
#include "../material/type.h" #include "../material/type.h"
#include "../material/project_icons.h"
#include "../../util/texture_loader.h"
#include "../../util/platform.h"
#include "image_picker.h"
#include "../layout.h" #include "../layout.h"
#include "imgui.h" #include "imgui.h"
#include <filesystem>
#include <unordered_map>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <cmath>
#include <cstdint>
#include <cstring> #include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -36,6 +44,10 @@ static char s_edit_address[512] = "";
static char s_edit_notes[512] = ""; static char s_edit_notes[512] = "";
static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle static bool s_edit_global = false; // add/edit dialog: "visible in every wallet" toggle
static char s_search[128] = ""; static char s_search[128] = "";
// Avatar being edited: "" = default Z/T badge, "icon:<name>", or "img:<abs path>".
static std::string s_edit_avatar;
static int s_edit_avatar_mode = 0; // 0 = badge, 1 = icon, 2 = image (segmented picker)
static char s_edit_icon_search[64] = ""; // icon-grid filter in the edit dialog
static void copyEditField(char* dest, size_t destSize, const std::string& source) { static void copyEditField(char* dest, size_t destSize, const std::string& source) {
if (destSize == 0) return; if (destSize == 0) return;
@@ -56,19 +68,211 @@ static bool matchesSearch(const data::AddressBookEntry& e, const std::string& ne
|| toLower(e.notes).find(needleLower) != std::string::npos; || toLower(e.notes).find(needleLower) != std::string::npos;
} }
// Lazily-loaded, path-keyed cache of custom avatar image textures. Each entry is a frame SEQUENCE
// (1 frame for stills, N for animated GIF/WebP), box-downscaled by texture_loader to bound VRAM.
// Decoding is budgeted per frame so opening a large library/contact list doesn't stall the UI — callers
// show a placeholder and the image fills in over the next frames.
struct AvatarTex {
std::vector<ImTextureID> frames; // 1 = still, N = animated
std::vector<float> delays; // seconds per frame (parallel to frames)
float totalDur = 0.0f;
int w = 0, h = 0;
};
static std::unordered_map<std::string, AvatarTex> s_avatarTexCache;
static int s_avatarLoadsThisFrame = 0;
static bool s_animateAvatars = true; // mirrors the setting; refreshed each frame in RenderContactsTab
static bool s_avatarAnimatedThisFrame = false; // set when a live animated frame is drawn -> keep redrawing
static constexpr int kAvatarLoadsPerFrame = 2;
static constexpr int kAvatarMaxFrames = 300; // cap frames per animated avatar (bounds VRAM/decode)
static const AvatarTex* getAvatarTexture(const std::string& path) {
auto it = s_avatarTexCache.find(path);
if (it != s_avatarTexCache.end()) return it->second.frames.empty() ? nullptr : &it->second;
if (s_avatarLoadsThisFrame >= kAvatarLoadsPerFrame) return nullptr; // defer to a later frame
s_avatarLoadsThisFrame++;
AvatarTex at;
util::AnimFrames af;
if (util::LoadAnimatedRGBA(path.c_str(), kAvatarMaxFrames, af) && !af.frames.empty()) {
at.w = af.w; at.h = af.h;
for (size_t i = 0; i < af.frames.size(); ++i) {
ImTextureID t = 0;
if (util::CreateRawTexture(af.frames[i].data(), af.w, af.h, false, &t)) {
at.frames.push_back(t);
at.delays.push_back(i < af.delaysSec.size() ? af.delaysSec[i] : 0.0f);
}
}
for (float d : at.delays) at.totalDur += d;
}
auto& slot = (s_avatarTexCache[path] = std::move(at));
return slot.frames.empty() ? nullptr : &slot;
}
// The texture to draw for `t` right now: a still's single frame, or — when the animate-avatars setting
// is on AND the avatar is actually on-screen — the animated frame for the current ImGui clock time (and
// flag that an animation is live, so the render loop keeps producing frames instead of idling). Passing
// onScreen=false (an avatar scrolled out of a list/table viewport) shows frame 0 and does NOT flag, so a
// culled animated avatar can't pin the app awake.
static ImTextureID currentAvatarFrame(const AvatarTex* t, bool onScreen = true) {
if (!t || t->frames.empty()) return 0;
if (onScreen && t->frames.size() > 1 && s_animateAvatars && t->totalDur > 0.0f) {
s_avatarAnimatedThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)t->totalDur);
double acc = 0.0;
for (size_t i = 0; i < t->delays.size() && i < t->frames.size(); ++i) {
acc += t->delays[i];
if (phase < acc) return t->frames[i];
}
}
return t->frames[0];
}
// Drop a cached avatar's textures (all frames) so the next getAvatarTexture reloads from disk.
static void invalidateAvatarTexture(const std::string& path) {
auto it = s_avatarTexCache.find(path);
if (it == s_avatarTexCache.end()) return;
for (ImTextureID t : it->second.frames) if (t) util::DestroyTexture(t);
s_avatarTexCache.erase(it);
}
// Draw a contact's avatar into the circle at `c` (radius `r`): a custom image (circular-cropped), a
// Material icon in a tinted circle, or — the default — the Z/T type badge.
static void drawContactAvatar(ImDrawList* dl, ImVec2 c, float r, const data::AddressBookEntry& e,
bool shielded, ImU32 typeCol, bool light, float dp,
ImFont* letterFont, ImFont* iconFont, bool onScreen = true) {
const std::string& av = e.avatar;
if (av.rfind("img:", 0) == 0) {
const AvatarTex* t = getAvatarTexture(av.substr(4));
ImTextureID tex = currentAvatarFrame(t, onScreen);
if (tex) {
float u0 = 0, v0 = 0, u1 = 1, v1 = 1; // centre-crop to a square so the circle isn't stretched
if (t->w > t->h) { float m = (t->w - t->h) * 0.5f / t->w; u0 = m; u1 = 1 - m; }
else if (t->h > t->w) { float m = (t->h - t->w) * 0.5f / t->h; v0 = m; v1 = 1 - m; }
dl->AddImageRounded(tex, ImVec2(c.x - r, c.y - r), ImVec2(c.x + r, c.y + r),
ImVec2(u0, v0), ImVec2(u1, v1), IM_COL32_WHITE, r);
dl->AddCircle(c, r, material::WithAlpha(material::OnSurface(), 45), 0, 1.0f * dp);
return;
}
// fall through to the type badge if the image failed to load
} else if (av.rfind("icon:", 0) == 0) {
const std::string iconName = av.substr(5);
const bool known = (iconName == material::project_icons::kPickaxeName) ||
(material::project_icons::glyphForName(iconName) != nullptr);
if (known) {
dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60));
dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp);
material::project_icons::drawByName(dl, iconName, c, typeCol, iconFont, iconFont->LegacySize);
return;
}
// fall through to the type badge if the icon name is unknown
}
// Default: Z/T type badge.
dl->AddCircleFilled(c, r, material::WithAlpha(typeCol, light ? 45 : 60));
dl->AddCircle(c, r, material::WithAlpha(typeCol, 190), 0, 1.4f * dp);
const char* letter = shielded ? "Z" : "T";
const ImVec2 ls = letterFont->CalcTextSizeA(letterFont->LegacySize, FLT_MAX, 0, letter);
dl->AddText(letterFont, letterFont->LegacySize, ImVec2(c.x - ls.x * 0.5f, c.y - ls.y * 0.5f), typeCol, letter);
}
static bool isShieldedAddr(const std::string& a) { static bool isShieldedAddr(const std::string& a) {
return !a.empty() && a[0] == 'z'; return !a.empty() && a[0] == 'z';
} }
// Accent colour for a contact's address type (Z = shielded/green, T = transparent/amber), tuned per
// theme. File-scope so both the list rows and the edit-dialog preview share one source of truth.
static ImU32 contactTypeColor(bool shielded, bool light) {
ImVec4 c = shielded ? (light ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f))
: (light ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f));
return ImGui::ColorConvertFloat4ToU32(c);
}
// Copy a chosen avatar image into <config>/contact-avatars/, returning the destination absolute path
// (empty on failure). Named by the source stem + an 8-hex FNV hash of the full source path, so
// re-picking the same file is idempotent and two different files never clobber each other.
static std::string copyAvatarImage(const std::string& srcPath) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path src(srcPath);
if (!fs::is_regular_file(src, ec)) return "";
fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
fs::create_directories(dir, ec);
uint64_t h = 1469598103934665603ull; // FNV-1a
for (unsigned char c : srcPath) { h ^= c; h *= 1099511628211ull; }
char suffix[9];
snprintf(suffix, sizeof(suffix), "%08x", (unsigned)(h & 0xffffffffu));
std::string stem = src.stem().string();
if (stem.size() > 40) stem.resize(40);
fs::path dst = dir / (stem + "-" + suffix + src.extension().string());
fs::copy_file(src, dst, fs::copy_options::overwrite_existing, ec);
return ec ? std::string() : dst.string();
}
// Avatar image LIBRARY: the images the user has added live in <config>/contact-avatars/ and persist as
// a reusable, portable set (they travel with the wallet data dir). The Image avatar tab is a grid over
// this library; images are only removed when the user explicitly deletes them.
static std::vector<std::string> s_avatarLibrary; // absolute paths, sorted
static bool s_avatarLibraryDirty = true;
static bool isImageFileName(const std::string& name) {
std::string lo = name;
for (char& c : lo) c = static_cast<char>(std::tolower((unsigned char)c));
for (const char* e : { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" }) {
const std::string ext(e);
if (lo.size() > ext.size() && lo.compare(lo.size() - ext.size(), ext.size(), ext) == 0) return true;
}
return false;
}
static void rescanAvatarLibrary() {
s_avatarLibrary.clear();
s_avatarLibraryDirty = false;
namespace fs = std::filesystem;
std::error_code ec;
fs::path dir = fs::path(util::Platform::getConfigDir()) / "contact-avatars";
if (!fs::is_directory(dir, ec)) return;
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
std::error_code fec;
if (it->is_regular_file(fec) && isImageFileName(it->path().filename().string()))
s_avatarLibrary.push_back(it->path().string());
}
std::sort(s_avatarLibrary.begin(), s_avatarLibrary.end());
}
// Explicitly delete a library image (user pressed its delete icon). Only touches files directly inside
// the managed dir; drops the cached texture, clears the current selection if it pointed here, and marks
// the library for rescan. Contacts still referencing a deleted image fall back to their Z/T badge.
static void deleteAvatarLibraryFile(const std::string& path) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path managed = fs::weakly_canonical(fs::path(util::Platform::getConfigDir()) / "contact-avatars", ec);
fs::path target = fs::weakly_canonical(fs::path(path), ec);
if (managed.empty() || target.parent_path() != managed) return; // safety: never escape the dir
fs::remove(target, ec);
invalidateAvatarTexture(path);
if (s_edit_avatar == "img:" + path) s_edit_avatar.clear();
s_avatarLibraryDirty = true;
}
// UI-loop accessor: true if an animated avatar frame was drawn since the last call (clear-on-read), so
// the main render loop keeps producing frames while an animation plays and idles once it stops / the
// contacts view is hidden. Declared in contacts_tab.h.
bool ConsumeContactsAvatarAnimation() {
// Also consume the image picker's hover-preview animation flag (the picker renders within this tab).
bool picker = ImagePicker::consumeAnimationActive();
bool v = s_avatarAnimatedThisFrame || picker;
s_avatarAnimatedThisFrame = false;
return v;
}
void RenderContactsTab(App* app) void RenderContactsTab(App* app)
{ {
s_avatarLoadsThisFrame = 0; // reset the per-frame avatar-decode budget (see getAvatarTexture)
s_animateAvatars = !app->settings() || app->settings()->getAnimateAvatars();
auto& S = schema::UI(); auto& S = schema::UI();
// Reuse the existing address-book schema/column config for the table + add/edit form. // Reuse the existing address-book schema/column config for the table + add/edit form.
auto addrTable = S.table("dialogs.address-book", "address-table"); auto addrTable = S.table("dialogs.address-book", "address-table");
auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label"); auto addrFrontLbl = S.label("dialogs.address-book", "address-front-label");
auto addrBackLbl = S.label("dialogs.address-book", "address-back-label"); auto addrBackLbl = S.label("dialogs.address-book", "address-back-label");
auto addrInput = S.input("dialogs.address-book", "address-input");
auto notesInput = S.input("dialogs.address-book", "notes-input");
auto actionBtn = S.button("dialogs.address-book", "action-button"); auto actionBtn = S.button("dialogs.address-book", "action-button");
auto& book = app->addressBook(); auto& book = app->addressBook();
@@ -82,6 +286,10 @@ void RenderContactsTab(App* app)
s_edit_address[0] = '\0'; s_edit_address[0] = '\0';
s_edit_notes[0] = '\0'; s_edit_notes[0] = '\0';
s_edit_global = false; // new contacts default to the current wallet s_edit_global = false; // new contacts default to the current wallet
s_edit_avatar.clear();
s_edit_avatar_mode = 0;
s_edit_icon_search[0] = '\0';
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
}; };
auto loadEditFields = [](const data::AddressBookEntry& entry) { auto loadEditFields = [](const data::AddressBookEntry& entry) {
@@ -89,14 +297,23 @@ void RenderContactsTab(App* app)
copyEditField(s_edit_address, sizeof(s_edit_address), entry.address); copyEditField(s_edit_address, sizeof(s_edit_address), entry.address);
copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes); copyEditField(s_edit_notes, sizeof(s_edit_notes), entry.notes);
s_edit_global = entry.isGlobal(); s_edit_global = entry.isGlobal();
s_edit_avatar = entry.avatar;
s_edit_avatar_mode = (entry.avatar.rfind("icon:", 0) == 0) ? 1
: (entry.avatar.rfind("img:", 0) == 0) ? 2 : 0;
s_edit_icon_search[0] = '\0';
s_avatarLibraryDirty = true; // rescan the image library on open (files may have changed)
}; };
bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size()); bool has_selection = s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size());
// Shared delete/copy actions (used by both the toolbar buttons and keyboard shortcuts). // Shared delete/copy/edit actions (used by the toolbar, keyboard shortcuts, and per-row icons).
// Each validates s_selected_index FRESHLY rather than the frame-top has_selection bool: a per-row
// icon sets the selection then invokes the action in the SAME frame, so the cached bool is stale.
auto selValid = [&]() { return s_selected_index >= 0 && s_selected_index < static_cast<int>(book.size()); };
auto doDelete = [&]() { auto doDelete = [&]() {
if (!has_selection) return; if (!selValid()) return;
if (s_confirm_delete_idx == s_selected_index) { if (s_confirm_delete_idx == s_selected_index) {
// The contact's avatar image (if any) stays in the library for reuse — not deleted here.
book.removeEntry(s_selected_index); book.removeEntry(s_selected_index);
s_selected_index = -1; s_selected_index = -1;
s_confirm_delete_idx = -1; s_confirm_delete_idx = -1;
@@ -107,52 +324,143 @@ void RenderContactsTab(App* app)
} }
}; };
auto doCopy = [&]() { auto doCopy = [&]() {
if (!has_selection) return; if (!selValid()) return;
ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str()); ImGui::SetClipboardText(book.entries()[s_selected_index].address.c_str());
Notifications::instance().info(TR("address_copied")); Notifications::instance().info(TR("address_copied"));
}; };
auto openEdit = [&]() { auto openEdit = [&]() {
if (!has_selection) return; if (!selValid()) return;
loadEditFields(book.entries()[s_selected_index]); loadEditFields(book.entries()[s_selected_index]);
s_show_edit_dialog = true; s_show_edit_dialog = true;
s_focus_edit_field = true; s_focus_edit_field = true;
}; };
// Add/edit form — a modal popup layered over the tab. // Add/edit form — a modal popup layered over the tab, with a live contact preview and an avatar
// picker (default Z/T badge, a Material icon, or a custom image via the in-app image picker).
auto renderEntryDialog = [&]() { auto renderEntryDialog = [&]() {
// The image picker takes over the modal surface while it's up (the overlay framework doesn't
// nest) — render it instead of the edit dialog, exactly as WalletsDialog does for FolderPicker.
if (ImagePicker::isOpen()) { ImagePicker::render(); return; }
bool isEdit = s_show_edit_dialog; bool isEdit = s_show_edit_dialog;
bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog; bool* open = isEdit ? &s_show_edit_dialog : &s_show_add_dialog;
if (!*open) return; if (!*open) return;
const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add"); const char* title = isEdit ? TR("address_book_edit") : TR("address_book_add");
const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd"; const char* id = isEdit ? "AddressBookEdit" : "AddressBookAdd";
float dialogW = std::max(Layout::kDialogMinWidth(), Layout::kDialogDefaultWidth()); const float dp = Layout::dpiScale();
float formW = addrInput.width > 0 ? addrInput.width : Layout::kDialogFormWidth(); const bool light = material::IsLightTheme();
float actionW = actionBtn.width > 0 ? actionBtn.width : Layout::kDialogActionWidth(); ImFont* btnFont = S.resolveFont(actionBtn.font);
float actionGap = actionBtn.gap > 0 ? actionBtn.gap : Layout::kDialogActionGap();
float notesH = notesInput.height > 0 ? notesInput.height : 60.0f;
material::OverlayDialogSpec ov; material::OverlayDialogSpec ov;
ov.title = title; ov.p_open = open; ov.title = title; ov.p_open = open;
ov.style = material::OverlayStyle::BlurFloat; ov.style = material::OverlayStyle::BlurFloat;
ov.cardWidth = dialogW; ov.idSuffix = id; ov.cardWidth = 880.0f; ov.idSuffix = id; // two-column body: form (left) + avatar picker (right)
ov.cardBottomViewportRatio = Layout::kDialogCompactBottomRatio(); // Fixed, tall card — fill the vertical space so the icon grid shows many rows and the notes
// field has room, rather than a squat auto-height box with dead space below.
const float vpH = ImGui::GetMainViewport()->Size.y;
ov.cardHeight = std::min(vpH * 0.84f, 820.0f * dp) / dp; // logical; framework re-applies dp
if (material::BeginOverlayDialog(ov)) { if (material::BeginOverlayDialog(ov)) {
// End-truncate a string to fit maxW (whole UTF-8 code points), appending an ellipsis.
auto fitText = [&](std::string s, ImFont* f, float maxW) {
bool t = false;
while (s.size() > 1 && f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x > maxW) {
while (s.size() > 1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (s.size() > 1) s.pop_back();
t = true;
}
if (t) s += "\xE2\x80\xA6";
return s;
};
// Middle-truncate to fit maxW, keeping both ends — an address reads best head + tail
// (addresses are ASCII base58/bech32, so byte-wise trimming is safe).
auto fitMiddle = [&](std::string s, ImFont* f, float maxW) {
auto w = [&](const std::string& t){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, t.c_str()).x; };
if (w(s) <= maxW || s.size() <= 8) return s;
const std::string ell = "\xE2\x80\xA6";
size_t head = s.size() / 2, tail = s.size() - head;
while (head + tail > 6) {
std::string cand = s.substr(0, head) + ell + s.substr(s.size() - tail);
if (w(cand) <= maxW) return cand;
if (head >= tail) --head; else --tail;
}
return s.substr(0, 3) + ell + s.substr(s.size() - 3);
};
// ---- Live preview: the contact card as it will appear in the list --------------------
{
ImDrawList* pdl = ImGui::GetWindowDrawList();
ImFont* nameF = material::Type().subtitle1();
ImFont* addrF = material::Type().caption();
ImFont* letF = material::Type().subtitle1();
ImFont* icoF = material::Type().iconMed();
const float pad = Layout::spacingMd();
const float avR = 26.0f * dp;
const float blockH = nameF->LegacySize + Layout::spacingXs() + addrF->LegacySize;
const float ph = pad * 2.0f + std::max(avR * 2.0f, blockH);
const float pw = ImGui::GetContentRegionAvail().x;
ImVec2 pMin = ImGui::GetCursorScreenPos();
ImVec2 pMax(pMin.x + pw, pMin.y + ph);
material::GlassPanelSpec g; g.rounding = 10.0f * dp; g.fillAlpha = 22; g.borderAlpha = 40;
material::DrawGlassPanel(pdl, pMin, pMax, g);
data::AddressBookEntry pv;
pv.label = s_edit_label; pv.address = s_edit_address; pv.avatar = s_edit_avatar;
const bool sh = isShieldedAddr(s_edit_address);
const ImU32 tu = contactTypeColor(sh, light);
ImVec2 avC(pMin.x + pad + avR, pMin.y + ph * 0.5f);
drawContactAvatar(pdl, avC, avR, pv, sh, tu, light, dp, letF, icoF);
float tx = avC.x + avR + Layout::spacingMd();
float textRight = pMax.x - pad;
float ty = pMin.y + (ph - blockH) * 0.5f;
const bool hasName = s_edit_label[0] != '\0';
std::string nm = fitText(hasName ? std::string(s_edit_label) : std::string(TR("contact_preview_name")),
nameF, textRight - tx);
pdl->AddText(nameF, nameF->LegacySize, ImVec2(tx, ty),
hasName ? material::OnSurface() : material::OnSurfaceDisabled(), nm.c_str());
const bool hasAddr = s_edit_address[0] != '\0';
std::string ad = hasAddr ? fitMiddle(std::string(s_edit_address), addrF, textRight - tx)
: fitText(std::string(TR("contact_preview_addr")), addrF, textRight - tx);
pdl->AddText(addrF, addrF->LegacySize, ImVec2(tx, ty + nameF->LegacySize + Layout::spacingXs()),
hasAddr ? material::OnSurfaceMedium() : material::OnSurfaceDisabled(), ad.c_str());
ImGui::Dummy(ImVec2(pw, ph));
}
ImGui::Dummy(ImVec2(0, Layout::spacingMd()));
// ================= Two-column body: form (left) | avatar picker (right) =================
// Body flexes to fill the tall card, leaving just the footer button row — so the icon grid
// and notes field grow into the vertical space instead of leaving it empty.
const float footerReserve = 44.0f * dp + Layout::spacingMd();
// Never floor above what's available — the footer (Save/Cancel) must stay inside the fixed,
// non-scrolling card even on short windows. A tiny floor only guards a degenerate size.
const float bodyH = std::max(48.0f * dp, ImGui::GetContentRegionAvail().y - footerReserve);
const float colGap = Layout::spacingLg();
const float bodyAvail = ImGui::GetContentRegionAvail().x;
const float colW = (bodyAvail - colGap) * 0.5f;
// ---- LEFT: label / address / notes / global ----------------------------------------
ImGui::BeginChild("##contactFormCol", ImVec2(colW, bodyH), false,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// Focus the first field the frame the dialog opens so it's keyboard-ready. // Focus the first field the frame the dialog opens so it's keyboard-ready.
if (s_focus_edit_field) { if (s_focus_edit_field) {
ImGui::SetKeyboardFocusHere(); ImGui::SetKeyboardFocusHere();
s_focus_edit_field = false; s_focus_edit_field = false;
} }
material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel", material::LabeledInput(TR("label"), isEdit ? "##EditLabel" : "##AddLabel",
s_edit_label, sizeof(s_edit_label), formW); s_edit_label, sizeof(s_edit_label), -1.0f);
ImGui::Spacing(); ImGui::Spacing();
// Address (full width) with a centered Paste button beneath it.
material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress", material::LabeledInput(TR("address_label"), isEdit ? "##EditAddress" : "##AddAddress",
s_edit_address, sizeof(s_edit_address), formW); s_edit_address, sizeof(s_edit_address), -1.0f);
if (!isEdit) { {
ImGui::SameLine(); float pasteW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("paste")).x
if (material::TactileButton(TR("paste"), ImVec2(0,0), S.resolveFont(actionBtn.font))) { + ImGui::GetStyle().FramePadding.x * 2.0f + 24.0f * dp;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
std::max(0.0f, (ImGui::GetContentRegionAvail().x - pasteW) * 0.5f));
if (material::TactileButton(TR("paste"), ImVec2(pasteW, 0), btnFont)) {
const char* clipboard = ImGui::GetClipboardText(); const char* clipboard = ImGui::GetClipboardText();
if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard); if (clipboard) copyEditField(s_edit_address, sizeof(s_edit_address), clipboard);
} }
@@ -160,21 +468,293 @@ void RenderContactsTab(App* app)
ImGui::Spacing(); ImGui::Spacing();
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes", // Notes grows to fill the column above the Global checkbox, which is pinned to the bottom.
s_edit_notes, sizeof(s_edit_notes), ImVec2(formW, notesH)); // Reserve the checkbox frame + the item spacing before it + a clear bottom margin so its
// descenders ("y"/"g") never clip against the child's edge.
{
float globalReserve = ImGui::GetFrameHeight() + Layout::spacingLg() * 2.0f;
float labelLine = ImGui::GetTextLineHeightWithSpacing();
float notesFill = std::max(60.0f * dp,
ImGui::GetContentRegionAvail().y - globalReserve - labelLine);
material::LabeledInputMultiline(TR("notes_optional"), isEdit ? "##EditNotes" : "##AddNotes",
s_edit_notes, sizeof(s_edit_notes), ImVec2(-1.0f, notesFill));
}
ImGui::Spacing(); ImGui::Spacing();
ImGui::Checkbox(TR("contact_global"), &s_edit_global); ImGui::Checkbox(TR("contact_global"), &s_edit_global);
if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt")); if (ImGui::IsItemHovered()) material::Tooltip("%s", TR("contact_global_tt"));
ImGui::EndChild(); // ##contactFormCol
ImGui::SameLine(0, colGap);
// ---- RIGHT: avatar picker (Badge / Icon / Image), fills the column height -----------
ImGui::BeginChild("##contactAvatarCol", ImVec2(colW, bodyH), false,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceMedium(), TR("contact_avatar"));
{
// Custom icon + label segmented control (the shared helper is single-font; avatar modes
// read clearer with a glyph). Track + active pill mirror material::SegmentedControl.
const char* segLbl[3] = { TR("contact_avatar_badge"), TR("contact_avatar_icon"),
TR("contact_avatar_image") };
const char* segIco[3] = { ICON_MD_BADGE, ICON_MD_PALETTE, ICON_MD_IMAGE };
ImFont* segIcoF = material::Type().iconSmall();
float segH = 34.0f * dp;
float segW = ImGui::GetContentRegionAvail().x;
float cellW = segW / 3.0f;
ImVec2 sMin = ImGui::GetCursorScreenPos();
ImDrawList* sdl = ImGui::GetWindowDrawList();
sdl->AddRectFilled(sMin, ImVec2(sMin.x + segW, sMin.y + segH),
material::WithAlpha(material::OnSurface(), 20), segH * 0.5f);
int clk = -1;
for (int i = 0; i < 3; i++) {
ImVec2 cMin(sMin.x + i * cellW, sMin.y), cMax(cMin.x + cellW, sMin.y + segH);
bool active = (s_edit_avatar_mode == i);
bool hov = ImGui::IsMouseHoveringRect(cMin, cMax);
if (active)
sdl->AddRectFilled(ImVec2(cMin.x + 2.0f * dp, cMin.y + 2.0f * dp),
ImVec2(cMax.x - 2.0f * dp, cMax.y - 2.0f * dp),
material::WithAlpha(material::Primary(), 210), (segH - 4.0f * dp) * 0.5f);
ImU32 fg = active ? IM_COL32(255, 255, 255, 255) : (hov ? material::OnSurface() : material::OnSurfaceMedium());
float igW = segIcoF->CalcTextSizeA(segIcoF->LegacySize, FLT_MAX, 0, segIco[i]).x;
float lbW = btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, segLbl[i]).x;
float gapI = 5.0f * dp;
float startX = cMin.x + (cellW - (igW + gapI + lbW)) * 0.5f;
sdl->AddText(segIcoF, segIcoF->LegacySize,
ImVec2(startX, cMin.y + (segH - segIcoF->LegacySize) * 0.5f), fg, segIco[i]);
sdl->AddText(btnFont, btnFont->LegacySize,
ImVec2(startX + igW + gapI, cMin.y + (segH - btnFont->LegacySize) * 0.5f), fg, segLbl[i]);
ImGui::PushID(i);
ImGui::SetCursorScreenPos(cMin);
if (ImGui::InvisibleButton("##avseg", ImVec2(cellW, segH))) clk = i;
if (ImGui::IsItemHovered()) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
ImGui::PopID();
}
if (clk >= 0 && clk != s_edit_avatar_mode) {
s_edit_avatar_mode = clk;
// Switching mode resets the value unless it already matches that mode's prefix.
if (clk == 0) s_edit_avatar.clear();
else if (clk == 1 && s_edit_avatar.rfind("icon:", 0) != 0) s_edit_avatar.clear();
else if (clk == 2 && s_edit_avatar.rfind("img:", 0) != 0) s_edit_avatar.clear();
}
ImGui::SetCursorScreenPos(ImVec2(sMin.x, sMin.y + segH));
ImGui::Dummy(ImVec2(segW, 0));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// The mode content fills the rest of the avatar column (fixed height → no jump on switch).
if (s_edit_avatar_mode == 0) {
// Badge: show the two example chips (Z shielded / T transparent) — the badge is picked
// automatically from the address type — plus a short caption. Centered in the space below.
ImDrawList* bdl = ImGui::GetWindowDrawList();
ImFont* letF = material::Type().subtitle1();
ImFont* capF = material::Type().caption();
ImVec2 a = ImGui::GetContentRegionAvail();
ImVec2 origin = ImGui::GetCursorScreenPos();
const float chipR = 24.0f * dp;
const float chipGap = 40.0f * dp;
const float labelY = chipR * 2.0f + Layout::spacingXs() + capF->LegacySize;
const char* msg = TR("contact_avatar_badge_hint");
float wrapW = a.x - 8.0f * dp;
ImVec2 ms = capF->CalcTextSizeA(capF->LegacySize, wrapW, 0, msg);
const float blockH = labelY + Layout::spacingMd() + ms.y;
float cy = origin.y + std::max(0.0f, (a.y - blockH) * 0.42f);
float cx = origin.x + a.x * 0.5f;
struct Chip { bool sh; const char* lbl; };
Chip chips[2] = { { true, TR("contact_avatar_shielded") }, { false, TR("contact_avatar_transparent") } };
for (int i = 0; i < 2; i++) {
float ccx = cx + (i == 0 ? -(chipR + chipGap * 0.5f) : (chipR + chipGap * 0.5f));
ImVec2 cc(ccx, cy + chipR);
ImU32 col = contactTypeColor(chips[i].sh, light);
bdl->AddCircleFilled(cc, chipR, material::WithAlpha(col, light ? 45 : 60));
bdl->AddCircle(cc, chipR, material::WithAlpha(col, 190), 0, 1.6f * dp);
const char* L = chips[i].sh ? "Z" : "T";
ImVec2 ls = letF->CalcTextSizeA(letF->LegacySize, FLT_MAX, 0, L);
bdl->AddText(letF, letF->LegacySize, ImVec2(cc.x - ls.x * 0.5f, cc.y - ls.y * 0.5f), col, L);
ImVec2 lb = capF->CalcTextSizeA(capF->LegacySize, FLT_MAX, 0, chips[i].lbl);
bdl->AddText(capF, capF->LegacySize,
ImVec2(cc.x - lb.x * 0.5f, cy + chipR * 2.0f + Layout::spacingXs()),
material::OnSurfaceMedium(), chips[i].lbl);
}
// Caption below the chips (wrapped, centered).
ImGui::SetCursorScreenPos(ImVec2(origin.x + std::max(0.0f, (a.x - std::min(ms.x, wrapW)) * 0.5f),
cy + labelY + Layout::spacingMd()));
ImGui::PushTextWrapPos(ImGui::GetCursorPosX() + std::min(ms.x, wrapW));
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), msg);
ImGui::PopTextWrapPos();
} else if (s_edit_avatar_mode == 1) {
// Icon grid: search on top, then a scrollable name-filtered grid.
ImGui::SetNextItemWidth(-1);
ImGui::InputTextWithHint("##avIconSearch", TR("portfolio_search_icons"),
s_edit_icon_search, sizeof(s_edit_icon_search));
std::string needle = toLower(s_edit_icon_search);
std::vector<int> vis;
for (int i = 0; i < material::project_icons::walletIconCount(); ++i) {
const char* nm = material::project_icons::walletIconName(i);
if (needle.empty() || toLower(nm).find(needle) != std::string::npos) vis.push_back(i);
}
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
ImGui::BeginChild("##avIconGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
ImDrawList* gdl = ImGui::GetWindowDrawList();
ImFont* gIcoF = material::Type().iconXL();
if (vis.empty())
material::Type().textColored(material::TypeStyle::Caption, material::OnSurfaceDisabled(), TR("no_icons_found"));
const float cellGap = 6.0f * dp;
const float availW = ImGui::GetContentRegionAvail().x;
const int cols = std::max(4, (int)((availW + cellGap) / (54.0f * dp + cellGap)));
const float cell = std::max(28.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
int col = 0;
for (int vi = 0; vi < (int)vis.size(); ++vi) {
int i = vis[vi];
const char* nm = material::project_icons::walletIconName(i);
if (col != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
bool sel = (s_edit_avatar == std::string("icon:") + nm);
if (sel) {
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 40), 6.0f * dp);
gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 120), 6.0f * dp, 0, 1.5f * dp);
} else if (hov) {
gdl->AddRectFilled(mn, mx, IM_COL32(255, 255, 255, 20), 6.0f * dp);
}
ImU32 icol = sel ? material::Primary() : (hov ? material::OnSurface() : material::OnSurfaceMedium());
material::project_icons::drawByName(gdl, nm, cc, icol, gIcoF, cell * 0.5f);
ImGui::PushID(i);
ImGui::InvisibleButton("##avic", ImVec2(cell, cell));
if (ImGui::IsItemClicked()) s_edit_avatar = std::string("icon:") + nm;
if (hov) material::Tooltip("%s", nm);
ImGui::PopID();
col = (col + 1) % cols;
}
ImGui::EndChild();
ImGui::PopStyleColor();
} else {
// Image: a grid over the avatar image LIBRARY. The first cell adds a new image (opens the
// picker + copies it into the library); each library image is selectable and has a delete
// badge to remove it. Images persist in <config>/contact-avatars/ so they're reusable and
// travel with the wallet data — deleting a contact never removes them.
if (s_avatarLibraryDirty) rescanAvatarLibrary();
ImGui::PushStyleColor(ImGuiCol_ChildBg, IM_COL32(0, 0, 0, 0));
ImGui::BeginChild("##avImgGrid", ImVec2(-1, ImGui::GetContentRegionAvail().y), false);
ImDrawList* gdl = ImGui::GetWindowDrawList();
const float cellGap = 6.0f * dp;
const float availW = ImGui::GetContentRegionAvail().x;
const int cols = std::max(3, (int)((availW + cellGap) / (74.0f * dp + cellGap)));
const float cell = std::max(48.0f * dp, (availW - cellGap * (cols - 1)) / (float)cols);
const int total = 1 + (int)s_avatarLibrary.size();
int pendingDelete = -1;
int col = 0;
for (int n = 0; n < total; ++n) {
if (col != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
ImVec2 cc(mn.x + cell * 0.5f, mn.y + cell * 0.5f);
if (n == 0) {
// "Add image" cell — always first.
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 30 : 14), 6.0f * dp);
gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), hov ? 110 : 55), 6.0f * dp, 0, 1.5f * dp);
ImFont* gf = material::Type().iconXL();
float gsz = cell * 0.4f;
ImVec2 gs = gf->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_ADD_PHOTO_ALTERNATE);
gdl->AddText(gf, gsz, ImVec2(cc.x - gs.x * 0.5f, cc.y - gs.y * 0.5f),
hov ? material::Primary() : material::OnSurfaceMedium(), ICON_MD_ADD_PHOTO_ALTERNATE);
ImGui::PushID(0);
if (ImGui::InvisibleButton("##avadd", ImVec2(cell, cell))) {
ImagePicker::open("", [](const std::string& src) {
// Verify the source decodes before committing (guards corrupt/unsupported files).
int iw = 0, ih = 0;
unsigned char* px = util::LoadRawPixelsFromFile(src.c_str(), &iw, &ih);
if (!px) { Notifications::instance().error(TR("contact_avatar_bad_image")); return; }
util::FreeRawPixels(px);
std::string dst = copyAvatarImage(src);
if (!dst.empty()) {
invalidateAvatarTexture(dst);
s_edit_avatar = "img:" + dst; s_edit_avatar_mode = 2;
s_avatarLibraryDirty = true; // surface the new image in the grid
} else Notifications::instance().error(TR("contact_avatar_copy_failed"));
});
}
if (ImGui::IsItemHovered()) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", TR("contact_avatar_choose")); }
ImGui::PopID();
} else {
const std::string& path = s_avatarLibrary[n - 1];
const bool sel = (s_edit_avatar == "img:" + path);
bool hov = ImGui::IsMouseHoveringRect(mn, mx);
gdl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 18), 6.0f * dp);
const AvatarTex* t = ImGui::IsRectVisible(mn, mx) ? getAvatarTexture(path) : nullptr;
ImTextureID ttex = currentAvatarFrame(t);
if (ttex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (t->w > t->h) { float m=(t->w-t->h)*0.5f/t->w; u0=m; u1=1-m; }
else if (t->h > t->w) { float m=(t->h-t->w)*0.5f/t->h; v0=m; v1=1-m; }
float ins = 2.0f * dp;
gdl->AddImageRounded(ttex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImFont* gf = material::Type().iconLarge();
ImVec2 gs = gf->CalcTextSizeA(cell * 0.34f, FLT_MAX, 0, ICON_MD_IMAGE);
gdl->AddText(gf, cell * 0.34f, ImVec2(cc.x - gs.x*0.5f, cc.y - gs.y*0.5f),
material::OnSurfaceDisabled(), ICON_MD_IMAGE);
}
if (sel) gdl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 230), 6.0f * dp, 0, 2.5f * dp);
else if (hov) gdl->AddRect(mn, mx, material::WithAlpha(material::OnSurface(), 100), 6.0f * dp, 0, 1.5f * dp);
// The delete badge is hit-tested MANUALLY (not an ImGui item) so it never moves the
// layout cursor — an InvisibleButton here would leave CursorPosPrevLine at the badge
// corner and jitter the rest of the row's cells via the next SameLine.
float dr = std::max(7.0f * dp, cell * 0.15f);
ImVec2 dcc(mx.x - dr - 3.0f * dp, mn.y + dr + 3.0f * dp);
bool dhov = ImGui::IsMouseHoveringRect(ImVec2(dcc.x-dr, dcc.y-dr), ImVec2(dcc.x+dr, dcc.y+dr));
ImGui::PushID(n);
bool thumbClicked = ImGui::InvisibleButton("##avthumb", ImVec2(cell, cell));
bool thumbHov = ImGui::IsItemHovered();
ImGui::PopID();
if (thumbHov || sel || hov) { // draw the delete badge
gdl->AddCircleFilled(dcc, dr, dhov ? material::ReadableError() : IM_COL32(0, 0, 0, 175));
ImFont* xf = material::Type().iconSmall();
float xsz = dr * 1.35f;
ImVec2 xs = xf->CalcTextSizeA(xsz, FLT_MAX, 0, ICON_MD_CLOSE);
gdl->AddText(xf, xsz, ImVec2(dcc.x - xs.x*0.5f, dcc.y - xs.y*0.5f), IM_COL32(255,255,255,235), ICON_MD_CLOSE);
}
if (dhov) { // badge takes priority over selecting the thumbnail
ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
material::Tooltip("%s", TR("delete"));
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) pendingDelete = n - 1;
} else {
if (thumbHov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (thumbClicked) s_edit_avatar = "img:" + path;
}
}
col = (col + 1) % cols;
}
ImGui::EndChild();
ImGui::PopStyleColor();
// Deferred: mutate the library only after the grid loop is done.
if (pendingDelete >= 0 && pendingDelete < (int)s_avatarLibrary.size())
deleteAvatarLibraryFile(s_avatarLibrary[pendingDelete]);
}
ImGui::EndChild(); // ##contactAvatarCol
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0; bool canSubmit = std::strlen(s_edit_label) > 0 && std::strlen(s_edit_address) > 0;
float actionW = std::max(110.0f * dp,
btnFont->CalcTextSizeA(btnFont->LegacySize, FLT_MAX, 0, TR("save")).x
+ ImGui::GetStyle().FramePadding.x * 2.0f + 28.0f * dp);
float actionGap = Layout::spacingSm();
float totalActionsW = actionW * 2.0f + actionGap; float totalActionsW = actionW * 2.0f + actionGap;
material::BeginOverlayDialogFooter(totalActionsW); material::BeginOverlayDialogFooter(totalActionsW, /*drawSeparator=*/false);
if (!canSubmit) ImGui::BeginDisabled(); if (!canSubmit) ImGui::BeginDisabled();
// Accent the primary action (Save/Add) so it reads above Cancel.
ImGui::PushStyleColor(ImGuiCol_Button, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 205)));
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImGui::ColorConvertU32ToFloat4(material::Primary()));
ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImGui::ColorConvertU32ToFloat4(material::WithAlpha(material::Primary(), 235)));
const char* primaryLabel = isEdit ? TR("save") : TR("add"); const char* primaryLabel = isEdit ? TR("save") : TR("add");
if (material::TactileButton(primaryLabel, ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { bool doSubmit = material::TactileButton(primaryLabel, ImVec2(actionW, 0), btnFont);
ImGui::PopStyleColor(3);
if (doSubmit) {
// Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is. // Trim the label/address (a pasted address often carries a trailing newline); keep notes as-is.
auto trimAB = [](std::string s) { auto trimAB = [](std::string s) {
while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin()); while (!s.empty() && (s.front()==' '||s.front()=='\t'||s.front()=='\n'||s.front()=='\r')) s.erase(s.begin());
@@ -182,6 +762,7 @@ void RenderContactsTab(App* app)
return s; return s;
}; };
data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes); data::AddressBookEntry entry(trimAB(s_edit_label), trimAB(s_edit_address), s_edit_notes);
entry.avatar = s_edit_avatar;
// Global if the user asked, or as a safe fallback when we don't yet know the wallet // Global if the user asked, or as a safe fallback when we don't yet know the wallet
// (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet. // (pre-connect) — better a visible-everywhere contact than one orphaned to no wallet.
entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash; entry.scope = (s_edit_global || activeHash.empty()) ? std::string("global") : activeHash;
@@ -205,7 +786,7 @@ void RenderContactsTab(App* app)
if (!canSubmit) ImGui::EndDisabled(); if (!canSubmit) ImGui::EndDisabled();
ImGui::SameLine(0, actionGap); ImGui::SameLine(0, actionGap);
if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), S.resolveFont(actionBtn.font))) { if (material::TactileButton(TR("cancel"), ImVec2(actionW, 0), btnFont)) {
*open = false; *open = false;
} }
@@ -326,11 +907,7 @@ void RenderContactsTab(App* app)
if (listH < 120.0f * dp) listH = 120.0f * dp; if (listH < 120.0f * dp) listH = 120.0f * dp;
const bool lightTheme = material::IsLightTheme(); const bool lightTheme = material::IsLightTheme();
auto typeColor = [&](bool shielded) -> ImU32 { auto typeColor = [&](bool shielded) -> ImU32 { return contactTypeColor(shielded, lightTheme); };
ImVec4 c = shielded ? (lightTheme ? ImVec4(0.10f,0.55f,0.38f,1.0f) : ImVec4(0.35f,0.80f,0.60f,1.0f))
: (lightTheme ? ImVec4(0.72f,0.48f,0.05f,1.0f) : ImVec4(0.95f,0.72f,0.30f,1.0f));
return ImGui::ColorConvertFloat4ToU32(c);
};
// Centered Material empty state (no contacts yet / no search match). // Centered Material empty state (no contacts yet / no search match).
auto emptyState = [&](const char* iconGlyph, const char* msgKey) { auto emptyState = [&](const char* iconGlyph, const char* msgKey) {
const char* msg = TR(msgKey); const char* msg = TR(msgKey);
@@ -347,6 +924,10 @@ void RenderContactsTab(App* app)
material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg); material::Type().textColored(material::TypeStyle::Body2, material::OnSurfaceMedium(), msg);
}; };
// Right-click a row (any view) -> select it and open the shared context menu (rendered after the
// list). A left-click on empty list space clears the selection (handled in the Cards/List child).
bool openContextMenu = false;
if (viewMode == 2) { if (viewMode == 2) {
// ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ── // ── TABLE mode (material-ized): no outer/grid borders, row backgrounds, interactive sort. ──
if (ImGui::BeginTable("AddressBookTable", 3, if (ImGui::BeginTable("AddressBookTable", 3,
@@ -380,16 +961,30 @@ void RenderContactsTab(App* app)
const auto& entry = book.entries()[i]; const auto& entry = book.entries()[i];
ImGui::TableNextRow(); ImGui::TableNextRow();
ImGui::PushID(static_cast<int>(i)); ImGui::PushID(static_cast<int>(i));
const bool shielded = isShieldedAddr(entry.address);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
bool is_selected = (s_selected_index == static_cast<int>(i)); bool is_selected = (s_selected_index == static_cast<int>(i));
// Small avatar before the label. SpanAllColumns keeps the whole row clickable; the avatar
// is drawn AFTER the Selectable so its selection/hover fill doesn't paint over it.
const float tLineH = ImGui::GetTextLineHeight();
const float tAvR = tLineH * 0.5f;
const ImVec2 tAvP = ImGui::GetCursorScreenPos();
ImGui::Dummy(ImVec2(tAvR * 2.0f, tLineH));
ImGui::SameLine(0.0f, 6.0f * dp);
if (ImGui::Selectable(entry.label.c_str(), is_selected, if (ImGui::Selectable(entry.label.c_str(), is_selected,
ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) { ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowDoubleClick)) {
if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1; if (s_selected_index != static_cast<int>(i)) s_confirm_delete_idx = -1;
s_selected_index = static_cast<int>(i); s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit(); if (ImGui::IsMouseDoubleClicked(0)) openEdit();
} }
if (ImGui::IsItemHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
}
bool tAvOnScreen = ImGui::IsRectVisible(tAvP, ImVec2(tAvP.x + tAvR * 2.0f, tAvP.y + tLineH));
drawContactAvatar(ImGui::GetWindowDrawList(), ImVec2(tAvP.x + tAvR, tAvP.y + tLineH * 0.5f),
tAvR, entry, shielded, typeColor(shielded), lightTheme, dp,
material::Type().caption(), material::Type().iconSmall(), tAvOnScreen);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
bool shielded = isShieldedAddr(entry.address);
ImGui::PushFont(material::Type().subtitle2()); ImGui::PushFont(material::Type().subtitle2());
ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T"); ImGui::TextColored(ImGui::ColorConvertU32ToFloat4(typeColor(shielded)), "%s", shielded ? "Z" : "T");
ImGui::PopFont(); ImGui::PopFont();
@@ -437,6 +1032,7 @@ void RenderContactsTab(App* app)
const float avR = 15.0f * dp; const float avR = 15.0f * dp;
const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp); const float rowH = avR * 2.0f + (asCard ? 16.0f * dp : 12.0f * dp);
const float round = 10.0f * dp; const float round = 10.0f * dp;
bool rowDeleteRequested = false; // per-row delete is deferred until after the loop
for (size_t vi = 0; vi < visibleRows.size(); ++vi) { for (size_t vi = 0; vi < visibleRows.size(); ++vi) {
size_t i = visibleRows[vi]; size_t i = visibleRows[vi];
const auto& entry = book.entries()[i]; const auto& entry = book.entries()[i];
@@ -444,6 +1040,7 @@ void RenderContactsTab(App* app)
const bool selected = (s_selected_index == static_cast<int>(i)); const bool selected = (s_selected_index == static_cast<int>(i));
const ImVec2 p = ImGui::GetCursorScreenPos(); const ImVec2 p = ImGui::GetCursorScreenPos();
const float w = ImGui::GetContentRegionAvail().x; const float w = ImGui::GetContentRegionAvail().x;
ImGui::SetNextItemAllowOverlap(); // let the per-row action buttons overlap the row
if (ImGui::Selectable("##it", selected, if (ImGui::Selectable("##it", selected,
ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick, ImGuiSelectableFlags_SpanAvailWidth | ImGuiSelectableFlags_AllowDoubleClick,
ImVec2(0, rowH))) { ImVec2(0, rowH))) {
@@ -451,59 +1048,125 @@ void RenderContactsTab(App* app)
s_selected_index = static_cast<int>(i); s_selected_index = static_cast<int>(i);
if (ImGui::IsMouseDoubleClicked(0)) openEdit(); if (ImGui::IsMouseDoubleClicked(0)) openEdit();
} }
const bool hov = ImGui::IsItemHovered(); const bool selHov = ImGui::IsItemHovered();
if (hov) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); if (selHov && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) {
s_selected_index = static_cast<int>(i); s_confirm_delete_idx = -1; openContextMenu = true;
}
const ImVec2 afterRow = ImGui::GetCursorScreenPos(); // restore this for the next row
const ImVec2 mn = p, mx(p.x + w, p.y + rowH); const ImVec2 mn = p, mx(p.x + w, p.y + rowH);
const bool rowHovered = ImGui::IsMouseHoveringRect(mn, mx); // whole-row hover (survives action-icon hover)
if (rowHovered && !selected) ImGui::SetMouseCursor(ImGuiMouseCursor_Hand);
if (asCard) { if (asCard) {
ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42) ImU32 fill = selected ? material::WithAlpha(material::Primary(), 42)
: hov ? material::WithAlpha(material::OnSurface(), 26) : rowHovered ? material::WithAlpha(material::OnSurface(), 26)
: material::WithAlpha(material::OnSurface(), 12); : material::WithAlpha(material::OnSurface(), 12);
dl->AddRectFilled(mn, mx, fill, round); dl->AddRectFilled(mn, mx, fill, round);
if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp); if (selected) dl->AddRect(mn, mx, material::WithAlpha(material::Primary(), 150), round, 0, 1.6f * dp);
} else { } else {
if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0); if (selected) dl->AddRectFilled(mn, mx, material::WithAlpha(material::Primary(), 34), 0);
else if (hov) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0); else if (rowHovered) dl->AddRectFilled(mn, mx, material::WithAlpha(material::OnSurface(), 16), 0);
dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f), dl->AddLine(ImVec2(mn.x + pad, mx.y - 0.5f), ImVec2(mx.x - pad, mx.y - 0.5f),
material::WithAlpha(material::OnSurface(), 24), 1.0f); material::WithAlpha(material::OnSurface(), 24), 1.0f);
} }
// Circular type avatar with the Z/T letter. // Avatar: custom image (circular) / Material icon / default Z/T type badge.
const bool shielded = isShieldedAddr(entry.address); const bool shielded = isShieldedAddr(entry.address);
const ImU32 tu = typeColor(shielded); const ImU32 tu = typeColor(shielded);
const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f); const ImVec2 avC(mn.x + pad + avR, mn.y + rowH * 0.5f);
dl->AddCircleFilled(avC, avR, material::WithAlpha(tu, lightTheme ? 45 : 60)); // Only animate when the row is actually on-screen, so a scrolled-off animated avatar
dl->AddCircle(avC, avR, material::WithAlpha(tu, 190), 0, 1.4f * dp); // doesn't hold the app awake (this loop has no clipper).
const char* letter = shielded ? "Z" : "T"; const bool rowOnScreen = ImGui::IsRectVisible(mn, mx);
const ImVec2 ls = lblF->CalcTextSizeA(lblF->LegacySize, FLT_MAX, 0, letter); drawContactAvatar(dl, avC, avR, entry, shielded, tu, lightTheme, dp, lblF, icoF, rowOnScreen);
dl->AddText(lblF, lblF->LegacySize, ImVec2(avC.x - ls.x * 0.5f, avC.y - ls.y * 0.5f), tu, letter); // Label (line 1) + muted address (line 2) — reserve trailing room for the globe + actions.
// Label (line 1) + muted address (line 2). const float actHit = icoF->LegacySize + 8.0f * dp; // per-action square hit area
const float globeW = entry.isGlobal() ? (icoF->LegacySize + 8.0f * dp) : 0.0f;
const float trailW = 3.0f * actHit + 6.0f * dp + globeW;
const float cy = mn.y + rowH * 0.5f;
const float tx = mn.x + pad + avR * 2.0f + pad; const float tx = mn.x + pad + avR * 2.0f + pad;
const float trailW = entry.isGlobal() ? 28.0f * dp : 0.0f;
const float textMaxX = mx.x - pad - trailW; const float textMaxX = mx.x - pad - trailW;
const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp; const float blockH = lblF->LegacySize + adrF->LegacySize + 3.0f * dp;
const float ty = mn.y + rowH * 0.5f - blockH * 0.5f; const float ty = cy - blockH * 0.5f;
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true); dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
dl->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str()); dl->AddText(lblF, lblF->LegacySize, ImVec2(tx, ty), material::OnSurface(), entry.label.c_str());
dl->PopClipRect(); dl->PopClipRect();
std::string addr = util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate); // Un-collapse to the full address on hover (clipped to the text column so it never
// runs under the trailing actions); middle-truncated otherwise.
std::string addr = rowHovered
? entry.address
: util::truncateMiddle(entry.address, addrFrontLbl.truncate, addrBackLbl.truncate);
dl->PushClipRect(ImVec2(tx, mn.y), ImVec2(textMaxX, mx.y), true);
dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp), dl->AddText(adrF, adrF->LegacySize, ImVec2(tx, ty + lblF->LegacySize + 3.0f * dp),
material::OnSurfaceMedium(), addr.c_str()); material::OnSurfaceMedium(), addr.c_str());
// Trailing globe badge for global contacts. dl->PopClipRect();
// Trailing: the globe badge stays pinned far-right (global contacts); per-row copy/edit/
// delete actions appear to its LEFT on hover/selection.
float rightX = mx.x - pad;
if (entry.isGlobal()) { if (entry.isGlobal()) {
const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC); const ImVec2 gs = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, ICON_MD_PUBLIC);
dl->AddText(icoF, icoF->LegacySize, dl->AddText(icoF, icoF->LegacySize, ImVec2(rightX - gs.x, cy - gs.y * 0.5f),
ImVec2(mx.x - pad - gs.x, mn.y + rowH * 0.5f - gs.y * 0.5f),
material::OnSurfaceMedium(), ICON_MD_PUBLIC); material::OnSurfaceMedium(), ICON_MD_PUBLIC);
rightX -= gs.x + 8.0f * dp;
} }
if (hov) material::Tooltip("%s", entry.address.c_str()); if (rowHovered || selected) {
const char* actGlyph[3] = { ICON_MD_CONTENT_COPY, ICON_MD_EDIT, ICON_MD_DELETE };
const char* actTip[3] = { TR("copy_address"), TR("edit"), TR("delete") };
float ax = rightX - actHit; // rightmost action (delete), just left of the globe
for (int a = 2; a >= 0; --a) {
ImGui::SetCursorScreenPos(ImVec2(ax, cy - actHit * 0.5f));
ImGui::PushID(a + 100);
const bool aClk = ImGui::InvisibleButton("##act", ImVec2(actHit, actHit));
const bool aHov = ImGui::IsItemHovered();
if (aHov) { ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); material::Tooltip("%s", actTip[a]); }
if (aClk) {
s_selected_index = static_cast<int>(i);
if (a == 0) doCopy();
else if (a == 1) openEdit();
// Delete mutates book.entries() (removeEntry -> erase) — deferring it past the
// row loop avoids iterating a vector we shrink mid-loop (OOB on later rows).
else rowDeleteRequested = true;
}
const bool armedDel = (a == 2 && s_confirm_delete_idx == static_cast<int>(i));
const ImU32 acol = armedDel ? material::ReadableError()
: aHov ? material::OnSurface()
: material::OnSurfaceMedium();
const ImVec2 gsz = icoF->CalcTextSizeA(icoF->LegacySize, FLT_MAX, 0, actGlyph[a]);
dl->AddText(icoF, icoF->LegacySize,
ImVec2(ax + (actHit - gsz.x) * 0.5f, cy - gsz.y * 0.5f),
acol, actGlyph[a]);
ImGui::PopID();
ax -= actHit + 2.0f * dp;
}
}
ImGui::SetCursorScreenPos(afterRow); // undo the action buttons' cursor moves
ImGui::PopID(); ImGui::PopID();
} }
// Run the deferred per-row delete now that the loop over book.entries() has finished
// (doDelete's two-stage confirm arms on the first click and removes on the second).
if (rowDeleteRequested) doDelete();
// The per-row SetCursorScreenPos leaves the cursor at the last row's bottom with no item
// submitted there; commit it so ImGui sizes the scroll child (avoids the extend-boundary warning).
ImGui::Dummy(ImVec2(0.0f, 0.0f));
ImGui::PopStyleVar(); ImGui::PopStyleVar();
ImGui::PopStyleColor(3); ImGui::PopStyleColor(3);
} }
// A left-click on empty list space (no row/action hovered) clears the selection.
if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !ImGui::IsAnyItemHovered()) {
s_selected_index = -1;
s_confirm_delete_idx = -1;
}
ImGui::EndChild(); ImGui::EndChild();
ImGui::PopStyleColor(); ImGui::PopStyleColor();
} }
// Shared right-click context menu (opened by either view's row right-click; acts on the selection).
if (openContextMenu) ImGui::OpenPopup("##contactCtx");
if (ImGui::BeginPopup("##contactCtx")) {
if (ImGui::MenuItem(TR("copy_address"))) doCopy();
if (ImGui::MenuItem(TR("edit"))) openEdit();
ImGui::Separator();
if (ImGui::MenuItem(TR("delete"))) doDelete();
ImGui::EndPopup();
}
// Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved"). // Status line — singular form for exactly one contact ("1 address saved", not "1 addresses saved").
ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size()); ImGui::TextDisabled(TR(book.size() == 1 ? "address_book_count_one" : "address_book_count"), book.size());
@@ -537,5 +1200,28 @@ void RenderContactsTab(App* app)
renderEntryDialog(); renderEntryDialog();
} }
void ContactsSweepOpenEditDialog(int avatarMode)
{
s_selected_index = 0; // seedSweepContacts seeds entry 0 as a valid target
copyEditField(s_edit_label, sizeof(s_edit_label), "drgx pool payout address");
copyEditField(s_edit_address, sizeof(s_edit_address),
"zs1sweepdemocoldsavingsaddressforuicapture0000000000000000000000000000");
copyEditField(s_edit_notes, sizeof(s_edit_notes), "mining pool payouts");
s_edit_global = true;
s_edit_avatar_mode = (avatarMode < 0 || avatarMode > 2) ? 0 : avatarMode;
s_edit_avatar = (s_edit_avatar_mode == 1) ? "icon:account_balance" : "";
s_edit_icon_search[0] = '\0';
s_show_add_dialog = false;
s_show_edit_dialog = true;
s_focus_edit_field = false;
}
void ContactsSweepCloseDialog()
{
s_show_edit_dialog = false;
s_show_add_dialog = false;
ImagePicker::close();
}
} // namespace ui } // namespace ui
} // namespace dragonx } // namespace dragonx

View File

@@ -21,5 +21,15 @@ namespace ui {
*/ */
void RenderContactsTab(App* app); void RenderContactsTab(App* app);
// True if an animated avatar frame was drawn since the previous call (clear-on-read). The main render
// loop uses this to keep drawing while an avatar animation plays, then idle when it stops.
bool ConsumeContactsAvatarAnimation();
// UI-sweep ONLY: open the revamped add/edit dialog on a seeded demo contact, in the given avatar
// mode (0 = badge, 1 = icon, 2 = image), so the sweep can capture the preview + avatar picker.
// Pair with ContactsSweepCloseDialog(). Do not use outside the sweep.
void ContactsSweepOpenEditDialog(int avatarMode);
void ContactsSweepCloseDialog();
} // namespace ui } // namespace ui
} // namespace dragonx } // namespace dragonx

View File

@@ -0,0 +1,531 @@
// DragonX Wallet - ImGui Edition
// Copyright 2024-2026 The Hush Developers
// Released under the GPLv3
//
// In-app, Material-styled image picker. A modal overlay that browses the filesystem and lets the
// user pick an image file, shown as a thumbnail grid. Used by the Contacts edit dialog to choose a
// custom contact avatar. Like FolderPicker, only one overlay renders at a time (the framework does
// not nest), so the caller suppresses its own overlay while ImagePicker::isOpen().
//
// Thumbnails are decoded to raw pixels, box-downscaled to a small texture, and cached per directory
// (destroyed on navigate/close) so browsing a Pictures folder full of large photos stays cheap.
#pragma once
#include <algorithm>
#include <atomic>
#include <cctype>
#include <cmath>
#include <cstdint>
#include <filesystem>
#include <functional>
#include <memory>
#include <string>
#include <thread>
#include <unordered_map>
#include <vector>
#include "imgui.h"
#include "../../util/i18n.h"
#include "../../util/platform.h"
#include "../../util/texture_loader.h"
#include "../../embedded/IconsMaterialDesign.h"
#include "../layout.h"
#include "../material/colors.h"
#include "../material/draw_helpers.h"
#include "../material/type.h"
namespace dragonx {
namespace ui {
class ImagePicker {
public:
// Open the picker starting at `startDir` (falls back to the user's Pictures dir, then home, if
// empty/invalid). `onPick` receives the chosen absolute image path when the user confirms.
static void open(const std::string& startDir, std::function<void(const std::string&)> onPick) {
namespace fs = std::filesystem;
std::error_code ec;
std::string start = startDir;
if (start.empty() || !fs::is_directory(fs::path(start), ec)) {
if (!s_dir.empty() && fs::is_directory(fs::path(s_dir), ec)) start = s_dir; // reopen last
else start = defaultStartDir();
}
navigate(start);
s_onPick = std::move(onPick);
s_selected.clear();
s_open = true;
}
static bool isOpen() { return s_open; }
static void close() { s_open = false; clearThumbs(); }
static void render() {
if (!s_open) return;
using namespace material;
const float dp = Layout::dpiScale();
s_loadedThisFrame = 0; // budget: decode at most a few thumbnails per frame (no scroll hitch)
ImFont* rowFont = Type().body1();
ImFont* icoFont = Type().iconSmall();
ImFont* metaFont = Type().caption();
const float vpH = ImGui::GetMainViewport()->Size.y;
const float cardH = (vpH * 0.82f) / dp; // logical; the framework re-applies dp + caps at vp-32
OverlayDialogSpec ov;
ov.title = TR("img_picker_title");
ov.p_open = &s_open;
ov.style = OverlayStyle::BlurFloat;
ov.cardWidth = 760.0f;
ov.cardHeight = cardH;
ov.idSuffix = "imagepicker";
if (!BeginOverlayDialog(ov)) { if (!s_open) clearThumbs(); return; }
if (ImGui::IsKeyPressed(ImGuiKey_Escape)) s_open = false;
auto tw = [](ImFont* f, const std::string& s){ return f->CalcTextSizeA(f->LegacySize, FLT_MAX, 0, s.c_str()).x; };
auto fitPath = [&](std::string s, ImFont* f, float maxW){
const std::string ell = "\xE2\x80\xA6";
if (tw(f,s) <= maxW) return s;
while (s.size()>1 && tw(f, ell+s) > maxW){
s.erase(s.begin());
while (!s.empty() && (static_cast<unsigned char>(s.front()) & 0xC0) == 0x80) s.erase(s.begin());
}
return ell + s;
};
auto fit = [&](std::string s, ImFont* f, float maxW){
bool t=false;
while (s.size()>1 && tw(f,s)>maxW){
while (s.size()>1 && (static_cast<unsigned char>(s.back()) & 0xC0) == 0x80) s.pop_back();
if (s.size()>1) s.pop_back();
t=true;
}
if (t) s += "\xE2\x80\xA6";
return s;
};
std::string pendingNav;
// Inner padded body so the filled list + thumbnail grid keep a clear margin from the card's
// rounded edges (the overlay's own content padding is tight for edge-to-edge filled content).
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(10.0f * dp, 8.0f * dp));
ImGui::BeginChild("##imgPickBody", ImVec2(0, 0), ImGuiChildFlags_AlwaysUseWindowPadding,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
// ---- Path bar: Up + Home + Pictures + current path strip ------------------------------
{
const float bh = ImGui::GetFrameHeight();
IconButtonStyle st; st.color = OnSurfaceMedium(); st.hoverBg = StateHover();
st.bgRounding = 5.0f * dp;
std::filesystem::path cur(s_dir);
const bool hasParent = cur.has_parent_path() && cur.parent_path() != cur;
st.tooltip = TR("picker_up");
if (IconButton("##imgUp", ICON_MD_ARROW_UPWARD, icoFont, ImVec2(bh, bh), st) && hasParent)
pendingNav = cur.parent_path().string();
ImGui::SameLine(0.0f, Layout::spacingXs());
IconButtonStyle hst = st; hst.tooltip = TR("picker_home");
if (IconButton("##imgHome", ICON_MD_HOME, icoFont, ImVec2(bh, bh), hst))
pendingNav = util::Platform::getHomeDir();
ImGui::SameLine(0.0f, Layout::spacingXs());
IconButtonStyle pst = st; pst.tooltip = TR("img_picker_pictures");
if (IconButton("##imgPics", ICON_MD_IMAGE, icoFont, ImVec2(bh, bh), pst))
pendingNav = defaultStartDir();
ImGui::SameLine(0.0f, Layout::spacingSm());
ImVec2 sMin = ImGui::GetCursorScreenPos();
const float stripW = ImGui::GetContentRegionAvail().x;
ImVec2 sMax(sMin.x + stripW, sMin.y + bh);
ImDrawList* wdl = ImGui::GetWindowDrawList();
wdl->AddRectFilled(sMin, sMax, WithAlpha(OnSurface(), 14), 6.0f * dp);
wdl->AddRect(sMin, sMax, WithAlpha(OnSurface(), 40), 6.0f * dp, 0, 1.0f);
const float tpad = Layout::spacingSm();
wdl->AddText(rowFont, rowFont->LegacySize,
ImVec2(sMin.x + tpad, sMin.y + (bh - rowFont->LegacySize) * 0.5f),
OnSurface(), fitPath(s_dir, rowFont, stripW - tpad * 2.0f).c_str());
ImGui::Dummy(ImVec2(stripW, bh));
}
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
// ---- Body: subfolder rows (to navigate) then a thumbnail grid of images ----------------
const ImGuiStyle& gstyle = ImGui::GetStyle();
const float footerBlockH = Layout::spacingXs() + metaFont->LegacySize // status line
+ Layout::spacingSm() + 1.0f + Layout::spacingSm() // separator block
+ ImGui::GetFrameHeight() // footer buttons
+ 6.0f * gstyle.ItemSpacing.y;
float listH = ImGui::GetContentRegionAvail().y - footerBlockH;
listH = std::max(listH, 160.0f * dp);
const float fullW = ImGui::GetContentRegionAvail().x;
// Bordered/rounded outer frame whose 6px padding insets the inner scrollbar so it clears the
// card's rounded corners; the inner child scrolls smoothly (ApplySmoothScroll lerps the wheel).
ImGui::PushStyleColor(ImGuiCol_ChildBg, WithAlpha(OnSurface(), 20));
ImGui::PushStyleVar(ImGuiStyleVar_ChildRounding, 6.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f * dp, 6.0f * dp));
ImGui::BeginChild("##imgListFrame", ImVec2(fullW, listH), true,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoScrollWithMouse);
ImGui::PopStyleVar(2); // ChildRounding + outer WindowPadding (captured by the frame child)
ImGui::PopStyleColor(); // ChildBg — the frame already drew it; the inner child stays transparent
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarSize, 11.0f * dp);
ImGui::PushStyleVar(ImGuiStyleVar_ScrollbarRounding, 5.5f * dp);
ImGui::BeginChild("##imgList", ImVec2(0, 0), false, ImGuiWindowFlags_NoScrollWithMouse);
ApplySmoothScroll();
ImDrawList* dl = ImGui::GetWindowDrawList();
const float padX = Layout::spacingSm();
if (s_subdirs.empty() && s_images.empty()) {
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
ImGui::Indent(padX);
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_empty"));
ImGui::Unindent(padX);
}
// Folders: a 2-column grid of thin rounded rectangles (folder icon + name), clickable to descend.
if (!s_subdirs.empty()) {
const int cols = 2;
const float cellGap = Layout::spacingSm();
const float availW = ImGui::GetContentRegionAvail().x;
const float cellW = (availW - cellGap * (cols - 1)) / (float)cols;
const float cellH = rowFont->LegacySize + Layout::spacingSm() * 1.5f;
int col = 0;
for (std::size_t i = 0; i < s_subdirs.size(); ++i) {
if (col != 0) ImGui::SameLine(0, cellGap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cellW, mn.y + cellH);
ImGui::PushID((int)(i + 1));
const bool clicked = ImGui::InvisibleButton("##idir", ImVec2(cellW, cellH));
const bool hov = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), hov ? 34 : 16), 6.0f * dp);
if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 70), 6.0f * dp, 0, 1.0f); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
const float midY = (mn.y + mx.y) * 0.5f;
const float icoSz = rowFont->LegacySize * 1.05f;
float x = mn.x + padX;
dl->AddText(icoFont, icoSz, ImVec2(x, midY - icoSz * 0.5f), hov ? Primary() : OnSurfaceMedium(), ICON_MD_FOLDER);
x += icoSz + Layout::spacingSm();
std::string nm = fit(s_subdirs[i], rowFont, (mx.x - padX) - x);
dl->AddText(rowFont, rowFont->LegacySize, ImVec2(x, midY - rowFont->LegacySize * 0.5f), OnSurface(), nm.c_str());
if (clicked) pendingNav = (std::filesystem::path(s_dir) / s_subdirs[i]).string();
col = (col + 1) % cols;
}
if (!s_images.empty()) ImGui::Dummy(ImVec2(0, Layout::spacingSm())); // gap before the thumbnails
}
// Thumbnail grid: a fixed 6 columns whose square cells scale to fill the width.
{
const float availW = ImGui::GetContentRegionAvail().x;
const float gap = Layout::spacingSm();
const int cols = 6;
const float cell = std::max(24.0f * dp, (availW - gap * (cols - 1)) / (float)cols);
int col = 0;
for (std::size_t i = 0; i < s_images.size(); ++i) {
if (col != 0) ImGui::SameLine(0, gap);
ImVec2 mn = ImGui::GetCursorScreenPos();
ImVec2 mx(mn.x + cell, mn.y + cell);
const std::string abs = (std::filesystem::path(s_dir) / s_images[i]).string();
const bool sel = (s_selected == abs);
ImGui::PushID((int)(i + 1));
const bool clicked = ImGui::InvisibleButton("##thumb", ImVec2(cell, cell));
const bool dbl = ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left) && ImGui::IsItemHovered();
const bool hov = ImGui::IsItemHovered();
ImGui::PopID();
dl->AddRectFilled(mn, mx, WithAlpha(OnSurface(), 18), 6.0f * dp);
// Only decode thumbnails for cells actually on-screen, and only a few per frame.
const Thumb* t = ImGui::IsRectVisible(mn, mx) ? thumbFor(abs) : nullptr;
ImTextureID tex = t ? t->tex : 0;
int tw = t ? t->w : 0, thh = t ? t->h : 0;
// Hovering an animated image loads its frames in the BACKGROUND (no UI hang) and plays
// them once ready; the still thumbnail shows meanwhile.
if (t && hov && t->animated) {
Thumb& mt = s_thumbs[abs]; // t is non-null => the entry exists
driveThumbAnim(mt, abs);
if (mt.animReady && mt.frames.size() > 1 && mt.totalDur > 0.0f) {
s_animatingThisFrame = true;
double phase = std::fmod(ImGui::GetTime(), (double)mt.totalDur);
double acc = 0.0;
for (size_t k = 0; k < mt.delays.size() && k < mt.frames.size(); ++k) {
acc += mt.delays[k];
if (phase < acc) { tex = mt.frames[k]; tw = mt.aw; thh = mt.ah; break; }
}
}
}
if (tex) {
float u0=0,v0=0,u1=1,v1=1; // centre-crop to a square
if (tw > thh) { float m=(tw-thh)*0.5f/tw; u0=m; u1=1-m; }
else if (thh > tw) { float m=(thh-tw)*0.5f/thh; v0=m; v1=1-m; }
const float ins = 3.0f * dp;
dl->AddImageRounded(tex, ImVec2(mn.x+ins, mn.y+ins), ImVec2(mx.x-ins, mx.y-ins),
ImVec2(u0,v0), ImVec2(u1,v1), IM_COL32_WHITE, 5.0f * dp);
} else {
ImVec2 cc(mn.x + cell*0.5f, mn.y + cell*0.5f);
dl->AddText(icoFont, cell*0.34f, ImVec2(cc.x - cell*0.17f, cc.y - cell*0.17f),
OnSurfaceDisabled(), ICON_MD_IMAGE);
}
// "Animated" badge (bottom-right) on GIF/WebP that move — hidden while it plays on hover.
if (t && t->animated && !hov) {
float bh = std::max(13.0f * dp, cell * 0.24f);
float bw = bh * 1.15f;
ImVec2 bmax(mx.x - 4.0f * dp, mx.y - 4.0f * dp);
ImVec2 bmin(bmax.x - bw, bmax.y - bh);
dl->AddRectFilled(bmin, bmax, IM_COL32(0, 0, 0, 180), 4.0f * dp);
float gsz = bh * 0.92f;
ImVec2 gs = icoFont->CalcTextSizeA(gsz, FLT_MAX, 0, ICON_MD_PLAY_ARROW);
dl->AddText(icoFont, gsz,
ImVec2((bmin.x + bmax.x) * 0.5f - gs.x * 0.5f, (bmin.y + bmax.y) * 0.5f - gs.y * 0.5f),
IM_COL32(255, 255, 255, 235), ICON_MD_PLAY_ARROW);
}
if (sel) dl->AddRect(mn, mx, WithAlpha(Primary(), 220), 6.0f * dp, 0, 2.0f * dp);
else if (hov) { dl->AddRect(mn, mx, WithAlpha(OnSurface(), 90), 6.0f * dp, 0, 1.5f * dp); ImGui::SetMouseCursor(ImGuiMouseCursor_Hand); }
if (hov) material::Tooltip("%s", s_images[i].c_str());
if (dbl && s_onPick) { s_onPick(abs); s_open = false; }
else if (clicked) s_selected = abs;
col = (col + 1) % cols;
}
}
ImGui::EndChild(); // inner scrolling list
ImGui::PopStyleVar(2); // ScrollbarSize + ScrollbarRounding
ImGui::EndChild(); // outer frame
// ---- Status ----------------------------------------------------------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingXs()));
if (!s_images.empty()) {
char buf[96];
snprintf(buf, sizeof(buf), TR("img_picker_count"), (int)s_images.size());
Type().textColored(TypeStyle::Caption, OnSurfaceMedium(), buf);
} else {
Type().textColored(TypeStyle::Caption, OnSurfaceDisabled(), TR("img_picker_none"));
}
// ---- Footer: Use image / Cancel (centered) ---------------------------------------------
ImGui::Dummy(ImVec2(0, Layout::spacingSm()));
{
const float useW = 200.0f * dp, cancelW = 120.0f * dp;
const float total = useW + cancelW + ImGui::GetStyle().ItemSpacing.x;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() +
std::max(0.0f, (ImGui::GetContentRegionAvail().x - total) * 0.5f));
ImGui::BeginDisabled(s_selected.empty());
if (StyledButton(TR("img_picker_use"), ImVec2(useW, 0))) {
if (s_onPick && !s_selected.empty()) s_onPick(s_selected);
s_open = false;
}
ImGui::EndDisabled();
ImGui::SameLine();
if (StyledButton(TR("cancel"), ImVec2(cancelW, 0))) s_open = false;
}
ImGui::EndChild(); // ##imgPickBody
ImGui::PopStyleVar(); // WindowPadding
EndOverlayDialog();
if (!pendingNav.empty()) navigate(pendingNav);
if (!s_open) clearThumbs(); // release GL textures the frame the picker dismisses
}
public:
// Clear-on-read: true if an animated thumbnail was playing this frame (mouse over it), so the render
// loop keeps drawing while a hover-preview animates. Consumed by ConsumeContactsAvatarAnimation.
static bool consumeAnimationActive() { bool v = s_animatingThisFrame; s_animatingThisFrame = false; return v; }
private:
// Background decode job: a detached worker fills `result` off the UI thread and sets `done`. The
// shared_ptr keeps it alive if the owning Thumb is destroyed mid-decode (no blocking on navigate).
struct AnimJob {
std::atomic<bool> done{false};
util::AnimFrames result;
};
struct Thumb {
ImTextureID tex = 0; int w = 0, h = 0; bool tried = false; // static frame-0 thumbnail
bool animated = false; // multi-frame GIF/WebP (badge)
// Async animation load (kicked off on hover): the full frame decode runs on a worker thread,
// then frames are uploaded to textures a few per UI frame — so hovering never blocks the UI.
bool animRequested = false, staged = false, animReady = false;
std::shared_ptr<AnimJob> job;
util::AnimFrames pending; // decoded frames awaiting GPU upload
size_t uploaded = 0;
std::vector<ImTextureID> frames;
std::vector<float> delays; int aw = 0, ah = 0; float totalDur = 0.0f;
};
// Drive the async animation load for a hovered thumbnail: (1) kick off a background decode once,
// (2) move its result to a staging buffer when done, (3) upload a few frames per UI frame. Never
// blocks — the static thumbnail keeps showing until the sequence is fully uploaded.
static void driveThumbAnim(Thumb& th, const std::string& path) {
if (th.animReady) return;
if (!th.animRequested) {
if (s_animInFlight->load() >= kMaxAnimInFlight) return; // cap workers; retry a later frame
th.animRequested = true;
th.job = std::make_shared<AnimJob>();
s_animInFlight->fetch_add(1);
std::shared_ptr<AnimJob> job = th.job;
std::shared_ptr<std::atomic<int>> inflight = s_animInFlight; // co-owned: safe past teardown
std::string p = path;
std::thread([job, inflight, p]() {
util::LoadAnimatedRGBA(p.c_str(), kThumbMaxFrames, job->result);
job->done.store(true, std::memory_order_release);
inflight->fetch_sub(1);
}).detach();
return;
}
if (th.job && th.job->done.load(std::memory_order_acquire)) {
th.pending = std::move(th.job->result);
th.job.reset();
if (th.pending.frames.size() <= 1) { // turned out not to animate
th.animated = false; th.pending = util::AnimFrames{}; th.animReady = true; return;
}
th.staged = true;
}
if (th.staged) {
s_animatingThisFrame = true; // keep redrawing so the upload progresses
int budget = 4;
while (th.uploaded < th.pending.frames.size() && budget-- > 0) {
ImTextureID t = 0;
if (util::CreateRawTexture(th.pending.frames[th.uploaded].data(), th.pending.w, th.pending.h, false, &t)) {
th.frames.push_back(t);
th.delays.push_back(th.uploaded < th.pending.delaysSec.size() ? th.pending.delaysSec[th.uploaded] : 0.1f);
}
th.uploaded++;
}
if (th.uploaded >= th.pending.frames.size()) {
th.aw = th.pending.w; th.ah = th.pending.h;
for (float d : th.delays) th.totalDur += d;
th.pending = util::AnimFrames{};
th.animReady = true;
}
}
}
static bool isImageName(const std::string& name) {
auto lower = name;
for (char& c : lower) c = (char)std::tolower((unsigned char)c);
static const char* kExt[] = { ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp",
".tga", ".psd", ".pnm", ".ppm", ".pgm", ".pic" };
for (const char* e : kExt) {
const std::string ext(e);
if (lower.size() > ext.size() && lower.compare(lower.size()-ext.size(), ext.size(), ext) == 0)
return true;
}
return false;
}
// Only these can hold multiple frames in our decoders; skip the hover re-decode for other formats.
static bool isAnimatableName(const std::string& name) {
auto lo = name;
for (char& c : lo) c = (char)std::tolower((unsigned char)c);
auto ends = [&](const char* e){ std::string s(e); return lo.size()>s.size() && lo.compare(lo.size()-s.size(), s.size(), s)==0; };
return ends(".gif") || ends(".webp");
}
static std::string defaultStartDir() {
namespace fs = std::filesystem;
std::error_code ec;
const std::string home = util::Platform::getHomeDir();
fs::path pics = fs::path(home) / "Pictures";
if (fs::is_directory(pics, ec)) return pics.string();
return home;
}
// Lazy, budgeted thumbnail loader: decodes to raw pixels, box-downscales to <=THUMB px, uploads a
// small texture. Returns null (placeholder icon shown) until the budget lets it load.
static const Thumb* thumbFor(const std::string& absPath) {
auto it = s_thumbs.find(absPath);
if (it != s_thumbs.end()) return &it->second;
if (s_loadedThisFrame >= kLoadsPerFrame) return nullptr; // defer to a later frame
s_loadedThisFrame++;
Thumb th; th.tried = true;
// Cheap animation probe (header/structure only) so animated items can show a badge without
// decoding all frames; only .gif/.webp can be animations in our decoders.
if (isAnimatableName(absPath)) th.animated = util::IsAnimatedImageFile(absPath.c_str());
int sw = 0, sh = 0;
unsigned char* raw = util::LoadRawPixelsFromFile(absPath.c_str(), &sw, &sh);
if (raw && sw > 0 && sh > 0) {
int dw = sw, dh = sh;
if (sw > kThumbPx || sh > kThumbPx) {
float s = (float)kThumbPx / (float)std::max(sw, sh);
dw = std::max(1, (int)(sw * s));
dh = std::max(1, (int)(sh * s));
}
std::vector<unsigned char> small((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = raw + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = small.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
if (util::CreateRawTexture(small.data(), dw, dh, false, &th.tex)) { th.w = dw; th.h = dh; }
}
if (raw) util::FreeRawPixels(raw);
auto& slot = (s_thumbs[absPath] = th);
return &slot;
}
static void clearThumbs() {
for (auto& kv : s_thumbs) {
if (kv.second.tex) util::DestroyTexture(kv.second.tex);
for (ImTextureID f : kv.second.frames) if (f) util::DestroyTexture(f);
}
s_thumbs.clear();
}
static void navigate(const std::string& dir) {
namespace fs = std::filesystem;
std::error_code ec;
fs::path p(dir);
if (!fs::is_directory(p, ec)) return;
clearThumbs(); // thumbnails are per-directory
fs::path abs = fs::absolute(p, ec);
s_dir = ec ? dir : abs.lexically_normal().string();
s_subdirs.clear();
s_images.clear();
fs::directory_iterator it(p, ec), end;
int guard = 0;
for (; !ec && it != end && guard < 8000; it.increment(ec), ++guard) {
std::error_code fec;
std::string name = it->path().filename().string();
if (name.empty() || name[0] == '.') continue; // skip hidden/dotfiles
if (it->is_directory(fec)) s_subdirs.push_back(name);
else if (it->is_regular_file(fec) && isImageName(name)) s_images.push_back(name);
}
auto ci = [](const std::string& a, const std::string& b){
return std::lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(),
[](char x, char y){ return std::tolower((unsigned char)x) < std::tolower((unsigned char)y); });
};
std::sort(s_subdirs.begin(), s_subdirs.end(), ci);
std::sort(s_images.begin(), s_images.end(), ci);
}
static constexpr int kThumbPx = 128; // max thumbnail dimension (downscaled)
static constexpr int kLoadsPerFrame = 6; // decode budget per frame (spreads a big folder over frames)
static constexpr int kThumbMaxFrames = 300; // cap frames per hovered animation
static constexpr int kMaxAnimInFlight = 2; // max concurrent background decodes
// Heap-owned so a detached worker (which decrements it) can safely outlive static teardown at
// process exit — the worker holds a shared_ptr copy, so it never touches a destroyed static.
static inline std::shared_ptr<std::atomic<int>> s_animInFlight = std::make_shared<std::atomic<int>>(0);
static inline bool s_open = false;
// Main-thread-only (set in render/driveThumbAnim, read+cleared in consumeAnimationActive); NOT atomic
// on purpose — do not read/write it from a worker thread.
static inline bool s_animatingThisFrame = false; // a hover-preview animation is playing
static inline int s_loadedThisFrame = 0;
static inline std::string s_dir;
static inline std::string s_selected;
static inline std::vector<std::string> s_subdirs;
static inline std::vector<std::string> s_images;
static inline std::unordered_map<std::string, Thumb> s_thumbs;
static inline std::function<void(const std::string&)> s_onPick;
};
} // namespace ui
} // namespace dragonx

View File

@@ -306,6 +306,13 @@ void I18n::loadBuiltinEnglish()
strings_["picker_dat_count"] = "%d wallet file(s) in this folder"; strings_["picker_dat_count"] = "%d wallet file(s) in this folder";
strings_["picker_dat_none"] = "No wallet files directly in this folder"; strings_["picker_dat_none"] = "No wallet files directly in this folder";
strings_["picker_select"] = "Scan this folder"; strings_["picker_select"] = "Scan this folder";
// In-app image picker (contact avatars)
strings_["img_picker_title"] = "Choose an image";
strings_["img_picker_pictures"] = "Pictures folder";
strings_["img_picker_empty"] = "This folder has no sub-folders or images.";
strings_["img_picker_none"] = "No images in this folder";
strings_["img_picker_count"] = "%d image(s) in this folder";
strings_["img_picker_use"] = "Use image";
strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it"; strings_["tt_seed_migrate"] = "Create a new seed-phrase wallet and move your funds into it";
// Migrate-to-seed modal // Migrate-to-seed modal
strings_["mig_title"] = "Migrate to a seed wallet"; strings_["mig_title"] = "Migrate to a seed wallet";
@@ -382,6 +389,7 @@ void I18n::loadBuiltinEnglish()
strings_["simple_background"] = "Simple background"; strings_["simple_background"] = "Simple background";
strings_["console_scanline"] = "Console scanline"; strings_["console_scanline"] = "Console scanline";
strings_["theme_effects"] = "Theme effects"; strings_["theme_effects"] = "Theme effects";
strings_["animate_avatars"] = "Animate avatars";
strings_["acrylic"] = "Acrylic"; strings_["acrylic"] = "Acrylic";
strings_["noise"] = "Noise"; strings_["noise"] = "Noise";
strings_["ui_opacity"] = "UI Opacity"; strings_["ui_opacity"] = "UI Opacity";
@@ -606,6 +614,7 @@ void I18n::loadBuiltinEnglish()
strings_["tt_simple_bg_alt"] = "Use a gradient version of the theme background image\nHotkey: Ctrl+Up"; strings_["tt_simple_bg_alt"] = "Use a gradient version of the theme background image\nHotkey: Ctrl+Up";
strings_["tt_scanline"] = "CRT scanline effect in console"; strings_["tt_scanline"] = "CRT scanline effect in console";
strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme"; strings_["tt_theme_effects"] = "Shimmer, glow, hue-cycling per theme";
strings_["tt_animate_avatars"] = "Play animated (GIF / WebP) contact avatars; off shows the first frame only";
strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)"; strings_["tt_blur"] = "Blur amount (0%% = off, 100%% = maximum)";
strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)"; strings_["tt_noise"] = "Grain texture intensity (0%% = off, 100%% = maximum)";
strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)"; strings_["tt_ui_opacity"] = "Card and sidebar opacity (100%% = fully opaque, lower = more see-through)";
@@ -1222,6 +1231,21 @@ void I18n::loadBuiltinEnglish()
strings_["address_book_add_new"] = "Add New"; strings_["address_book_add_new"] = "Add New";
strings_["contact_global"] = "Show in every wallet (global contact)"; strings_["contact_global"] = "Show in every wallet (global contact)";
strings_["contact_global_tt"] = "On: this contact stays visible no matter which wallet you load. Off: it belongs to the current wallet only."; strings_["contact_global_tt"] = "On: this contact stays visible no matter which wallet you load. Off: it belongs to the current wallet only.";
// Contact edit dialog: live preview + avatar picker
strings_["contact_preview_name"] = "Contact name";
strings_["contact_preview_addr"] = "Address will appear here";
strings_["contact_avatar"] = "AVATAR";
strings_["contact_avatar_badge"] = "Badge";
strings_["contact_avatar_icon"] = "Icon";
strings_["contact_avatar_image"] = "Image";
strings_["contact_avatar_badge_hint"] = "The badge is chosen automatically from the address type.";
strings_["contact_avatar_shielded"] = "Shielded";
strings_["contact_avatar_transparent"] = "Transparent";
strings_["contact_avatar_choose"] = "Choose image\xE2\x80\xA6";
strings_["contact_avatar_remove"] = "Remove";
strings_["contact_avatar_image_hint"] = "The image is copied into the app so it stays available if the original moves.";
strings_["contact_avatar_copy_failed"] = "Could not copy that image.";
strings_["contact_avatar_bad_image"] = "That image couldn't be loaded.";
strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet"; strings_["contact_global_badge_tt"] = "Global contact — visible in every wallet";
strings_["address_book_added"] = "Address added to book"; strings_["address_book_added"] = "Address added to book";
strings_["address_book_count"] = "%zu addresses saved"; strings_["address_book_count"] = "%zu addresses saved";

View File

@@ -7,12 +7,27 @@
// stb_image — single-file image loader (public domain) // stb_image — single-file image loader (public domain)
// Only compiled here; all other files just include the header. // Only compiled here; all other files just include the header.
#define STB_IMAGE_IMPLEMENTATION #define STB_IMAGE_IMPLEMENTATION
// Formats stb decodes for us: PNG for app assets, plus the common user-avatar formats. WebP is handled
// separately (libwebp) below. Keep this list in sync with ImagePicker's accepted extensions.
#define STBI_ONLY_PNG #define STBI_ONLY_PNG
#define STBI_ONLY_JPEG
#define STBI_ONLY_BMP
#define STBI_ONLY_GIF
#define STBI_ONLY_TGA
#define STBI_ONLY_PSD
#define STBI_ONLY_PNM // .pnm/.ppm/.pgm/.pbm
#define STBI_ONLY_PIC
#define STBI_NO_STDIO // we do our own fread for portability #define STBI_NO_STDIO // we do our own fread for portability
#include "stb_image.h" #include "stb_image.h"
#include <webp/decode.h>
#include <webp/demux.h> // WebPAnimDecoder — animated WebP
#include <algorithm>
#include <cstdint>
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <vector> #include <vector>
#ifdef DRAGONX_USE_DX11 #ifdef DRAGONX_USE_DX11
@@ -38,6 +53,30 @@ static ID3D11Device* GetImGuiD3D11Device()
namespace dragonx { namespace dragonx {
namespace util { namespace util {
// True if the buffer is a WebP (RIFF....WEBP) container.
static bool IsWebP(const unsigned char* d, size_t n)
{
return n >= 12 && memcmp(d, "RIFF", 4) == 0 && memcmp(d + 8, "WEBP", 4) == 0;
}
// Decode any supported image to a fresh RGBA8 buffer. WebP goes through libwebp; everything else
// through stb. The returned buffer is always free()-able (stbi's default free is free(), and the WebP
// path decodes into a malloc'd buffer), so FreeRawPixels(stbi_image_free) releases either. NULL on fail.
static unsigned char* DecodeImageRGBA(const unsigned char* data, size_t len, int* outW, int* outH)
{
if (IsWebP(data, len)) {
int w = 0, h = 0;
if (!WebPGetInfo(data, len, &w, &h) || w <= 0 || h <= 0) return nullptr;
unsigned char* buf = (unsigned char*)malloc((size_t)w * h * 4);
if (!buf) return nullptr;
if (!WebPDecodeRGBAInto(data, len, buf, (size_t)w * h * 4, w * 4)) { free(buf); return nullptr; }
*outW = w; *outH = h;
return buf;
}
int channels = 0;
return stbi_load_from_memory(data, (int)len, outW, outH, &channels, 4);
}
// Read entire file into memory // Read entire file into memory
static bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf) static bool ReadFileToBuffer(const char* path, std::vector<unsigned char>& buf)
{ {
@@ -61,11 +100,10 @@ bool LoadTextureFromFile(const char* path, ImTextureID* outTexID, int* outW, int
return false; return false;
} }
int w = 0, h = 0, channels = 0; int w = 0, h = 0;
unsigned char* pixels = stbi_load_from_memory( unsigned char* pixels = DecodeImageRGBA(fileData.data(), fileData.size(), &w, &h);
fileData.data(), (int)fileData.size(), &w, &h, &channels, 4);
if (!pixels) { if (!pixels) {
DEBUG_LOGF("LoadTextureFromFile: stbi failed for '%s'\n", path); DEBUG_LOGF("LoadTextureFromFile: decode failed for '%s'\n", path);
return false; return false;
} }
@@ -149,11 +187,10 @@ bool LoadTextureFromMemory(const unsigned char* data, size_t dataSize,
return false; return false;
} }
int w = 0, h = 0, channels = 0; int w = 0, h = 0;
unsigned char* pixels = stbi_load_from_memory( unsigned char* pixels = DecodeImageRGBA(data, dataSize, &w, &h);
data, (int)dataSize, &w, &h, &channels, 4);
if (!pixels) { if (!pixels) {
DEBUG_LOGF("LoadTextureFromMemory: stbi decode failed\n"); DEBUG_LOGF("LoadTextureFromMemory: decode failed\n");
return false; return false;
} }
@@ -293,16 +330,13 @@ unsigned char* LoadRawPixelsFromFile(const char* path, int* outW, int* outH)
{ {
std::vector<unsigned char> fileData; std::vector<unsigned char> fileData;
if (!ReadFileToBuffer(path, fileData)) return nullptr; if (!ReadFileToBuffer(path, fileData)) return nullptr;
int channels = 0; return DecodeImageRGBA(fileData.data(), fileData.size(), outW, outH);
return stbi_load_from_memory(fileData.data(), (int)fileData.size(),
outW, outH, &channels, 4);
} }
unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, size_t dataSize, int* outW, int* outH) unsigned char* LoadRawPixelsFromMemory(const unsigned char* data, size_t dataSize, int* outW, int* outH)
{ {
if (!data || dataSize == 0) return nullptr; if (!data || dataSize == 0) return nullptr;
int channels = 0; return DecodeImageRGBA(data, dataSize, outW, outH);
return stbi_load_from_memory(data, (int)dataSize, outW, outH, &channels, 4);
} }
void FreeRawPixels(unsigned char* pixels) void FreeRawPixels(unsigned char* pixels)
@@ -310,6 +344,175 @@ void FreeRawPixels(unsigned char* pixels)
if (pixels) stbi_image_free(pixels); if (pixels) stbi_image_free(pixels);
} }
// Box-average downscale an RGBA image to dw*dh, appending the result as a new frame in `out`.
static void AppendDownscaledFrame(const unsigned char* src, int sw, int sh, int dw, int dh,
std::vector<std::vector<unsigned char>>& out)
{
out.emplace_back();
std::vector<unsigned char>& dst = out.back();
dst.resize((size_t)dw * dh * 4);
for (int y = 0; y < dh; y++) {
int sy0 = y * sh / dh, sy1 = std::max(sy0 + 1, (y + 1) * sh / dh);
for (int x = 0; x < dw; x++) {
int sx0 = x * sw / dw, sx1 = std::max(sx0 + 1, (x + 1) * sw / dw);
uint32_t r=0,g=0,b=0,a=0,n=0;
for (int yy = sy0; yy < sy1; yy++)
for (int xx = sx0; xx < sx1; xx++) {
const unsigned char* p = src + ((size_t)yy * sw + xx) * 4;
r+=p[0]; g+=p[1]; b+=p[2]; a+=p[3]; ++n;
}
unsigned char* d = dst.data() + ((size_t)y * dw + x) * 4;
d[0]=(unsigned char)(r/n); d[1]=(unsigned char)(g/n); d[2]=(unsigned char)(b/n); d[3]=(unsigned char)(a/n);
}
}
}
static void FitDims(int sw, int sh, int maxDim, int* dw, int* dh)
{
*dw = sw; *dh = sh;
if (sw > maxDim || sh > maxDim) {
float s = (float)maxDim / (float)std::max(sw, sh);
*dw = std::max(1, (int)(sw * s));
*dh = std::max(1, (int)(sh * s));
}
}
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out)
{
out.frames.clear();
out.delaysSec.clear();
out.w = out.h = 0;
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
const unsigned char* data = file.data();
const size_t len = file.size();
if (maxFrames < 1) maxFrames = 1;
// Static max dimension is generous (an avatar renders <=112px); animations use a smaller cap so a
// long clip can't cost tens of MB of VRAM. We only know if it animates after peeking the frame count.
const int kStillMax = 256, kAnimMax = 128;
// ---- Animated (or still) WebP via libwebp's demux decoder ----
if (IsWebP(data, len)) {
WebPData wd; wd.bytes = data; wd.size = len;
WebPAnimDecoderOptions opt;
if (!WebPAnimDecoderOptionsInit(&opt)) return false;
opt.color_mode = MODE_RGBA;
WebPAnimDecoder* dec = WebPAnimDecoderNew(&wd, &opt);
if (!dec) {
// Not an animation container — fall back to a single still decode.
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f); out.w = dw; out.h = dh;
free(px);
return true;
}
WebPAnimInfo info;
if (!WebPAnimDecoderGetInfo(dec, &info) || info.canvas_width <= 0 || info.canvas_height <= 0) {
WebPAnimDecoderDelete(dec); return false;
}
const int animated = (info.frame_count > 1);
int dw, dh; FitDims(info.canvas_width, info.canvas_height, animated ? kAnimMax : kStillMax, &dw, &dh);
int prevTs = 0, produced = 0;
while (WebPAnimDecoderHasMoreFrames(dec) && produced < maxFrames) {
uint8_t* buf = nullptr; int ts = 0;
if (!WebPAnimDecoderGetNext(dec, &buf, &ts)) break; // buf valid until next call/delete
AppendDownscaledFrame(buf, info.canvas_width, info.canvas_height, dw, dh, out.frames);
float d = (ts - prevTs) / 1000.0f; prevTs = ts;
out.delaysSec.push_back(std::max(0.02f, d));
++produced;
}
WebPAnimDecoderDelete(dec);
if (out.frames.empty()) return false;
out.w = dw; out.h = dh;
return true;
}
// ---- Animated (or still) GIF via stb ----
const bool isGif = len >= 6 && memcmp(data, "GIF8", 4) == 0;
if (isGif) {
int* delays = nullptr; int w=0,h=0,z=0,comp=0;
unsigned char* all = stbi_load_gif_from_memory(data, (int)len, &delays, &w, &h, &z, &comp, 4);
if (all && w > 0 && h > 0 && z > 0) {
const int animated = (z > 1);
int dw, dh; FitDims(w, h, animated ? kAnimMax : kStillMax, &dw, &dh);
int frames = std::min(z, maxFrames);
for (int i = 0; i < frames; i++) {
AppendDownscaledFrame(all + (size_t)i * w * h * 4, w, h, dw, dh, out.frames);
float d = (delays && delays[i] > 0) ? delays[i] / 1000.0f : 0.1f;
out.delaysSec.push_back(std::max(0.02f, d));
}
out.w = dw; out.h = dh;
stbi_image_free(all);
if (delays) stbi_image_free(delays);
return true;
}
if (all) stbi_image_free(all);
if (delays) stbi_image_free(delays);
// fall through to a plain decode if the GIF path failed
}
// ---- Everything else: a single still frame (PNG/JPEG/BMP/TGA/... and APNG's default image) ----
{
int w=0,h=0; unsigned char* px = DecodeImageRGBA(data, len, &w, &h);
if (!px) return false;
int dw,dh; FitDims(w, h, kStillMax, &dw, &dh);
AppendDownscaledFrame(px, w, h, dw, dh, out.frames);
out.delaysSec.push_back(0.0f);
out.w = dw; out.h = dh;
free(px);
return true;
}
}
// Lightweight animation probe (no full decode): animated WebP via WebPGetFeatures.has_animation, or a
// GIF with >1 image-descriptor block. Walks the GIF block structure only until it finds a 2nd frame.
static bool IsAnimatedData(const unsigned char* data, size_t len)
{
if (IsWebP(data, len)) {
WebPBitstreamFeatures f;
if (WebPGetFeatures(data, len, &f) == VP8_STATUS_OK) return f.has_animation != 0;
return false;
}
if (len >= 13 && memcmp(data, "GIF8", 4) == 0) {
size_t p = 6; // after "GIF8?a"
unsigned char packed = data[p + 4]; // Logical Screen Descriptor packed byte
p += 7;
if (packed & 0x80) p += (size_t)(2 << (packed & 7)) * 3; // skip global color table
int frames = 0;
while (p < len) {
unsigned char b = data[p++];
if (b == 0x2C) { // image descriptor = one frame
if (++frames > 1) return true;
if (p + 9 > len) break;
unsigned char ip = data[p + 8];
p += 9;
if (ip & 0x80) p += (size_t)(2 << (ip & 7)) * 3; // skip local color table
if (p >= len) break;
++p; // LZW min code size
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // image sub-blocks
} else if (b == 0x21) { // extension
if (p >= len) break;
++p; // label
while (p < len) { unsigned char s = data[p++]; if (!s) break; p += s; } // sub-blocks
} else { // trailer (0x3B) or unexpected
break;
}
}
return frames > 1;
}
return false;
}
bool IsAnimatedImageFile(const char* path)
{
std::vector<unsigned char> file;
if (!ReadFileToBuffer(path, file)) return false;
return IsAnimatedData(file.data(), file.size());
}
void DestroyTexture(ImTextureID texID) void DestroyTexture(ImTextureID texID)
{ {
if (!texID) return; if (!texID) return;

View File

@@ -6,9 +6,37 @@
#include "imgui.h" #include "imgui.h"
#include <vector>
namespace dragonx { namespace dragonx {
namespace util { namespace util {
/**
* @brief Downscaled RGBA frame sequence for an image (1 frame for stills, N for animations).
*/
struct AnimFrames {
std::vector<std::vector<unsigned char>> frames; ///< each is w*h*4 RGBA (already downscaled)
std::vector<float> delaysSec; ///< per-frame duration in seconds (parallel to frames)
int w = 0;
int h = 0;
};
/**
* @brief Decode an image into 1+ downscaled RGBA frames plus per-frame durations.
*
* Animated GIF (via stb) and animated WebP (via libwebp WebPAnimDecoder) return every frame (capped at
* maxFrames); static formats — and APNG, which stb reads as a single image — return one frame. Frames
* are box-downscaled internally (smaller for animations to bound VRAM). The caller uploads each frame
* with CreateRawTexture. Returns false on decode failure.
*/
bool LoadAnimatedRGBA(const char* path, int maxFrames, AnimFrames& out);
/**
* @brief Cheaply report whether an image file is an animation (multi-frame GIF or animated WebP),
* WITHOUT a full decode — a header/structure probe. False for stills + non-animatable formats.
*/
bool IsAnimatedImageFile(const char* path);
/** /**
* @brief Load a PNG/JPG/BMP image from disk into an OpenGL or DX11 texture. * @brief Load a PNG/JPG/BMP image from disk into an OpenGL or DX11 texture.
* @param path File path (relative to working directory or absolute) * @param path File path (relative to working directory or absolute)