notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit ccc188c0aec93afbeb5686f4021bcfd9ad44d914
parent 77ac91e810b82ac05e74d9219861822d277359d2
Author: William Casarin <jb55@jb55.com>
Date:   Wed, 20 Aug 2025 15:26:31 -0700

chrome: greatly improve soft-keyboard visibility & layout handling

This reworks how we detect and respond to the on-screen keyboard so inputs
don’t get buried and the UI doesn’t “jump”.

- Add SoftKeyboardAnim + AnimState FSM for smooth IME open/close animation
- Centralize logic in keyboard_visibility() with clear edge states
- Animate keyboard height via animate_value_with_time instead of layer
  transforms
- Add ChromeOptions::KeyboardVisibility flag when focused input would be
  occluded
- Add SidebarOptions::Compact to collapse sidebar while typing
- Hide mobile toolbar when keyboard is open (columns app)
- Use .stick_to_bottom(true) in reply + profile editors; remove old spacer hack
- Virtual keyboard toggle moved to F1 in Debug builds
- Introduce SoftKeyboardContext::platform(ctx) helper
- Cleanup dead/commented code and wire up soft_kb_anim_state in Chrome

Result: inputs stay visible, open/close is smooth, and UI adjusts gracefully
when typing.

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
Mcrates/notedeck/src/context.rs | 15++++++++++++++-
Mcrates/notedeck_chrome/src/chrome.rs | 363+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Mcrates/notedeck_chrome/src/options.rs | 3+++
Mcrates/notedeck_columns/src/app.rs | 27+++++++++++++++++++++------
Mcrates/notedeck_columns/src/ui/note/reply.rs | 3++-
Mcrates/notedeck_columns/src/ui/profile/edit.rs | 1+
6 files changed, 332 insertions(+), 80 deletions(-)

