Просмотр исходного кода

app/emoji: improve algo for placing emojis inside emoji_picker and add a margin too

darkfi 11 часов назад
Родитель
Сommit
c479932a26

+ 16 - 10
bin/app/Makefile

@@ -29,7 +29,7 @@ DEBUG_FEATURES = --features=schema-app,enable-filelog,enable-plugin-darkirc,enab
 
 #DEV_FEATURES = --features=enable-filelog,enable-netdebug,emulate-android
 #DEV_FEATURES = --features=enable-filelog,enable-netdebug,enable-plugins
-DEV_FEATURES = --features=schema-app,enable-filelog,enable-netdebug,enable-plugin-darkirc
+DEV_FEATURES = --features=schema-app,enable-filelog,enable-netdebug
 
 default: build-release
 	./darkfi-app
@@ -78,23 +78,29 @@ build-debug: $(SRC) $(PROOFS_BIN) fonts assets/forest_1920x1080.ivf
 
 # Download font data
 
-fonts: ibm-plex-mono-regular.otf NotoColorEmoji.ttf
+fonts: data/font/ibm-plex-mono-regular.otf data/font/NotoColorEmoji.ttf
 
-ibm-plex-mono-regular.otf:
-	wget -c https://dark.fi/assets/ibm-plex-mono-regular.otf
+data/font/ibm-plex-mono-regular.otf:
+	mkdir -p data/font/
+	wget -P data/font/ -c https://dark.fi/assets/ibm-plex-mono-regular.otf
 
-NotoColorEmoji.ttf:
-	wget -c https://dark.fi/assets/NotoColorEmoji.ttf
+data/font/NotoColorEmoji.ttf:
+	mkdir -p data/font/
+	wget -P data/font/ -c https://dark.fi/assets/NotoColorEmoji.ttf
 
 # App data
 
-assets/forest_1920x1080.ivf:
+data/forest_1920x1080.ivf:
+	wget -P data/ -c https://dark.fi/assets/forest_1920x1080.ivf
+assets/forest_1920x1080.ivf: data/forest_1920x1080.ivf
 	rm -f assets/forest_720x1280.mp4
-	wget -P assets/ -c https://dark.fi/assets/forest_1920x1080.ivf
+	cp data/forest_1920x1080.ivf assets/
 
-assets/forest_720x1280.mp4:
+data/forest_720x1280.mp4:
+	wget -P data/ -c https://dark.fi/assets/forest_720x1280.mp4
+assets/forest_720x1280.mp4: data/forest_720x1280.mp4
 	rm -f assets/forest_1920x1080.ivf
-	wget -P assets/ -c https://dark.fi/assets/forest_720x1280.mp4
+	cp data/forest_720x1280.mp4 assets/
 
 # zkas circuits (needed for darkirc plugin crate)
 

+ 1 - 1
bin/app/src/app/mod.rs

