mirror of
https://github.com/aljazceru/notedeck.git
synced 2026-01-06 01:44:21 +01:00
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>
1178 lines
37 KiB
Rust
1178 lines
37 KiB
Rust
// Entry point for wasm
|
||
//#[cfg(target_arch = "wasm32")]
|
||
//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};
|
||
use nostrdb::{ProfileRecord, Transaction};
|
||
use notedeck::Error;
|
||
use notedeck::SoftKeyboardContext;
|
||
use notedeck::{
|
||
tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle,
|
||
UserAccount, WalletType,
|
||
};
|
||
use notedeck_columns::{timeline::TimelineKind, Damus};
|
||
use notedeck_dave::{Dave, DaveAvatar};
|
||
use notedeck_ui::{
|
||
app_images, expanding_button, AnimationHelper, ProfilePic, ICON_EXPANSION_MULTIPLE, ICON_WIDTH,
|
||
};
|
||
use std::collections::HashMap;
|
||
|
||
#[derive(Default)]
|
||
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>,
|
||
}
|
||
|
||
pub enum ChromePanelAction {
|
||
Support,
|
||
Settings,
|
||
Account,
|
||
Wallet,
|
||
SaveTheme(ThemePreference),
|
||
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();
|
||
|
||
if let Some(c) = chrome.get_columns_app().and_then(|columns| {
|
||
columns
|
||
.decks_cache
|
||
.selected_column_mut(ctx.i18n, ctx.accounts)
|
||
}) {
|
||
if c.router().routes().iter().any(|r| r == &route) {
|
||
// return if we are already routing to accounts
|
||
c.router_mut().go_back();
|
||
} else {
|
||
c.router_mut().route_to(route);
|
||
//c..route_to(Route::relays());
|
||
}
|
||
};
|
||
}
|
||
|
||
fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) {
|
||
match self {
|
||
Self::SaveTheme(theme) => {
|
||
ui.ctx().set_theme(*theme);
|
||
ctx.settings.set_theme(*theme);
|
||
}
|
||
|
||
Self::Support => {
|
||
Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Support);
|
||
}
|
||
|
||
Self::Account => {
|
||
Self::columns_navigate(ctx, chrome, notedeck_columns::Route::accounts());
|
||
}
|
||
|
||
Self::Settings => {
|
||
Self::columns_navigate(ctx, chrome, notedeck_columns::Route::Settings);
|
||
}
|
||
|
||
Self::Wallet => {
|
||
Self::columns_navigate(
|
||
ctx,
|
||
chrome,
|
||
notedeck_columns::Route::Wallet(WalletType::Auto),
|
||
);
|
||
}
|
||
Self::Profile(pk) => {
|
||
columns_route_to_profile(pk, chrome, ctx, ui);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Some people have been running notedeck in debug, let's catch that!
|
||
fn stop_debug_mode(options: NotedeckOptions) {
|
||
if !options.contains(NotedeckOptions::Tests)
|
||
&& cfg!(debug_assertions)
|
||
&& !options.contains(NotedeckOptions::Debug)
|
||
{
|
||
println!("--- WELCOME TO DAMUS NOTEDECK! ---");
|
||
println!(
|
||
"It looks like are running notedeck in debug mode, unless you are a developer, this is not likely what you want."
|
||
);
|
||
println!("If you are a developer, run `cargo run -- --debug` to skip this message.");
|
||
println!("For everyone else, try again with `cargo run --release`. Enjoy!");
|
||
println!("---------------------------------");
|
||
panic!();
|
||
}
|
||
}
|
||
|
||
impl Chrome {
|
||
/// Create a new chrome with the default app setup
|
||
pub fn new_with_apps(
|
||
cc: &CreationContext,
|
||
app_args: &[String],
|
||
notedeck: &mut Notedeck,
|
||
) -> Result<Self, Error> {
|
||
stop_debug_mode(notedeck.options());
|
||
|
||
let context = &mut notedeck.app_context();
|
||
let dave = Dave::new(cc.wgpu_render_state.as_ref());
|
||
let columns = Damus::new(context, app_args);
|
||
let mut chrome = Chrome::default();
|
||
|
||
notedeck.check_args(columns.unrecognized_args())?;
|
||
|
||
chrome.add_app(NotedeckApp::Columns(Box::new(columns)));
|
||
chrome.add_app(NotedeckApp::Dave(Box::new(dave)));
|
||
|
||
if notedeck.has_option(NotedeckOptions::FeatureNotebook) {
|
||
chrome.add_app(NotedeckApp::Notebook(Box::default()));
|
||
}
|
||
|
||
if notedeck.has_option(NotedeckOptions::FeatureClnDash) {
|
||
chrome.add_app(NotedeckApp::ClnDash(Box::default()));
|
||
}
|
||
|
||
chrome.set_active(0);
|
||
|
||
Ok(chrome)
|
||
}
|
||
|
||
pub fn toggle(&mut self) {
|
||
self.options.toggle(ChromeOptions::IsOpen);
|
||
}
|
||
|
||
pub fn add_app(&mut self, app: NotedeckApp) {
|
||
self.apps.push(app);
|
||
}
|
||
|
||
fn get_columns_app(&mut self) -> Option<&mut Damus> {
|
||
for app in &mut self.apps {
|
||
if let NotedeckApp::Columns(cols) = app {
|
||
return Some(cols);
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
fn switch_to_columns(&mut self) {
|
||
for (i, app) in self.apps.iter().enumerate() {
|
||
if let NotedeckApp::Columns(_) = app {
|
||
self.active = i as i32;
|
||
}
|
||
}
|
||
}
|
||
|
||
pub fn set_active(&mut self, app: i32) {
|
||
self.active = app;
|
||
}
|
||
|
||
/// The chrome side panel
|
||
fn panel(
|
||
&mut self,
|
||
app_ctx: &mut AppContext,
|
||
builder: StripBuilder,
|
||
amt_open: f32,
|
||
amt_keyboard_open: f32,
|
||
) -> Option<ChromePanelAction> {
|
||
let mut got_action: Option<ChromePanelAction> = None;
|
||
|
||
builder
|
||
.size(Size::exact(amt_open)) // collapsible sidebar
|
||
.size(Size::remainder()) // the main app contents
|
||
.clip(true)
|
||
.horizontal(|mut hstrip| {
|
||
hstrip.cell(|ui| {
|
||
let rect = ui.available_rect_before_wrap();
|
||
if !ui.visuals().dark_mode {
|
||
let rect = ui.available_rect_before_wrap();
|
||
ui.painter().rect(
|
||
rect,
|
||
0,
|
||
notedeck_ui::colors::ALMOST_WHITE,
|
||
egui::Stroke::new(0.0, Color32::TRANSPARENT),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
}
|
||
|
||
StripBuilder::new(ui)
|
||
.size(Size::remainder())
|
||
.size(Size::remainder())
|
||
.vertical(|mut vstrip| {
|
||
vstrip.cell(|ui| {
|
||
_ = ui.vertical_centered(|ui| {
|
||
self.topdown_sidebar(ui, app_ctx.i18n);
|
||
})
|
||
});
|
||
|
||
vstrip.cell(|ui| {
|
||
ui.with_layout(Layout::bottom_up(egui::Align::Center), |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);
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
// vertical sidebar line
|
||
ui.painter().vline(
|
||
rect.right(),
|
||
rect.y_range(),
|
||
ui.visuals().widgets.noninteractive.bg_stroke,
|
||
);
|
||
});
|
||
|
||
hstrip.cell(|ui| {
|
||
/*
|
||
let rect = ui.available_rect_before_wrap();
|
||
ui.painter().rect(
|
||
rect,
|
||
0,
|
||
egui::Color32::RED,
|
||
egui::Stroke::new(1.0, egui::Color32::BLUE),
|
||
egui::StrokeKind::Inside,
|
||
);
|
||
*/
|
||
|
||
if let Some(action) = self.apps[self.active as usize].update(app_ctx, ui) {
|
||
chrome_handle_app_action(self, app_ctx, action, ui);
|
||
}
|
||
});
|
||
});
|
||
|
||
got_action
|
||
}
|
||
|
||
/// How far is the chrome panel expanded?
|
||
fn amount_open(&self, ui: &mut egui::Ui) -> f32 {
|
||
let open_id = egui::Id::new("chrome_open");
|
||
let side_panel_width: f32 = 74.0;
|
||
ui.ctx()
|
||
.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.
|
||
///
|
||
/// The side menu should hover over the screen, while the side bar
|
||
/// is collapsible but persistent on the screen.
|
||
fn show(&mut self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<ChromePanelAction> {
|
||
ui.spacing_mut().item_spacing.x = 0.0;
|
||
|
||
let amt_open = self.amount_open(ui);
|
||
let skb_anim =
|
||
keyboard_visibility(ui, ctx, &mut self.options, &mut self.soft_kb_anim_state);
|
||
|
||
let virtual_keyboard = self.options.contains(ChromeOptions::VirtualKeyboard);
|
||
let keyboard_height = if self.options.contains(ChromeOptions::KeyboardVisibility) {
|
||
skb_anim.anim_height
|
||
} else {
|
||
0.0
|
||
};
|
||
|
||
// 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);
|
||
});
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
action
|
||
}
|
||
|
||
fn topdown_sidebar(&mut self, ui: &mut egui::Ui, i18n: &mut Localization) {
|
||
// macos needs a bit of space to make room for window
|
||
// minimize/close buttons
|
||
if cfg!(target_os = "macos") {
|
||
ui.add_space(30.0);
|
||
} else {
|
||
// we still want *some* padding so that it aligns with the + button regardless
|
||
ui.add_space(notedeck_ui::constants::FRAME_MARGIN.into());
|
||
}
|
||
|
||
if ui.add(expand_side_panel_button()).clicked() {
|
||
//self.active = (self.active + 1) % (self.apps.len() as i32);
|
||
self.options.toggle(ChromeOptions::IsOpen);
|
||
}
|
||
|
||
ui.add_space(4.0);
|
||
ui.add(milestone_name(i18n));
|
||
//let dark_mode = ui.ctx().style().visuals.dark_mode;
|
||
|
||
for (i, app) in self.apps.iter_mut().enumerate() {
|
||
let r = match app {
|
||
NotedeckApp::Columns(_columns_app) => columns_button(ui),
|
||
|
||
NotedeckApp::Dave(dave) => {
|
||
ui.add_space(24.0);
|
||
let rect = dave_sidebar_rect(ui);
|
||
dave_button(dave.avatar_mut(), ui, rect)
|
||
}
|
||
|
||
NotedeckApp::ClnDash(_clndash) => clndash_button(ui),
|
||
|
||
NotedeckApp::Notebook(_notebook) => notebook_button(ui),
|
||
|
||
NotedeckApp::Other(_other) => {
|
||
// app provides its own button rendering ui?
|
||
panic!("TODO: implement other apps")
|
||
}
|
||
};
|
||
|
||
ui.add_space(4.0);
|
||
|
||
if r.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
|
||
self.active = i as i32;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl notedeck::App for Chrome {
|
||
fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> Option<AppAction> {
|
||
if let Some(action) = self.show(ctx, ui) {
|
||
action.process(ctx, self, ui);
|
||
}
|
||
// TODO: unify this constant with the columns side panel width. ui crate?
|
||
None
|
||
}
|
||
}
|
||
|
||
fn milestone_name<'a>(i18n: &'a mut Localization) -> impl Widget + 'a {
|
||
|ui: &mut egui::Ui| -> egui::Response {
|
||
ui.vertical_centered(|ui| {
|
||
let font = egui::FontId::new(
|
||
notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Tiny),
|
||
egui::FontFamily::Name(notedeck::fonts::NamedFontFamily::Bold.as_str().into()),
|
||
);
|
||
ui.add(
|
||
Label::new(
|
||
RichText::new(tr!(i18n, "BETA", "Beta version label"))
|
||
.color(ui.style().visuals.noninteractive().fg_stroke.color)
|
||
.font(font),
|
||
)
|
||
.selectable(false),
|
||
)
|
||
.on_hover_text(tr!(
|
||
i18n,
|
||
"Notedeck is a beta product. Expect bugs and contact us when you run into issues.",
|
||
"Beta product warning message"
|
||
))
|
||
.on_hover_cursor(egui::CursorIcon::Help)
|
||
})
|
||
.inner
|
||
}
|
||
}
|
||
|
||
fn expand_side_panel_button() -> impl Widget {
|
||
|ui: &mut egui::Ui| -> egui::Response {
|
||
let img_size = 40.0;
|
||
let img = app_images::damus_image()
|
||
.max_width(img_size)
|
||
.sense(egui::Sense::click());
|
||
|
||
ui.add(img)
|
||
}
|
||
}
|
||
|
||
fn support_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"help-button",
|
||
16.0,
|
||
app_images::help_light_image(),
|
||
app_images::help_dark_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn settings_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"settings-button",
|
||
32.0,
|
||
app_images::settings_light_image(),
|
||
app_images::settings_dark_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn columns_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"columns-button",
|
||
40.0,
|
||
app_images::columns_image(),
|
||
app_images::columns_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn accounts_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"accounts-button",
|
||
24.0,
|
||
app_images::accounts_image().tint(ui.visuals().text_color()),
|
||
app_images::accounts_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn clndash_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"clndash-button",
|
||
24.0,
|
||
app_images::cln_image(),
|
||
app_images::cln_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn notebook_button(ui: &mut egui::Ui) -> egui::Response {
|
||
expanding_button(
|
||
"notebook-button",
|
||
40.0,
|
||
app_images::algo_image(),
|
||
app_images::algo_image(),
|
||
ui,
|
||
false,
|
||
)
|
||
}
|
||
|
||
fn dave_sidebar_rect(ui: &mut egui::Ui) -> Rect {
|
||
let size = vec2(60.0, 60.0);
|
||
let available = ui.available_rect_before_wrap();
|
||
let center_x = available.center().x;
|
||
let center_y = available.top();
|
||
egui::Rect::from_center_size(egui::pos2(center_x, center_y), size)
|
||
}
|
||
|
||
fn dave_button(avatar: Option<&mut DaveAvatar>, ui: &mut egui::Ui, rect: Rect) -> egui::Response {
|
||
if let Some(avatar) = avatar {
|
||
avatar.render(rect, ui)
|
||
} else {
|
||
// plain icon if wgpu device not available??
|
||
ui.label("fixme")
|
||
}
|
||
}
|
||
|
||
pub fn get_profile_url_owned(profile: Option<ProfileRecord<'_>>) -> &str {
|
||
if let Some(url) = profile.and_then(|pr| pr.record().profile().and_then(|p| p.picture())) {
|
||
url
|
||
} else {
|
||
notedeck::profile::no_pfp_url()
|
||
}
|
||
}
|
||
|
||
pub fn get_account_url<'a>(
|
||
txn: &'a nostrdb::Transaction,
|
||
ndb: &nostrdb::Ndb,
|
||
account: &UserAccount,
|
||
) -> &'a str {
|
||
if let Ok(profile) = ndb.get_profile_by_pubkey(txn, account.key.pubkey.bytes()) {
|
||
get_profile_url_owned(Some(profile))
|
||
} else {
|
||
get_profile_url_owned(None)
|
||
}
|
||
}
|
||
|
||
fn wallet_button() -> impl Widget {
|
||
|ui: &mut egui::Ui| -> egui::Response {
|
||
let img_size = 24.0;
|
||
|
||
let max_size = img_size * ICON_EXPANSION_MULTIPLE;
|
||
|
||
let img = if !ui.visuals().dark_mode {
|
||
app_images::wallet_light_image()
|
||
} else {
|
||
app_images::wallet_dark_image()
|
||
}
|
||
.max_width(img_size);
|
||
|
||
let helper = AnimationHelper::new(ui, "wallet-icon", vec2(max_size, max_size));
|
||
|
||
let cur_img_size = helper.scale_1d_pos(img_size);
|
||
img.paint_at(
|
||
ui,
|
||
helper
|
||
.get_animation_rect()
|
||
.shrink((max_size - cur_img_size) / 2.0),
|
||
);
|
||
|
||
helper.take_animation_response()
|
||
}
|
||
}
|
||
|
||
fn chrome_handle_app_action(
|
||
chrome: &mut Chrome,
|
||
ctx: &mut AppContext,
|
||
action: AppAction,
|
||
ui: &mut egui::Ui,
|
||
) {
|
||
match action {
|
||
AppAction::ToggleChrome => {
|
||
chrome.toggle();
|
||
}
|
||
|
||
AppAction::Note(note_action) => {
|
||
chrome.switch_to_columns();
|
||
let Some(columns) = chrome.get_columns_app() else {
|
||
return;
|
||
};
|
||
|
||
let txn = Transaction::new(ctx.ndb).unwrap();
|
||
|
||
let cols = columns
|
||
.decks_cache
|
||
.active_columns_mut(ctx.i18n, ctx.accounts)
|
||
.unwrap();
|
||
let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
|
||
note_action,
|
||
ctx.ndb,
|
||
cols,
|
||
0,
|
||
&mut columns.timeline_cache,
|
||
&mut columns.threads,
|
||
ctx.note_cache,
|
||
ctx.pool,
|
||
&txn,
|
||
ctx.unknown_ids,
|
||
ctx.accounts,
|
||
ctx.global_wallet,
|
||
ctx.zaps,
|
||
ctx.img_cache,
|
||
&mut columns.view_state,
|
||
ui,
|
||
);
|
||
|
||
if let Some(action) = m_action {
|
||
let col = cols.selected_mut();
|
||
|
||
action.process(&mut col.router, &mut col.sheet_router);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn columns_route_to_profile(
|
||
pk: ¬edeck::enostr::Pubkey,
|
||
chrome: &mut Chrome,
|
||
ctx: &mut AppContext,
|
||
ui: &mut egui::Ui,
|
||
) {
|
||
chrome.switch_to_columns();
|
||
let Some(columns) = chrome.get_columns_app() else {
|
||
return;
|
||
};
|
||
|
||
let cols = columns
|
||
.decks_cache
|
||
.active_columns_mut(ctx.i18n, ctx.accounts)
|
||
.unwrap();
|
||
|
||
let router = cols.get_selected_router();
|
||
if router.routes().iter().any(|r| {
|
||
matches!(
|
||
r,
|
||
notedeck_columns::Route::Timeline(TimelineKind::Profile(_))
|
||
)
|
||
}) {
|
||
router.go_back();
|
||
return;
|
||
}
|
||
|
||
let txn = Transaction::new(ctx.ndb).unwrap();
|
||
let m_action = notedeck_columns::actionbar::execute_and_process_note_action(
|
||
notedeck::NoteAction::Profile(*pk),
|
||
ctx.ndb,
|
||
cols,
|
||
0,
|
||
&mut columns.timeline_cache,
|
||
&mut columns.threads,
|
||
ctx.note_cache,
|
||
ctx.pool,
|
||
&txn,
|
||
ctx.unknown_ids,
|
||
ctx.accounts,
|
||
ctx.global_wallet,
|
||
ctx.zaps,
|
||
ctx.img_cache,
|
||
&mut columns.view_state,
|
||
ui,
|
||
);
|
||
|
||
if let Some(action) = m_action {
|
||
let col = cols.selected_mut();
|
||
|
||
action.process(&mut col.router, &mut col.sheet_router);
|
||
}
|
||
}
|
||
|
||
fn pfp_button(ctx: &mut AppContext, ui: &mut egui::Ui) -> egui::Response {
|
||
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
|
||
let helper = AnimationHelper::new(ui, "pfp-button", egui::vec2(max_size, max_size));
|
||
|
||
let min_pfp_size = ICON_WIDTH;
|
||
let cur_pfp_size = helper.scale_1d_pos(min_pfp_size);
|
||
|
||
let txn = Transaction::new(ctx.ndb).expect("should be able to create txn");
|
||
let profile_url = get_account_url(&txn, ctx.ndb, ctx.accounts.get_selected_account());
|
||
|
||
let mut widget = ProfilePic::new(ctx.img_cache, profile_url).size(cur_pfp_size);
|
||
|
||
ui.put(helper.get_animation_rect(), &mut widget);
|
||
|
||
helper.take_animation_response()
|
||
}
|
||
|
||
/// The section of the chrome sidebar that starts at the
|
||
/// bottom and goes up
|
||
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);
|
||
|
||
let theme_action = match ui.ctx().theme() {
|
||
egui::Theme::Dark => {
|
||
let resp = ui
|
||
.add(Button::new("☀").frame(false))
|
||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||
.on_hover_text(tr!(
|
||
ctx.i18n,
|
||
"Switch to light mode",
|
||
"Hover text for light mode toggle button"
|
||
));
|
||
if resp.clicked() {
|
||
Some(ChromePanelAction::SaveTheme(ThemePreference::Light))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
egui::Theme::Light => {
|
||
let resp = ui
|
||
.add(Button::new("🌙").frame(false))
|
||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||
.on_hover_text(tr!(
|
||
ctx.i18n,
|
||
"Switch to dark mode",
|
||
"Hover text for dark mode toggle button"
|
||
));
|
||
if resp.clicked() {
|
||
Some(ChromePanelAction::SaveTheme(ThemePreference::Dark))
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
};
|
||
|
||
let support_resp = support_button(ui).on_hover_cursor(egui::CursorIcon::PointingHand);
|
||
|
||
let wallet_resp = ui
|
||
.add(wallet_button())
|
||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||
|
||
if ctx.args.options.contains(NotedeckOptions::Debug) {
|
||
let r = ui
|
||
.weak(format!("{}", ctx.frame_history.fps() as i32))
|
||
.union(ui.weak(format!(
|
||
"{:10.1}",
|
||
ctx.frame_history.mean_frame_time() * 1e3
|
||
)))
|
||
.on_hover_cursor(egui::CursorIcon::PointingHand);
|
||
|
||
if r.clicked() {
|
||
chrome.options.toggle(ChromeOptions::RepaintDebug);
|
||
}
|
||
|
||
if chrome.options.contains(ChromeOptions::RepaintDebug) {
|
||
for cause in ui.ctx().repaint_causes() {
|
||
chrome
|
||
.repaint_causes
|
||
.entry(cause)
|
||
.and_modify(|rc| {
|
||
*rc += 1;
|
||
})
|
||
.or_insert(1);
|
||
}
|
||
repaint_causes_window(ui, &chrome.repaint_causes)
|
||
}
|
||
|
||
#[cfg(feature = "memory")]
|
||
{
|
||
let mem_use = re_memory::MemoryUse::capture();
|
||
if let Some(counted) = mem_use.counted {
|
||
if ui
|
||
.label(format!("{}", format_bytes(counted as f64)))
|
||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||
.clicked()
|
||
{
|
||
chrome.show_memory_debug = !chrome.show_memory_debug;
|
||
}
|
||
}
|
||
if let Some(resident) = mem_use.resident {
|
||
ui.weak(format!("{}", format_bytes(resident as f64)));
|
||
}
|
||
|
||
if chrome.show_memory_debug {
|
||
egui::Window::new("Memory Debug").show(ui.ctx(), memory_debug_ui);
|
||
}
|
||
}
|
||
}
|
||
|
||
if pfp_resp.clicked() {
|
||
let pk = ctx.accounts.get_selected_account().key.pubkey;
|
||
Some(ChromePanelAction::Profile(pk))
|
||
} else if accounts_resp.clicked() {
|
||
Some(ChromePanelAction::Account)
|
||
} else if settings_resp.clicked() {
|
||
Some(ChromePanelAction::Settings)
|
||
} else if theme_action.is_some() {
|
||
theme_action
|
||
} else if support_resp.clicked() {
|
||
Some(ChromePanelAction::Support)
|
||
} else if wallet_resp.clicked() {
|
||
Some(ChromePanelAction::Wallet)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
|
||
#[cfg(feature = "memory")]
|
||
fn memory_debug_ui(ui: &mut egui::Ui) {
|
||
let Some(stats) = &re_memory::accounting_allocator::tracking_stats() else {
|
||
ui.label("re_memory::accounting_allocator::set_tracking_callstacks(true); not set!!");
|
||
return;
|
||
};
|
||
|
||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||
ui.label(format!(
|
||
"track_size_threshold {}",
|
||
stats.track_size_threshold
|
||
));
|
||
ui.label(format!(
|
||
"untracked {} {}",
|
||
stats.untracked.count,
|
||
format_bytes(stats.untracked.size as f64)
|
||
));
|
||
ui.label(format!(
|
||
"stochastically_tracked {} {}",
|
||
stats.stochastically_tracked.count,
|
||
format_bytes(stats.stochastically_tracked.size as f64),
|
||
));
|
||
ui.label(format!(
|
||
"fully_tracked {} {}",
|
||
stats.fully_tracked.count,
|
||
format_bytes(stats.fully_tracked.size as f64)
|
||
));
|
||
ui.label(format!(
|
||
"overhead {} {}",
|
||
stats.overhead.count,
|
||
format_bytes(stats.overhead.size as f64)
|
||
));
|
||
|
||
ui.separator();
|
||
|
||
for (i, callstack) in stats.top_callstacks.iter().enumerate() {
|
||
let full_bt = format!("{}", callstack.readable_backtrace);
|
||
let mut lines = full_bt.lines().skip(5);
|
||
let bt_header = lines.nth(0).map_or("??", |v| v);
|
||
let header = format!(
|
||
"#{} {bt_header} {}x {}",
|
||
i + 1,
|
||
callstack.extant.count,
|
||
format_bytes(callstack.extant.size as f64)
|
||
);
|
||
|
||
egui::CollapsingHeader::new(header)
|
||
.id_salt(("mem_cs", i))
|
||
.show(ui, |ui| {
|
||
ui.label(lines.collect::<Vec<_>>().join("\n"));
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
/// Pretty format a number of bytes by using SI notation (base2), e.g.
|
||
///
|
||
/// ```
|
||
/// # use re_format::format_bytes;
|
||
/// assert_eq!(format_bytes(123.0), "123 B");
|
||
/// assert_eq!(format_bytes(12_345.0), "12.1 KiB");
|
||
/// assert_eq!(format_bytes(1_234_567.0), "1.2 MiB");
|
||
/// assert_eq!(format_bytes(123_456_789.0), "118 MiB");
|
||
/// ```
|
||
#[cfg(feature = "memory")]
|
||
pub fn format_bytes(number_of_bytes: f64) -> String {
|
||
/// The minus character: <https://www.compart.com/en/unicode/U+2212>
|
||
/// Looks slightly different from the normal hyphen `-`.
|
||
const MINUS: char = '−';
|
||
|
||
if number_of_bytes < 0.0 {
|
||
format!("{MINUS}{}", format_bytes(-number_of_bytes))
|
||
} else if number_of_bytes == 0.0 {
|
||
"0 B".to_owned()
|
||
} else if number_of_bytes < 1.0 {
|
||
format!("{number_of_bytes} B")
|
||
} else if number_of_bytes < 20.0 {
|
||
let is_integer = number_of_bytes.round() == number_of_bytes;
|
||
if is_integer {
|
||
format!("{number_of_bytes:.0} B")
|
||
} else {
|
||
format!("{number_of_bytes:.1} B")
|
||
}
|
||
} else if number_of_bytes < 10.0_f64.exp2() {
|
||
format!("{number_of_bytes:.0} B")
|
||
} else if number_of_bytes < 20.0_f64.exp2() {
|
||
let decimals = (10.0 * number_of_bytes < 20.0_f64.exp2()) as usize;
|
||
format!("{:.*} KiB", decimals, number_of_bytes / 10.0_f64.exp2())
|
||
} else if number_of_bytes < 30.0_f64.exp2() {
|
||
let decimals = (10.0 * number_of_bytes < 30.0_f64.exp2()) as usize;
|
||
format!("{:.*} MiB", decimals, number_of_bytes / 20.0_f64.exp2())
|
||
} else {
|
||
let decimals = (10.0 * number_of_bytes < 40.0_f64.exp2()) as usize;
|
||
format!("{:.*} GiB", decimals, number_of_bytes / 30.0_f64.exp2())
|
||
}
|
||
}
|
||
|
||
fn repaint_causes_window(ui: &mut egui::Ui, causes: &HashMap<egui::RepaintCause, u64>) {
|
||
egui::Window::new("Repaint Causes").show(ui.ctx(), |ui| {
|
||
use egui_extras::{Column, TableBuilder};
|
||
TableBuilder::new(ui)
|
||
.column(Column::auto().at_least(600.0).resizable(true))
|
||
.column(Column::auto().at_least(50.0).resizable(true))
|
||
.column(Column::auto().at_least(50.0).resizable(true))
|
||
.column(Column::remainder())
|
||
.header(20.0, |mut header| {
|
||
header.col(|ui| {
|
||
ui.heading("file");
|
||
});
|
||
header.col(|ui| {
|
||
ui.heading("line");
|
||
});
|
||
header.col(|ui| {
|
||
ui.heading("count");
|
||
});
|
||
header.col(|ui| {
|
||
ui.heading("reason");
|
||
});
|
||
})
|
||
.body(|mut body| {
|
||
for (cause, hits) in causes.iter() {
|
||
body.row(30.0, |mut row| {
|
||
row.col(|ui| {
|
||
ui.label(cause.file.to_string());
|
||
});
|
||
row.col(|ui| {
|
||
ui.label(format!("{}", cause.line));
|
||
});
|
||
row.col(|ui| {
|
||
ui.label(format!("{hits}"));
|
||
});
|
||
row.col(|ui| {
|
||
ui.label(format!("{}", &cause.reason));
|
||
});
|
||
});
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
fn virtual_keyboard_ui(ui: &mut egui::Ui, rect: egui::Rect) {
|
||
let painter = ui.painter_at(rect);
|
||
|
||
painter.rect_filled(rect, 0.0, Color32::from_black_alpha(200));
|
||
|
||
ui.put(rect, |ui: &mut egui::Ui| {
|
||
ui.centered_and_justified(|ui| {
|
||
ui.label("This is a keyboard");
|
||
})
|
||
.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
|
||
}
|