Build 51: fit the balance, center the send flow, fix the success logo

On-device review surfaced UI overflow/centering issues at phone width:

- Balance hero shrinks to fit: amount_text_centered_ink measures the text and
  scales the font down when it would run off the edge (e.g. 0.47520721ツ).
- Send flow sits closer to the screen edges (smaller side gutters) and is
  properly centered: the Review/Confirm hero now uses a centered, wrapping
  layout instead of a fragile manual offset that clipped a long npub and
  threw off the centering. info_row values truncate instead of overflowing.
- Recipient picker: the suggested row shows the full npub in the grey subtitle
  (truncated to fit) instead of repeating the same shortened npub as the title.
- Success screen uses the goblin head (goblin-logo2), not the mascot mask.

Verified live at 394px across balance, recipient, review, confirm and the
success screen.
This commit is contained in:
2ro
2026-06-13 15:55:02 -04:00
parent c05074faac
commit ba5ddf07ac
3 changed files with 68 additions and 38 deletions
+10
View File
@@ -236,6 +236,16 @@ pub fn short_npub(hex: &str) -> String {
format!("{}", &hex[..8.min(hex.len())])
}
/// Full bech32 npub (no truncation), for the recipient picker's grey subtitle
/// where showing the complete key is more useful than repeating the truncation.
pub fn full_npub(hex: &str) -> String {
use nostr_sdk::{PublicKey, ToBech32};
PublicKey::from_hex(hex)
.ok()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_else(|| hex.to_string())
}
/// Build the activity feed for a wallet, newest first.
pub fn activity_items(wallet: &Wallet) -> Vec<ActivityItem> {
let data = match wallet.get_data() {
+9 -14
View File
@@ -206,8 +206,8 @@ impl SendFlow {
t.bg
},
inner_margin: egui::Margin {
left: (View::far_left_inset_margin(ui) + 20.0) as i8,
right: (View::get_right_inset() + 20.0) as i8,
left: (View::far_left_inset_margin(ui) + 12.0) as i8,
right: (View::get_right_inset() + 12.0) as i8,
top: (View::get_top_inset() + 12.0) as i8,
bottom: (View::get_bottom_inset() + 12.0) as i8,
},
@@ -383,7 +383,7 @@ impl SendFlow {
if w::activity_row(
ui,
&name,
&short_npub(&npub),
&data::full_npub(&npub),
hue,
"",
false,
@@ -936,16 +936,11 @@ impl SendFlow {
} else {
format!("You're sending {}", recipient.name)
};
let galley = ui.painter().layout_no_wrap(
label.clone(),
FontId::new(14.0, fonts::regular()),
t.text_dim,
);
let row_w = 36.0 + 8.0 + galley.size().x;
ui.horizontal(|ui| {
ui.add_space(((ui.available_width() - row_w) / 2.0).max(0.0));
w::avatar_any(ui, &recipient.name, 36.0, recipient.hue, hero_tex.as_ref());
ui.add_space(8.0);
// Centered avatar + caption. A long counterparty (a bare npub) wraps
// and stays centered instead of overflowing the card.
ui.vertical_centered(|ui| {
w::avatar_any(ui, &recipient.name, 40.0, recipient.hue, hero_tex.as_ref());
ui.add_space(6.0);
ui.label(
RichText::new(label)
.font(FontId::new(14.0, fonts::regular()))
@@ -1157,7 +1152,7 @@ impl SendFlow {
let (rect, _) = ui.allocate_exact_size(Vec2::splat(120.0), Sense::hover());
ui.painter()
.circle_filled(rect.center(), 60.0, t.accent_ink);
let img = egui::Image::new(egui::include_image!("../../../../img/goblin-mask-128.png"))
let img = egui::Image::new(egui::include_image!("../../../../img/goblin-logo2.svg"))
.tint(t.accent)
.fit_to_exact_size(Vec2::splat(72.0));
img.paint_at(
+49 -24
View File
@@ -107,15 +107,27 @@ pub fn amount_text_centered_ink(
num_ink: Color32,
mark_ink: Color32,
) {
let num =
ui.painter()
.layout_no_wrap(value.to_string(), FontId::new(size, fonts::bold()), num_ink);
let mark = ui.painter().layout_no_wrap(
TSU.to_string(),
FontId::new(size * 0.4, fonts::medium()),
mark_ink,
);
let total = num.size().x + 1.0 + mark.size().x;
let avail = ui.available_width();
let measure = |ui: &Ui, sz: f32| -> f32 {
let num =
ui.painter()
.layout_no_wrap(value.to_string(), FontId::new(sz, fonts::bold()), num_ink);
let mark = ui.painter().layout_no_wrap(
TSU.to_string(),
FontId::new(sz * 0.4, fonts::medium()),
mark_ink,
);
num.size().x + 1.0 + mark.size().x
};
// Shrink to fit: a long balance (e.g. 0.46520721ツ) must not run off the
// edge. Glyph width is ~linear in font size, so scale down to the available
// width with a small margin and a sane floor.
let mut size = size;
let total0 = measure(ui, size);
if total0 > avail && total0 > 1.0 {
size = (size * (avail / total0) * 0.97).clamp(14.0, size);
}
let total = measure(ui, size);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add_space(((ui.available_width() - total) / 2.0).max(0.0));
@@ -426,15 +438,23 @@ pub fn activity_row(
ui.add_space(12.0);
ui.vertical(|ui| {
ui.add_space(2.0);
ui.label(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
ui.add(
egui::Label::new(
RichText::new(title)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
ui.label(
RichText::new(subtitle)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
// Single-line, truncated: keeps the fixed-height row tidy even when
// the subtitle is a long value (e.g. a full npub in the picker).
ui.add(
egui::Label::new(
RichText::new(subtitle)
.font(FontId::new(13.0, fonts::regular()))
.color(t.text_dim),
)
.truncate(),
);
});
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
@@ -481,10 +501,15 @@ pub fn info_row(ui: &mut Ui, label: &str, value: &str) {
.color(t.text_dim),
);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.label(
RichText::new(value)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
// Truncate so a long value (e.g. "Encrypted nostr DM over Nym") never
// runs past the edge or collides with the label on a narrow screen.
ui.add(
egui::Label::new(
RichText::new(value)
.font(FontId::new(15.0, fonts::semibold()))
.color(t.text),
)
.truncate(),
);
});
});
@@ -677,9 +702,9 @@ pub fn fill_bg(ui: &Ui, color: Color32) {
/// would start the row a single line tall, so a `ScrollArea` inside would
/// clip everything below the first widget.
pub fn centered_column<R>(ui: &mut Ui, width: f32, add: impl FnOnce(&mut Ui) -> R) -> R {
// Always keep a side gutter so content never runs flush to the screen
// edge on phones (where `width` exceeds the available width).
const MIN_SIDE_PAD: f32 = 18.0;
// Keep a small side gutter so content sits close to the screen edges on
// phones (where `width` exceeds the available width) without running flush.
const MIN_SIDE_PAD: f32 = 8.0;
let avail = ui.available_width();
let w = width.min(avail - MIN_SIDE_PAD * 2.0).max(0.0);
let margin = ((avail - w) / 2.0).max(MIN_SIDE_PAD);