diff --git a/crates/notedeck/src/context.rs b/crates/notedeck/src/context.rs @@ -40,6 +40,14 @@ pub enum SoftKeyboardContext { Platform { ppp: f32 }, } +impl SoftKeyboardContext { + pub fn platform(context: &egui::Context) -> Self { + Self::Platform { + ppp: context.pixels_per_point(), + } + } +} + impl<'a> AppContext<'a> { pub fn soft_keyboard_rect(&self, screen_rect: Rect, ctx: SoftKeyboardContext) -> Option<Rect> { match ctx { @@ -53,8 +61,13 @@ impl<'a> AppContext<'a> { #[cfg(target_os = "android")] { use android_activity::InsetType; + + // not sure why I need this, it seems to be consistently off by some amount of + // pixels ? + let fudge = 0.0; + let inset = self.android.get_window_insets(InsetType::Ime); - let height = inset.bottom as f32 / ppp; + let height = (inset.bottom as f32 / ppp) - fudge; skb_rect_from_screen_rect(screen_rect, height) } diff --git a/crates/notedeck_chrome/src/chrome.rs b/crates/notedeck_chrome/src/chrome.rs @@ -3,6 +3,7 @@ //use wasm_bindgen::prelude::*; use crate::app::NotedeckApp; use crate::ChromeOptions; +use bitflags::bitflags; use eframe::CreationContext; use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget}; use egui_extras::{Size, StripBuilder}; @@ -25,6 +26,10 @@ pub struct Chrome { active: i32, options: ChromeOptions, apps: Vec<NotedeckApp>, + + /// The state of the soft keyboard animation + soft_kb_anim_state: AnimState, + pub repaint_causes: HashMap<egui::RepaintCause, u64>, } @@ -37,6 +42,14 @@ pub enum ChromePanelAction { Profile(notedeck::enostr::Pubkey), } +bitflags! { + #[repr(transparent)] + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] + pub struct SidebarOptions: u8 { + const Compact = 1 << 0; + } +} + impl ChromePanelAction { fn columns_navigate(ctx: &mut AppContext, chrome: &mut Chrome, route: notedeck_columns::Route) { chrome.switch_to_columns(); @@ -174,6 +187,7 @@ impl Chrome { app_ctx: &mut AppContext, builder: StripBuilder, amt_open: f32, + amt_keyboard_open: f32, ) -> Option<ChromePanelAction> { let mut got_action: Option<ChromePanelAction> = None; @@ -204,9 +218,17 @@ impl Chrome { self.topdown_sidebar(ui, app_ctx.i18n); }) }); + vstrip.cell(|ui| { ui.with_layout(Layout::bottom_up(egui::Align::Center), |ui| { - if let Some(action) = bottomup_sidebar(self, app_ctx, ui) { + let options = if amt_keyboard_open > 0.0 { + SidebarOptions::Compact + } else { + SidebarOptions::default() + }; + if let Some(action) = + bottomup_sidebar(self, app_ctx, ui, options) + { got_action = Some(action); } }); @@ -250,7 +272,6 @@ impl Chrome { .animate_bool(open_id, self.options.contains(ChromeOptions::IsOpen)) * side_panel_width } - /// Show the side menu or bar, depending on if we're on a narrow /// or wide screen. /// @@ -259,52 +280,54 @@ impl Chrome { fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> { ui.spacing_mut().item_spacing.x = 0.0; - if ctx.args.options.contains(NotedeckOptions::Debug) - && ui.ctx().input(|i| i.key_pressed(egui::Key::Backtick)) - { - self.options.toggle(ChromeOptions::VirtualKeyboard); - } - let amt_open = self.amount_open(ui); - let r = self.panel(ctx, StripBuilder::new(ui), amt_open); + let skb_anim = + keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state); - let skb_ctx = if self.options.contains(ChromeOptions::VirtualKeyboard) { - SoftKeyboardContext::Virtual + let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard); + let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { + skb_anim.anim_height } else { - SoftKeyboardContext::Platform { - ppp: ui.ctx().pixels_per_point(), - } + 0.0 }; - // move screen up if virtual keyboard intersects with input_rect - let screen_rect = ui.ctx().screen_rect(); - let mut keyboard_height = 0.0; - if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) { - if let SoftKeyboardContext::Virtual = skb_ctx { - virtual_keyboard_ui(ui, vkb_rect); - } - if let Some(input_rect) = notedeck_ui::input_rect(ui) { - if input_rect.intersects(vkb_rect) { - tracing::debug!("screen:{screen_rect} skb:{vkb_rect}"); - keyboard_height = vkb_rect.height(); - } - } - } else { - // clear last input box position state - notedeck_ui::clear_input_rect(ui); - } + // if the soft keyboard is open, shrink the chrome contents + let mut action: Option<ChromePanelAction> = None; + // build a strip to carve out the soft keyboard inset + StripBuilder::new(ui) + .size(Size::remainder()) + .size(Size::exact(keyboard_height)) + .vertical(|mut strip| { + // the actual content, shifted up because of the soft keyboard + strip.cell(|ui| { + action = self.panel(ctx, StripBuilder::new(ui), amt_open, keyboard_height); + }); - let anim_height = - ui.ctx() - .animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1); - if anim_height > 0.0 { - ui.ctx().transform_layer_shapes( - ui.layer_id(), - egui::emath::TSTransform::from_translation(egui::Vec2::new(0.0, -anim_height)), - ); + // the filler space taken up by the soft keyboard + strip.cell(|ui| { + // keyboard-visibility virtual keyboard + if virtual_keyboard && keyboard_height > 0.0 { + tracing::debug!("got here"); + virtual_keyboard_ui(ui, ui.available_rect_before_wrap()) + } + }); + }); + + // hovering virtual keyboard + if virtual_keyboard { + if let Some(mut kb_rect) = skb_anim.skb_rect { + let kb_height = if self.options.contains(ChromeOptions::KeyboardVisibility) { + keyboard_height + } else { + 400.0 + }; + kb_rect.min.y = kb_rect.max.y - kb_height; + tracing::debug!("hovering virtual kb_height:{keyboard_height} kb_rect:{kb_rect}"); + virtual_keyboard_ui(ui, kb_rect) + } } - r + action } fn topdown_sidebar(&mut self, ui: &mut egui::Ui, i18n: &mut Localization) { @@ -652,38 +675,6 @@ fn pfp_button(ctx: &mut AppContext, ui: &mut egui::Ui) -> egui::Response { ui.put(helper.get_animation_rect(), &mut widget); helper.take_animation_response() - - // let selected = ctx.accounts.cache.selected(); - - // pfp_resp.context_menu(|ui| { - // for (pk, account) in &ctx.accounts.cache { - // let profile = ctx.ndb.get_profile_by_pubkey(&txn, pk).ok(); - // let is_selected = *pk == selected.key.pubkey; - // let has_nsec = account.key.secret_key.is_some(); - - // let profile_peview_view = { - // let max_size = egui::vec2(ui.available_width(), 77.0); - // let resp = ui.allocate_response(max_size, egui::Sense::click()); - // ui.allocate_new_ui(UiBuilder::new().max_rect(resp.rect), |ui| { - // ui.add( - // &mut ProfilePic::new(ctx.img_cache, get_profile_url(profile.as_ref())) - // .size(24.0), - // ) - // }) - // }; - - // // if let Some(op) = profile_peview_view { - // // return_op = Some(match op { - // // ProfilePreviewAction::SwitchTo => AccountsViewResponse::SelectAccount(*pk), - // // ProfilePreviewAction::RemoveAccount => AccountsViewResponse::RemoveAccount(*pk), - // // }); - // // } - // } - // // if ui.menu_image_button(image, add_contents).clicked() { - // // // ui.ctx().copy_text(url.to_owned()); - // // ui.close_menu(); - // // } - // }); } /// The section of the chrome sidebar that starts at the @@ -692,10 +683,23 @@ fn bottomup_sidebar( chrome: &mut Chrome, ctx: &mut AppContext, ui: &mut egui::Ui, + options: SidebarOptions, ) -> Option<ChromePanelAction> { ui.add_space(8.0); let pfp_resp = pfp_button(ctx, ui).on_hover_cursor(egui::CursorIcon::PointingHand); + + // we skip this whole function in compact mode + if options.contains(SidebarOptions::Compact) { + return if pfp_resp.clicked() { + Some(ChromePanelAction::Profile( + ctx.accounts.get_selected_account().key.pubkey, + )) + } else { + None + }; + } + let accounts_resp = accounts_button(ui).on_hover_cursor(egui::CursorIcon::PointingHand); let settings_resp = settings_button(ui).on_hover_cursor(egui::CursorIcon::PointingHand); @@ -956,3 +960,218 @@ fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) { .response }); } + +struct SoftKeyboardAnim { + skb_rect: Option<Rect>, + anim_height: f32, +} + +#[derive(Copy, Default, Clone, Eq, PartialEq, Debug)] +enum AnimState { + /// It finished opening + Opened, + + /// We started to open + StartOpen, + + /// We started to close + StartClose, + + /// We finished openning + FinishedOpen, + + /// We finished to close + FinishedClose, + + /// It finished closing + #[default] + Closed, + + /// We are animating towards open + Opening, + + /// We are animating towards close + Closing, +} + +impl SoftKeyboardAnim { + /// Advance the FSM based on current (anim_height) vs target (skb_rect.height()). + /// Start*/Finished* are one-tick edge states used for signaling. + fn changed(&self, state: AnimState) -> AnimState { + const EPS: f32 = 0.01; + + let target = self.skb_rect.map_or(0.0, |r| r.height()); + let current = self.anim_height; + + let done = (current - target).abs() <= EPS; + let going_up = target > current + EPS; + let going_down = current > target + EPS; + let target_is_closed = target <= EPS; + + match state { + // Resting states: emit a Start* edge only when a move is requested, + // and pick direction by the sign of (target - current). + AnimState::Opened => { + if done { + AnimState::Opened + } else if going_up { + AnimState::StartOpen + } else { + AnimState::StartClose + } + } + AnimState::Closed => { + if done { + AnimState::Closed + } else if going_up { + AnimState::StartOpen + } else { + AnimState::StartClose + } + } + + // Edge → flow + AnimState::StartOpen => AnimState::Opening, + AnimState::StartClose => AnimState::Closing, + + // Flow states: finish when we hit the target; if the target jumps across, + // emit the opposite Start* to signal a reversal. + AnimState::Opening => { + if done { + if target_is_closed { + AnimState::FinishedClose + } else { + AnimState::FinishedOpen + } + } else if going_down { + // target moved below current mid-flight → reversal + AnimState::StartClose + } else { + AnimState::Opening + } + } + AnimState::Closing => { + if done { + if target_is_closed { + AnimState::FinishedClose + } else { + AnimState::FinishedOpen + } + } else if going_up { + // target moved above current mid-flight → reversal + AnimState::StartOpen + } else { + AnimState::Closing + } + } + + // Finish edges collapse to the stable resting states on the next tick. + AnimState::FinishedOpen => AnimState::Opened, + AnimState::FinishedClose => AnimState::Closed, + } + } +} + +/// How "open" the softkeyboard is. This is an animated value +fn soft_keyboard_anim( + ui: &mut egui::Ui, + ctx: &mut AppContext, + chrome_options: &mut ChromeOptions, +) -> SoftKeyboardAnim { + let skb_ctx = if chrome_options.contains(ChromeOptions::VirtualKeyboard) { + SoftKeyboardContext::Virtual + } else { + SoftKeyboardContext::Platform { + ppp: ui.ctx().pixels_per_point(), + } + }; + + // move screen up if virtual keyboard intersects with input_rect + let screen_rect = ui.ctx().screen_rect(); + let mut skb_rect: Option<Rect> = None; + + let keyboard_height = + if let Some(vkb_rect) = ctx.soft_keyboard_rect(screen_rect, skb_ctx.clone()) { + skb_rect = Some(vkb_rect); + vkb_rect.height() + } else { + 0.0 + }; + + let anim_height = + ui.ctx() + .animate_value_with_time(egui::Id::new("keyboard_anim"), keyboard_height, 0.1); + + SoftKeyboardAnim { + anim_height, + skb_rect, + } +} + +fn try_toggle_virtual_keyboard( + ctx: &egui::Context, + options: NotedeckOptions, + chrome_options: &mut ChromeOptions, +) { + // handle virtual keyboard toggle here because why not + if options.contains(NotedeckOptions::Debug) && ctx.input(|i| i.key_pressed(egui::Key::F1)) { + chrome_options.toggle(ChromeOptions::VirtualKeyboard); + } +} + +/// All the logic which handles our keyboard visibility +fn keyboard_visibility( + ui: &mut egui::Ui, + ctx: &mut AppContext, + options: &mut ChromeOptions, + soft_kb_anim_state: &mut AnimState, +) -> SoftKeyboardAnim { + try_toggle_virtual_keyboard(ui.ctx(), ctx.args.options, options); + + let soft_kb_anim = soft_keyboard_anim(ui, ctx, options); + + let prev_state = *soft_kb_anim_state; + let current_state = soft_kb_anim.changed(prev_state); + *soft_kb_anim_state = current_state; + + if prev_state != current_state { + tracing::debug!("soft kb state {prev_state:?} -> {current_state:?}"); + } + + match current_state { + // we finished + AnimState::FinishedOpen => {} + + // on first open, we setup our scroll target + AnimState::StartOpen => { + // when we first open the keyboard, check to see if the target soft + // keyboard rect (the height at full open) intersects with any + // input response rects from last frame + // + // If we do, then we set a bit that we need keyboard visibility. + // We will use this bit to resize the screen based on the soft + // keyboard animation state + if let Some(skb_rect) = soft_kb_anim.skb_rect { + if let Some(input_rect) = notedeck_ui::input_rect(ui) { + options.set( + ChromeOptions::KeyboardVisibility, + input_rect.intersects(skb_rect), + ) + } + } + } + + AnimState::FinishedClose => { + // clear last input box position state + notedeck_ui::clear_input_rect(ui); + } + + AnimState::Closing => {} + AnimState::Opened => {} + AnimState::Closed => {} + AnimState::Opening => {} + AnimState::StartClose => {} + }; + + soft_kb_anim +} diff --git a/crates/notedeck_chrome/src/options.rs b/crates/notedeck_chrome/src/options.rs @@ -20,6 +20,9 @@ bitflags! { /// Repaint debug const RepaintDebug = 1 << 3; + + /// We need soft keyboard visibility + const KeyboardVisibility = 1 << 4; } } diff --git a/crates/notedeck_columns/src/app.rs b/crates/notedeck_columns/src/app.rs @@ -644,10 +644,20 @@ fn render_damus_mobile( let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; let mut app_action: Option<AppAction> = None; + // don't show toolbar if soft keyboard is open + let skb_rect = app_ctx.soft_keyboard_rect( + ui.ctx().screen_rect(), + notedeck::SoftKeyboardContext::platform(ui.ctx()), + ); + let toolbar_height = if skb_rect.is_none() { + Damus::toolbar_height() + } else { + 0.0 + }; StripBuilder::new(ui) .size(Size::remainder()) // top cell - .size(Size::exact(Damus::toolbar_height())) // bottom cell + .size(Size::exact(toolbar_height)) // bottom cell .vertical(|mut strip| { strip.cell(|ui| { let rect = ui.available_rect_before_wrap(); @@ -678,17 +688,22 @@ fn render_damus_mobile( hovering_post_button(ui, app, app_ctx, rect); }); - strip.cell(|ui| { + strip.cell(|ui| 'brk: { + if toolbar_height <= 0.0 { + break 'brk; + } + let unseen_notif = unseen_notification( app, app_ctx.ndb, app_ctx.accounts.get_selected_account().key.pubkey, ); - let resp = toolbar(ui, unseen_notif); - - if let Some(action) = resp { - action.process(app, app_ctx); + if skb_rect.is_none() { + let resp = toolbar(ui, unseen_notif); + if let Some(action) = resp { + action.process(app, app_ctx); + } } }); }); diff --git a/crates/notedeck_columns/src/ui/note/reply.rs b/crates/notedeck_columns/src/ui/note/reply.rs @@ -55,6 +55,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { pub fn show(&mut self, ui: &mut egui::Ui) -> PostResponse { ScrollArea::vertical() .id_salt(self.scroll_id) + .stick_to_bottom(true) .show(ui, |ui| self.show_internal(ui)) .inner } @@ -121,7 +122,7 @@ impl<'a, 'd> PostReplyView<'a, 'd> { // large and things start breaking. I think this is an ok // solution but there could be a better one. // - ui.add_space(500.0); + //ui.add_space(500.0); post_response }) diff --git a/crates/notedeck_columns/src/ui/profile/edit.rs b/crates/notedeck_columns/src/ui/profile/edit.rs @@ -37,6 +37,7 @@ impl<'a> EditProfileView<'a> { pub fn ui(&mut self, ui: &mut egui::Ui) -> bool { ScrollArea::vertical() .id_salt(EditProfileView::scroll_id()) + .stick_to_bottom(true) .show(ui, |ui| { banner(ui, self.state.banner(), 188.0);