@@ -119,7 +119,7 @@ impl App {
         let window_scale = {
             let screen_density = miniquad::window::dpi_scale();
             i!("Android screen density: {screen_density}");
-            screen_density / 2.8
+            screen_density / 3.5
         };
         #[cfg(not(target_os = "android"))]
         let window_scale = 1.;

+ 7 - 0
bin/app/src/app/node.rs

@@ -720,6 +720,13 @@ pub fn create_emoji_picker(name: &str) -> SceneNode {
     prop.set_range_f32(0., f32::MAX);
     node.add_property(prop).unwrap();
 
+    let mut prop = Property::new("emoji_margin", PropertyType::Float32, PropertySubType::Pixel);
+    prop.set_array_len(2);
+    prop.set_ui_text("Emoji Margin", "Horizontal and vertical padding around each emoji icon");
+    prop.set_range_f32(0., f32::MAX);
+    prop.set_defaults_f32(vec![10., 10.]).unwrap();
+    node.add_property(prop).unwrap();
+
     node.add_signal("emoji_select", "Emoji selected", vec![("text", "Text", CallArgType::Str)])
         .unwrap();
 

+ 4 - 1
bin/app/src/app/schema/chat.rs

@@ -168,7 +168,7 @@ mod ui_consts {
     pub const BACK_SEP_W: f32 = 0.5;
 }
 
-use super::EMOJI_PICKER_ICON_SIZE;
+use super::{EMOJI_PICKER_ICON_MARGIN_X, EMOJI_PICKER_ICON_MARGIN_Y, EMOJI_PICKER_ICON_SIZE};
 use ui_consts::*;
 
 fn android_keyboard_height() -> f32 {
@@ -434,6 +434,9 @@ pub async fn make(
     let emoji_h_prop = PropertyFloat32::wrap(&node, Role::App, "dynamic_h", 0).unwrap();
     //node.set_property_f32(atom, Role::App, "font_size", FONTSIZE).unwrap();
     node.set_property_f32(atom, Role::App, "emoji_size", EMOJI_PICKER_ICON_SIZE).unwrap();
+    let prop = node.get_property("emoji_margin").unwrap();
+    prop.set_f32(atom, Role::App, 0, EMOJI_PICKER_ICON_MARGIN_X).unwrap();
+    prop.set_f32(atom, Role::App, 1, EMOJI_PICKER_ICON_MARGIN_Y).unwrap();
     node.set_property_u32(atom, Role::App, "z_index", 2).unwrap();
     let node =
         node.setup(|me| EmojiPicker::new(me, renderer.clone(), emoji_meshes, redraw.clone())).await;

+ 5 - 1
bin/app/src/app/schema/mod.rs

@@ -58,6 +58,8 @@ mod android_ui_consts {
     pub const SETTINGS_ICON_SIZE: f32 = 140.;
     pub const NETLOGO_SCALE: f32 = 50.;
     pub const EMOJI_PICKER_ICON_SIZE: f32 = 120.;
+    pub const EMOJI_PICKER_ICON_MARGIN_X: f32 = 20.;
+    pub const EMOJI_PICKER_ICON_MARGIN_Y: f32 = 20.;
 
     pub const NETSTAT_OVERLAY_MARGIN: f32 = 20.;
     pub const NETSTAT_OVERLAY_BTN_W: f32 = 200.;
@@ -128,7 +130,9 @@ mod ui_consts {
     pub const NETSTATUS_ICON_SIZE: f32 = 60.;
     pub const SETTINGS_ICON_SIZE: f32 = 60.;
     pub const NETLOGO_SCALE: f32 = 25.;
-    pub const EMOJI_PICKER_ICON_SIZE: f32 = 50.;
+    pub const EMOJI_PICKER_ICON_SIZE: f32 = 40.;
+    pub const EMOJI_PICKER_ICON_MARGIN_X: f32 = 8.;
+    pub const EMOJI_PICKER_ICON_MARGIN_Y: f32 = 8.;
 
     pub const NETSTAT_OVERLAY_MARGIN: f32 = 10.;
     pub const NETSTAT_OVERLAY_BTN_W: f32 = 100.;

+ 47 - 0
bin/app/src/gfx/linalg.rs

@@ -173,6 +173,44 @@ pub struct Rectangle {
     pub h: f32,
 }
 
+/// Accumulates the union of many rectangles. Starts empty; `get()` returns
+/// `None` until something has been added.
+#[derive(Clone, Copy)]
+pub struct RectangleUnion {
+    bounds: Option<Rectangle>,
+}
+
+impl RectangleUnion {
+    pub fn new() -> Self {
+        Self { bounds: None }
+    }
+
+    pub fn add(&mut self, rect: Rectangle) {
+        self.bounds = Some(match self.bounds {
+            Some(bounds) => bounds.union(&rect),
+            None => rect,
+        });
+    }
+
+    /// Fold another union into this one
+    pub fn join(&mut self, other: Self) {
+        if let Some(rect) = other.bounds {
+            self.add(rect);
+        }
+    }
+
+    /// The union of everything added so far, or `None` if empty
+    pub fn get(&self) -> Option<Rectangle> {
+        self.bounds
+    }
+}
+
+impl Default for RectangleUnion {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
 impl Rectangle {
     pub const fn new(x: f32, y: f32, w: f32, h: f32) -> Self {
         Self { x, y, w, h }
@@ -215,6 +253,15 @@ impl Rectangle {
         Some(clipped)
     }
 
+    /// Smallest rectangle covering both `self` and `other`
+    pub fn union(&self, other: &Self) -> Self {
+        let x1 = self.x.min(other.x);
+        let y1 = self.y.min(other.y);
+        let x2 = (self.x + self.w).max(other.x + other.w);
+        let y2 = (self.y + self.h).max(other.y + other.h);
+        Self::new(x1, y1, x2 - x1, y2 - y1)
+    }
+
     pub fn with_zero_pos(&self) -> Self {
         Self::new(0., 0., self.w, self.h)
     }

+ 1 - 1
bin/app/src/gfx/mod.rs

@@ -58,7 +58,7 @@ mod favico;
 mod prune;
 use prune::PruneMethodHeap;
 mod linalg;
-pub use linalg::{Dimension, Point, Rectangle, Segment, Vector};
+pub use linalg::{Dimension, Point, Rectangle, RectangleUnion, Segment, Vector};
 mod shader;
 
 use crate::{

+ 8 - 1
bin/app/src/text/mod.rs

@@ -31,7 +31,10 @@ pub use editor::Editor;
 mod render;
 #[cfg(not(target_os = "android"))]
 pub use render::render_raw_layout;
-pub use render::{render_backgrounds, render_layout, render_layout_with_opts, DebugRenderOptions};
+pub use render::{
+    render_backgrounds, render_layout, render_layout_with_bounds, render_layout_with_opts,
+    DebugRenderOptions,
+};
 
 pub static GLOBAL_FONT_CTX: LazyLock<parley::FontContext> = LazyLock::new(|| {
     let mut font_ctx = parley::FontContext {
@@ -45,6 +48,9 @@ pub static GLOBAL_FONT_CTX: LazyLock<parley::FontContext> = LazyLock::new(|| {
     let font_data = include_bytes!("../../NotoColorEmoji.ttf") as &[u8];
     font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None);
 
+    //let font_data = include_bytes!("../../darkfi-custom-emoji.ttf") as &[u8];
+    //font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None);
+
     font_ctx
 });
 
@@ -56,6 +62,7 @@ thread_local! {
 const FONT_STACK: &[parley::FontFamilyName<'_>] = &[
     parley::FontFamilyName::named("IBM Plex Mono"),
     parley::FontFamilyName::named("Noto Color Emoji"),
+    //parley::FontFamilyName::named("DarkIRC Emoji"),
 ];
 
 /// A parley layout paired with the window scale it was built with.

+ 25 - 9
bin/app/src/text/render.rs

@@ -17,7 +17,7 @@
  */
 
 use crate::{
-    gfx::{DebugTag, DrawInstruction, DrawMesh, Point, Rectangle, Renderer},
+    gfx::{DebugTag, DrawInstruction, DrawMesh, Point, Rectangle, RectangleUnion, Renderer},
     mesh::{Color, MeshBuilder, COLOR_WHITE},
 };
 
@@ -69,7 +69,7 @@ pub fn render_raw_layout(
     renderer: &Renderer,
     tag: DebugTag,
 ) -> Vec<DrawInstruction> {
-    render_raw_layout_with_opts(layout, scale, DebugRenderOptions::OFF, renderer, tag)
+    render_raw_layout_impl(layout, scale, DebugRenderOptions::OFF, renderer, tag).0
 }
 
 /// Draw a filled (and optionally outlined) background box behind every glyph run
@@ -135,7 +135,18 @@ pub fn render_layout_with_opts(
     renderer: &Renderer,
     tag: DebugTag,
 ) -> Vec<DrawInstruction> {
-    render_raw_layout_with_opts(layout, layout.scale(), opts, renderer, tag)
+    render_raw_layout_impl(layout, layout.scale(), opts, renderer, tag).0
+}
+
+/// Render a layout and also return the union of the glyph ink bounds in
+/// virtual units, relative to the layout origin. Used by callers that
+/// position the mesh by its ink, e.g. centering emoji icons in grid cells.
+pub fn render_layout_with_bounds(
+    layout: &TextLayout,
+    renderer: &Renderer,
+    tag: DebugTag,
+) -> (Vec<DrawInstruction>, Rectangle) {
+    render_raw_layout_impl(layout, layout.scale(), DebugRenderOptions::OFF, renderer, tag)
 }
 
 /// Layout coordinates are physical (scale is baked in by parley) while
@@ -143,13 +154,13 @@ pub fn render_layout_with_opts(
 /// renderer's `SetScale`. So every emitted coordinate is divided by
 /// `scale` here. Glyphs are still rasterized at physical resolution so
 /// the final on-screen texel mapping stays crisp.
-fn render_raw_layout_with_opts(
+fn render_raw_layout_impl(
     layout: &parley::Layout<Color>,
     scale: f32,
     opts: DebugRenderOptions,
     renderer: &Renderer,
     tag: DebugTag,
-) -> Vec<DrawInstruction> {
+) -> (Vec<DrawInstruction>, Rectangle) {
     // First pass to create atlas
     let mut scale_ctx = swash::scale::ScaleContext::new();
     let mut atlas = Atlas::new(renderer, tag);
@@ -172,12 +183,14 @@ fn render_raw_layout_with_opts(
     // Second pass to draw glyphs
     let mut run_idx = 0;
     let mut instrs = vec![];
+    let mut bounds = RectangleUnion::new();
     for line in layout.lines() {
         for item in line.items() {
             match item {
                 parley::PositionedLayoutItem::GlyphRun(glyph_run) => {
-                    let mesh =
+                    let (mesh, run_bounds) =
                         render_glyph_run(&glyph_run, run_idx, opts, &atlas, scale, renderer, tag);
+                    bounds.join(run_bounds);
                     instrs.push(DrawInstruction::Draw(mesh));
                     run_idx += 1;
                 }
@@ -185,7 +198,7 @@ fn render_raw_layout_with_opts(
             }
         }
     }
-    instrs
+    (instrs, bounds.get().unwrap_or(Rectangle::zero()))
 }
 
 fn push_glyphs(
@@ -220,7 +233,7 @@ fn render_glyph_run(
     scale: f32,
     renderer: &Renderer,
     tag: DebugTag,
-) -> DrawMesh {
+) -> (DrawMesh, RectangleUnion) {
     let mut run_x = glyph_run.offset();
     let run_y = glyph_run.baseline();
     let style = glyph_run.style();
@@ -228,6 +241,7 @@ fn render_glyph_run(
     //trace!(target: "text::render", "render_glyph_run run_idx={run_idx} baseline={run_y}");
 
     let mut mesh = MeshBuilder::new(tag);
+    let mut bounds = RectangleUnion::new();
 
     if let Some(underline) = &style.underline {
         render_underline(underline, glyph_run, scale, &mut mesh);
@@ -247,6 +261,8 @@ fn render_glyph_run(
             glyph_inf.place.height as f32 / scale,
         );
 
+        bounds.add(glyph_rect);
+
         if opts.has(DebugRenderOptions::GLYPH) {
             mesh.draw_outline(&glyph_rect, [0., 1., 0., 0.7], 1.);
         }
@@ -262,7 +278,7 @@ fn render_glyph_run(
         mesh.draw_filled_box(&rect, [0., 0., 1., 0.7]);
     }
 
-    mesh.alloc(renderer).draw_with_textures(vec![atlas.texture.clone()])
+    (mesh.alloc(renderer).draw_with_textures(vec![atlas.texture.clone()]), bounds)
 }
 
 fn render_underline(

+ 49 - 0
bin/app/src/ui/emoji_picker/default.rs

@@ -17,6 +17,55 @@
  */
 
 pub static DEFAULT_EMOJI_LIST: &[&str] = &[
+    //"\u{f0000}",
+    //"\u{f0001}",
+    //"\u{f0002}",
+    //"\u{f0003}",
+    //"\u{f0004}",
+    //"\u{f0005}",
+    //"\u{f0006}",
+    //"\u{f0007}",
+    //"\u{f0008}",
+    //"\u{f0009}",
+    //"\u{f000a}",
+    //"\u{f000b}",
+    //"\u{f000c}",
+    //"\u{f000d}",
+    //"\u{f000e}",
+    //"\u{f000f}",
+    //"\u{f0010}",
+    //"\u{f0011}",
+    //"\u{f0012}",
+    //"\u{f0013}",
+    //"\u{f0014}",
+    //"\u{f0015}",
+    //"\u{f0016}",
+    //"\u{f0017}",
+    //"\u{f0018}",
+    //"\u{f0019}",
+    //"\u{f001a}",
+    //"\u{f001b}",
+    //"\u{f001c}",
+    //"\u{f001d}",
+    //"\u{f001e}",
+    //"\u{f001f}",
+    //"\u{f0020}",
+    //"\u{f0021}",
+    //"\u{f0022}",
+    //"\u{f0023}",
+    //"\u{f0024}",
+    //"\u{f0025}",
+    //"\u{f0026}",
+    //"\u{f0027}",
+    //"\u{f0028}",
+    //"\u{f0029}",
+    //"\u{f002a}",
+    //"\u{f002b}",
+    //"\u{f002c}",
+    //"\u{f002d}",
+    //"\u{f002e}",
+    //"\u{f002f}",
+    //"\u{f0030}",
     "😂",
     "❤️",
     "😁",

+ 15 - 8
bin/app/src/ui/emoji_picker/emoji.rs

@@ -20,7 +20,7 @@ use parking_lot::Mutex as SyncMutex;
 use std::sync::Arc;
 
 use crate::{
-    gfx::{gfxtag, DrawInstruction, DrawMesh, EpochTracker, Renderer},
+    gfx::{gfxtag, DrawInstruction, DrawMesh, EpochTracker, Rectangle, Renderer},
     mesh::COLOR_WHITE,
     text,
 };
@@ -33,7 +33,7 @@ pub struct EmojiMeshes {
     renderer: Renderer,
     emoji_size: f32,
     epoch_tracker: EpochTracker,
-    meshes: Vec<DrawMesh>,
+    meshes: Vec<(DrawMesh, Rectangle)>,
 }
 
 impl EmojiMeshes {
@@ -42,11 +42,16 @@ impl EmojiMeshes {
         Arc::new(SyncMutex::new(Self { renderer, emoji_size, epoch_tracker, meshes: vec![] }))
     }
 
+    pub fn set_size(&mut self, emoji_size: f32) {
+        self.emoji_size = emoji_size;
+        self.meshes.clear();
+    }
+
     pub fn clear(&mut self) {
         self.meshes.clear();
     }
 
-    pub fn get(&mut self, i: usize) -> DrawMesh {
+    pub fn get(&mut self, i: usize) -> (DrawMesh, Rectangle) {
         assert!(i < DEFAULT_EMOJI_LIST.len());
 
         // Meshes hold epoch-scoped buffers; after a UI restart they are
@@ -69,13 +74,16 @@ impl EmojiMeshes {
         self.meshes[i].clone()
     }
 
-    /// Make mesh for this emoji centered at (0, 0)
-    fn gen_emoji_mesh(&self, emoji: &str) -> DrawMesh {
+    /// Make the mesh for this emoji, plus its ink bounds relative to the
+    /// mesh origin (the text baseline), so callers can center the visible
+    /// glyph inside a cell.
+    fn gen_emoji_mesh(&self, emoji: &str) -> (DrawMesh, Rectangle) {
         //d!("rendering emoji: '{emoji}'");
         // The params here don't actually matter since we're talking about BMP fixed sizes
         let layout = text::make_layout(emoji, COLOR_WHITE, self.emoji_size, 1., 1., None, &[]);
 
-        let instrs = text::render_layout(&layout, &self.renderer, gfxtag!("emoji_mesh"));
+        let (instrs, bounds) =
+            text::render_layout_with_bounds(&layout, &self.renderer, gfxtag!("emoji_mesh"));
 
         // Extract the mesh from the draw instructions
         // For a single emoji, we should get exactly one Draw instruction with a mesh
@@ -84,7 +92,6 @@ impl EmojiMeshes {
             _ => panic!("Expected Draw instruction for emoji"),
         };
 
-        // For now, just return the original mesh since scaling is complex with textures
-        mesh
+        (mesh, bounds)
     }
 }

+ 49 - 18
bin/app/src/ui/emoji_picker/mod.rs

@@ -27,8 +27,11 @@ use std::sync::{
 };
 
 use crate::{
-    gfx::{gfxtag, DrawCall, DrawInstruction, EpochCache, Point, Rectangle, RenderApi, Renderer},
-    prop::{PropertyAtomicGuard, PropertyFloat32, PropertyRect, PropertyUint32, Role},
+    gfx::{
+        gfxtag, Dimension, DrawCall, DrawInstruction, EpochCache, Point, Rectangle, RenderApi,
+        Renderer,
+    },
+    prop::{PropertyAtomicGuard, PropertyFloat32, PropertyPtr, PropertyRect, PropertyUint32, Role},
     scene::{Pimpl, SceneNodeWeak},
     ExecutorPtr,
 };
@@ -65,6 +68,8 @@ pub struct EmojiPicker {
     priority: PropertyUint32,
     scroll: PropertyFloat32,
     emoji_size: PropertyFloat32,
+    /// `[x, y]` padding around each emoji icon
+    emoji_margin: PropertyPtr,
     mouse_scroll_speed: PropertyFloat32,
 
     redraw: RedrawTrigger,
@@ -90,6 +95,7 @@ impl EmojiPicker {
         let priority = PropertyUint32::wrap(node_ref, Role::Internal, "priority", 0).unwrap();
         let scroll = PropertyFloat32::wrap(node_ref, Role::Internal, "scroll", 0).unwrap();
         let emoji_size = PropertyFloat32::wrap(node_ref, Role::Internal, "emoji_size", 0).unwrap();
+        let emoji_margin = node_ref.get_property("emoji_margin").unwrap();
         let mouse_scroll_speed =
             PropertyFloat32::wrap(node_ref, Role::Internal, "mouse_scroll_speed", 0).unwrap();
 
@@ -108,6 +114,7 @@ impl EmojiPicker {
             priority,
             scroll,
             emoji_size,
+            emoji_margin,
             mouse_scroll_speed,
 
             redraw,
@@ -119,28 +126,36 @@ impl EmojiPicker {
         Pimpl::EmojiPicker(self_)
     }
 
+    /// Size of a grid cell, i.e. the emoji icon plus its surrounding margin
+    fn cell(&self) -> Dimension {
+        Dimension {
+            w: self.emoji_size.get() + self.emoji_margin.get_f32(0).unwrap(),
+            h: self.emoji_size.get() + self.emoji_margin.get_f32(1).unwrap(),
+        }
+    }
+
     fn emojis_per_line(&self) -> f32 {
-        let emoji_size = self.emoji_size.get();
+        let cell = self.cell();
         let rect_w = self.rect.get().w;
         //d!("rect_w = {rect_w}");
-        (rect_w / emoji_size).floor()
+        (rect_w / cell.w).floor()
     }
     fn calc_off_x(&self) -> f32 {
-        let emoji_size = self.emoji_size.get();
+        let cell = self.cell();
         let rect_w = self.rect.get().w;
         let n = self.emojis_per_line();
-        let off_x = (rect_w - emoji_size) / (n - 1.);
+        let off_x = (rect_w - cell.w) / (n - 1.);
         off_x
     }
 
     fn max_scroll(&self) -> f32 {
         let emojis_len = DEFAULT_EMOJI_LIST.len() as f32;
-        let emoji_size = self.emoji_size.get();
+        let cell = self.cell();
         let cols = self.emojis_per_line();
         let rows = (emojis_len / cols).ceil();
 
         let rect_h = self.rect.get().h;
-        let height = rows * emoji_size;
+        let height = rows * cell.h;
         if height < rect_h {
             return 0.
         }
@@ -149,18 +164,18 @@ impl EmojiPicker {
 
     async fn click_emoji(&self, pos: Point) {
         let n_cols = self.emojis_per_line();
-        let emoji_size = self.emoji_size.get();
+        let cell = self.cell();
         let scroll = self.scroll.get();
 
         // Emojis have spacing along the x axis.
-        // If the screen width is 2000, and emoji_size is 30, then that's 66 emojis.
-        // But that's 66.66px per emoji.
+        // If the screen width is 2000, and the cell is 30, then that's 66 emojis.
+        // But that's 66.66px per cell.
         let real_width = self.rect.get().w / n_cols;
         //d!("click_emoji({pos:?})");
         let col = (pos.x / real_width).floor();
 
         let y = pos.y + scroll;
-        let row = (y / emoji_size).floor();
+        let row = (y / cell.h).floor();
         //d!("emoji_size = {emoji_size}, col = {col}, row = {row}");
 
         //d!("idx = col + row * n_cols = {col} + {row} * {n_cols}");
@@ -218,26 +233,32 @@ impl EmojiPicker {
             let mut instrs = vec![DrawInstruction::ApplyView(rect)];
 
             let off_x = self.calc_off_x();
-            let emoji_size = self.emoji_size.get();
+            let cell = self.cell();
 
             let mut x = 0.;
             let mut y = -self.scroll.get();
             for i in 0..DEFAULT_EMOJI_LIST.len() {
-                let pos = Point::new(x, y);
-                let mesh = self.emoji_meshes.lock().get(i);
+                let (mesh, ink) = self.emoji_meshes.lock().get(i);
+                // Center the emoji's ink inside its cell so the margin pads
+                // it evenly on all sides. The ink origin sits above the
+                // mesh origin (text baseline), hence the -ink.x/-ink.y.
+                let pos = Point::new(
+                    x + (cell.w - ink.w) / 2. - ink.x,
+                    y + (cell.h - ink.h) / 2. - ink.y,
+                );
                 instrs.extend_from_slice(&[
                     DrawInstruction::SetPos(pos),
                     DrawInstruction::Draw(mesh),
                 ]);
 
                 x += off_x;
-                if x > rect.w {
+                if x + cell.w > rect.w {
                     x = 0.;
-                    y += emoji_size;
+                    y += cell.h;
                     //d!("Line break after idx={i}");
                 }
 
-                if y > rect.h + emoji_size {
+                if y > rect.h + cell.h {
                     break
                 }
             }
@@ -275,6 +296,16 @@ impl UIObject for EmojiPicker {
             self_.draw_cache.clear();
             self_.redraw.trigger();
         });
+        on_modify.when_change_external(self.emoji_size.prop(), |self_, _| async move {
+            let emoji_size = self_.emoji_size.get();
+            self_.emoji_meshes.lock().set_size(emoji_size);
+            self_.draw_cache.clear();
+            self_.redraw.trigger();
+        });
+        on_modify.when_change_external(self.emoji_margin.clone(), |self_, _| async move {
+            self_.draw_cache.clear();
+            self_.redraw.trigger();
+        });
 
         *self.tasks.lock() = on_modify.tasks;
     }