Files
notedeck/crates/notedeck_columns/src/app.rs
William Casarin 77ac91e810 Implement soft keyboard visibility on Android
- Added `SoftKeyboardContext` enum and support for calculating keyboard
  insets from both virtual and platform sources

- Updated `AppContext` to provide `soft_keyboard_rect` for determining
  visible keyboard area

- Adjusted UI rendering to shift content when input boxes intersect with
  the soft keyboard, preventing overlap

- Modified `MainActivity` and Android manifest to use
  `windowSoftInputMode="adjustResize"` and updated window inset handling

- Introduced helper functions (`include_input`, `input_rect`,
  `clear_input_rect`) in `notedeck_ui` for tracking focused input boxes

- Fixed Android JNI keyboard height reporting to clamp negative values

Together, these changes allow the app to correctly detect and respond
to soft keyboard visibility on Android, ensuring input fields remain
accessible when typing.

Fixes: https://github.com/damus-io/notedeck/issues/946
Fixes: https://github.com/damus-io/notedeck/issues/1043
2025-08-19 11:29:45 -07:00

978 lines
31 KiB
Rust

use crate::{
args::{ColumnsArgs, ColumnsFlag},
column::Columns,
decks::{Decks, DecksCache},
draft::Drafts,
nav::{self, ProcessNavResult},
onboarding::Onboarding,
options::AppOptions,
route::Route,
storage,
subscriptions::{SubKind, Subscriptions},
support::Support,
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
toolbar::unseen_notification,
ui::{self, toolbar::toolbar, DesktopSidePanel, SidePanelAction},
view_state::ViewState,
Result,
};
use egui_extras::{Size, StripBuilder};
use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
use nostrdb::Transaction;
use notedeck::{
tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState,
Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds,
};
use notedeck_ui::{
media::{MediaViewer, MediaViewerFlags, MediaViewerState},
NoteOptions,
};
use std::collections::{BTreeSet, HashMap};
use std::path::Path;
use tracing::{debug, error, info, trace, warn};
use uuid::Uuid;
#[derive(Debug, Eq, PartialEq, Clone)]
pub enum DamusState {
Initializing,
Initialized,
}
/// We derive Deserialize/Serialize so we can persist app state on shutdown.
pub struct Damus {
state: DamusState,
pub decks_cache: DecksCache,
pub view_state: ViewState,
pub drafts: Drafts,
pub timeline_cache: TimelineCache,
pub subscriptions: Subscriptions,
pub support: Support,
pub jobs: JobsCache,
pub threads: Threads,
//frame_history: crate::frame_history::FrameHistory,
// TODO: make these bitflags
/// Were columns loaded from the commandline? If so disable persistence.
pub options: AppOptions,
pub note_options: NoteOptions,
pub unrecognized_args: BTreeSet<String>,
/// keep track of follow packs
pub onboarding: Onboarding,
}
fn handle_egui_events(input: &egui::InputState, columns: &mut Columns) {
for event in &input.raw.events {
match event {
egui::Event::Key { key, pressed, .. } if *pressed => match key {
egui::Key::J => {
//columns.select_down();
{}
}
/*
egui::Key::K => {
columns.select_up();
}
egui::Key::H => {
columns.select_left();
}
egui::Key::L => {
columns.select_left();
}
*/
egui::Key::BrowserBack | egui::Key::Escape => {
columns.get_selected_router().go_back();
}
_ => {}
},
egui::Event::InsetsChanged => {
tracing::debug!("insets have changed!");
}
_ => {}
}
}
}
fn try_process_event(
damus: &mut Damus,
app_ctx: &mut AppContext<'_>,
ctx: &egui::Context,
) -> Result<()> {
let current_columns =
get_active_columns_mut(app_ctx.i18n, app_ctx.accounts, &mut damus.decks_cache);
ctx.input(|i| handle_egui_events(i, current_columns));
let ctx2 = ctx.clone();
let wakeup = move || {
ctx2.request_repaint();
};
app_ctx.pool.keepalive_ping(wakeup);
// NOTE: we don't use the while let loop due to borrow issues
#[allow(clippy::while_let_loop)]
loop {
let ev = if let Some(ev) = app_ctx.pool.try_recv() {
ev.into_owned()
} else {
break;
};
match (&ev.event).into() {
RelayEvent::Opened => {
app_ctx
.accounts
.send_initial_filters(app_ctx.pool, &ev.relay);
timeline::send_initial_timeline_filters(
damus.options.contains(AppOptions::SinceOptimize),
&mut damus.timeline_cache,
&mut damus.subscriptions,
app_ctx.pool,
&ev.relay,
app_ctx.accounts,
);
}
// TODO: handle reconnects
RelayEvent::Closed => warn!("{} connection closed", &ev.relay),
RelayEvent::Error(e) => error!("{}: {}", &ev.relay, e),
RelayEvent::Other(msg) => trace!("other event {:?}", &msg),
RelayEvent::Message(msg) => {
process_message(damus, app_ctx, &ev.relay, &msg);
}
}
}
for (kind, timeline) in &mut damus.timeline_cache {
let is_ready = timeline::is_timeline_ready(
app_ctx.ndb,
app_ctx.pool,
app_ctx.note_cache,
timeline,
app_ctx.accounts,
);
if is_ready {
let txn = Transaction::new(app_ctx.ndb).expect("txn");
// only thread timelines are reversed
let reversed = false;
if let Err(err) = timeline.poll_notes_into_view(
app_ctx.ndb,
&txn,
app_ctx.unknown_ids,
app_ctx.note_cache,
reversed,
) {
error!("poll_notes_into_view: {err}");
}
} else {
// TODO: show loading?
if matches!(kind, TimelineKind::List(ListKind::Contact(_))) {
timeline::fetch_contact_list(&mut damus.subscriptions, timeline, app_ctx.accounts);
}
}
}
if let Some(follow_packs) = damus.onboarding.get_follow_packs_mut() {
follow_packs.poll_for_notes(app_ctx.ndb, app_ctx.unknown_ids);
}
if app_ctx.unknown_ids.ready_to_send() {
unknown_id_send(app_ctx.unknown_ids, app_ctx.pool);
}
Ok(())
}
fn unknown_id_send(unknown_ids: &mut UnknownIds, pool: &mut RelayPool) {
debug!("unknown_id_send called on: {:?}", &unknown_ids);
let filter = unknown_ids.filter().expect("filter");
debug!(
"Getting {} unknown ids from relays",
unknown_ids.ids_iter().len()
);
let msg = ClientMessage::req("unknownids".to_string(), filter);
unknown_ids.clear();
pool.send(&msg);
}
fn update_damus(damus: &mut Damus, app_ctx: &mut AppContext<'_>, ctx: &egui::Context) {
app_ctx.img_cache.urls.cache.handle_io();
if damus.columns(app_ctx.accounts).columns().is_empty() {
damus
.columns_mut(app_ctx.i18n, app_ctx.accounts)
.new_column_picker();
}
match damus.state {
DamusState::Initializing => {
damus.state = DamusState::Initialized;
// this lets our eose handler know to close unknownids right away
damus
.subscriptions()
.insert("unknownids".to_string(), SubKind::OneShot);
if let Err(err) = timeline::setup_initial_nostrdb_subs(
app_ctx.ndb,
app_ctx.note_cache,
&mut damus.timeline_cache,
) {
warn!("update_damus init: {err}");
}
}
DamusState::Initialized => (),
};
if let Err(err) = try_process_event(damus, app_ctx, ctx) {
error!("error processing event: {}", err);
}
}
fn handle_eose(
subscriptions: &Subscriptions,
timeline_cache: &mut TimelineCache,
ctx: &mut AppContext<'_>,
subid: &str,
relay_url: &str,
) -> Result<()> {
let sub_kind = if let Some(sub_kind) = subscriptions.subs.get(subid) {
sub_kind
} else {
let n_subids = subscriptions.subs.len();
warn!(
"got unknown eose subid {}, {} tracked subscriptions",
subid, n_subids
);
return Ok(());
};
match sub_kind {
SubKind::Timeline(_) => {
// eose on timeline? whatevs
}
SubKind::Initial => {
//let txn = Transaction::new(ctx.ndb)?;
//unknowns::update_from_columns(
// &txn,
// ctx.unknown_ids,
// timeline_cache,
// ctx.ndb,
// ctx.note_cache,
//);
//// this is possible if this is the first time
//if ctx.unknown_ids.ready_to_send() {
// unknown_id_send(ctx.unknown_ids, ctx.pool);
//}
}
// oneshot subs just close when they're done
SubKind::OneShot => {
let msg = ClientMessage::close(subid.to_string());
ctx.pool.send_to(&msg, relay_url);
}
SubKind::FetchingContactList(timeline_uid) => {
let timeline = if let Some(tl) = timeline_cache.get_mut(timeline_uid) {
tl
} else {
error!(
"timeline uid:{:?} not found for FetchingContactList",
timeline_uid
);
return Ok(());
};
let filter_state = timeline.filter.get_mut(relay_url);
let FilterState::FetchingRemote(fetching_remote_type) = filter_state else {
// TODO: we could have multiple contact list results, we need
// to check to see if this one is newer and use that instead
warn!(
"Expected timeline to have FetchingRemote state but was {:?}",
timeline.filter
);
return Ok(());
};
let new_filter_state = match fetching_remote_type {
notedeck::filter::FetchingRemoteType::Normal(unified_subscription) => {
FilterState::got_remote(unified_subscription.local)
}
notedeck::filter::FetchingRemoteType::Contact => {
FilterState::GotRemote(notedeck::filter::GotRemoteType::Contact)
}
};
// We take the subscription id and pass it to the new state of
// "GotRemote". This will let future frames know that it can try
// to look for the contact list in nostrdb.
timeline
.filter
.set_relay_state(relay_url.to_string(), new_filter_state);
}
}
Ok(())
}
fn process_message(damus: &mut Damus, ctx: &mut AppContext<'_>, relay: &str, msg: &RelayMessage) {
match msg {
RelayMessage::Event(_subid, ev) => {
let relay = if let Some(relay) = ctx.pool.relays.iter().find(|r| r.url() == relay) {
relay
} else {
error!("couldn't find relay {} for note processing!?", relay);
return;
};
match relay {
PoolRelay::Websocket(_) => {
//info!("processing event {}", event);
if let Err(err) = ctx.ndb.process_event_with(
ev,
nostrdb::IngestMetadata::new()
.client(false)
.relay(relay.url()),
) {
error!("error processing event {ev}: {err}");
}
}
PoolRelay::Multicast(_) => {
// multicast events are client events
if let Err(err) = ctx.ndb.process_event_with(
ev,
nostrdb::IngestMetadata::new()
.client(true)
.relay(relay.url()),
) {
error!("error processing multicast event {ev}: {err}");
}
}
}
}
RelayMessage::Notice(msg) => warn!("Notice from {}: {}", relay, msg),
RelayMessage::OK(cr) => info!("OK {:?}", cr),
RelayMessage::Eose(sid) => {
if let Err(err) = handle_eose(
&damus.subscriptions,
&mut damus.timeline_cache,
ctx,
sid,
relay,
) {
error!("error handling eose: {}", err);
}
}
}
}
fn render_damus(
damus: &mut Damus,
app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> Option<AppAction> {
damus
.note_options
.set(NoteOptions::Wide, is_narrow(ui.ctx()));
let app_action = if notedeck::ui::is_narrow(ui.ctx()) {
render_damus_mobile(damus, app_ctx, ui)
} else {
render_damus_desktop(damus, app_ctx, ui)
};
fullscreen_media_viewer_ui(ui, &mut damus.view_state.media_viewer, app_ctx.img_cache);
// We use this for keeping timestamps and things up to date
//ui.ctx().request_repaint_after(Duration::from_secs(5));
app_action
}
/// Present a fullscreen media viewer if the FullscreenMedia AppOptions flag is set. This is
/// typically set by image carousels using a MediaAction's on_view_media callback when
/// an image is clicked
fn fullscreen_media_viewer_ui(
ui: &mut egui::Ui,
state: &mut MediaViewerState,
img_cache: &mut Images,
) {
if !state.should_show(ui) {
if state.scene_rect.is_some() {
// if we shouldn't show yet we will have a scene
// rect, then we should clear it for next time
tracing::debug!("fullscreen_media_viewer_ui: resetting scene rect");
state.scene_rect = None;
}
return;
}
let resp = MediaViewer::new(state).fullscreen(true).ui(img_cache, ui);
if resp.clicked() || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
fullscreen_media_close(state);
}
}
/// Close the fullscreen media player. This also resets the scene_rect state
fn fullscreen_media_close(state: &mut MediaViewerState) {
state.flags.set(MediaViewerFlags::Open, false);
}
/*
fn determine_key_storage_type() -> KeyStorageType {
#[cfg(target_os = "macos")]
{
KeyStorageType::MacOS
}
#[cfg(target_os = "linux")]
{
KeyStorageType::Linux
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
KeyStorageType::None
}
}
*/
impl Damus {
/// Called once before the first frame.
pub fn new(app_context: &mut AppContext<'_>, args: &[String]) -> Self {
// arg parsing
let (parsed_args, unrecognized_args) =
ColumnsArgs::parse(args, Some(app_context.accounts.selected_account_pubkey()));
let account = app_context.accounts.selected_account_pubkey_bytes();
let mut timeline_cache = TimelineCache::default();
let mut options = AppOptions::default();
let tmp_columns = !parsed_args.columns.is_empty();
options.set(AppOptions::TmpColumns, tmp_columns);
options.set(
AppOptions::Debug,
app_context.args.options.contains(NotedeckOptions::Debug),
);
options.set(
AppOptions::SinceOptimize,
parsed_args.is_flag_set(ColumnsFlag::SinceOptimize),
);
let decks_cache = if tmp_columns {
info!("DecksCache: loading from command line arguments");
let mut columns: Columns = Columns::new();
let txn = Transaction::new(app_context.ndb).unwrap();
for col in &parsed_args.columns {
let timeline_kind = col.clone().into_timeline_kind();
if let Some(add_result) = columns.add_new_timeline_column(
&mut timeline_cache,
&txn,
app_context.ndb,
app_context.note_cache,
app_context.pool,
&timeline_kind,
) {
add_result.process(
app_context.ndb,
app_context.note_cache,
&txn,
&mut timeline_cache,
app_context.unknown_ids,
);
}
}
columns_to_decks_cache(app_context.i18n, columns, account)
} else if let Some(decks_cache) = crate::storage::load_decks_cache(
app_context.path,
app_context.ndb,
&mut timeline_cache,
app_context.i18n,
) {
info!(
"DecksCache: loading from disk {}",
crate::storage::DECKS_CACHE_FILE
);
decks_cache
} else {
info!("DecksCache: creating new with demo configuration");
DecksCache::new_with_demo_config(&mut timeline_cache, app_context)
//for (pk, _) in &app_context.accounts.cache {
// cache.add_deck_default(*pk);
//}
};
let support = Support::new(app_context.path);
let note_options = get_note_options(parsed_args, app_context.settings);
let jobs = JobsCache::default();
let threads = Threads::default();
Self {
subscriptions: Subscriptions::default(),
timeline_cache,
drafts: Drafts::default(),
state: DamusState::Initializing,
note_options,
options,
//frame_history: FrameHistory::default(),
view_state: ViewState::default(),
support,
decks_cache,
unrecognized_args,
jobs,
threads,
onboarding: Onboarding::default(),
}
}
/// Scroll to the top of the currently selected column. This is called
/// by the chrome when you click the toolbar
pub fn scroll_to_top(&mut self) {
self.options.insert(AppOptions::ScrollToTop)
}
pub fn columns_mut(&mut self, i18n: &mut Localization, accounts: &Accounts) -> &mut Columns {
get_active_columns_mut(i18n, accounts, &mut self.decks_cache)
}
pub fn columns(&self, accounts: &Accounts) -> &Columns {
get_active_columns(accounts, &self.decks_cache)
}
pub fn gen_subid(&self, kind: &SubKind) -> String {
if self.options.contains(AppOptions::Debug) {
format!("{kind:?}")
} else {
Uuid::new_v4().to_string()
}
}
pub fn mock<P: AsRef<Path>>(data_path: P) -> Self {
let mut i18n = Localization::default();
let decks_cache = DecksCache::default_decks_cache(&mut i18n);
let path = DataPath::new(&data_path);
let imgcache_dir = path.path(DataPathType::Cache);
let _ = std::fs::create_dir_all(imgcache_dir.clone());
let options = AppOptions::default() | AppOptions::Debug | AppOptions::TmpColumns;
let support = Support::new(&path);
Self {
subscriptions: Subscriptions::default(),
timeline_cache: TimelineCache::default(),
drafts: Drafts::default(),
state: DamusState::Initializing,
note_options: NoteOptions::default(),
//frame_history: FrameHistory::default(),
view_state: ViewState::default(),
support,
options,
decks_cache,
unrecognized_args: BTreeSet::default(),
jobs: JobsCache::default(),
threads: Threads::default(),
onboarding: Onboarding::default(),
}
}
pub fn subscriptions(&mut self) -> &mut HashMap<String, SubKind> {
&mut self.subscriptions.subs
}
pub fn unrecognized_args(&self) -> &BTreeSet<String> {
&self.unrecognized_args
}
pub fn toolbar_height() -> f32 {
48.0
}
pub fn initially_selected_toolbar_index() -> i32 {
0
}
}
fn get_note_options(args: ColumnsArgs, settings_handler: &mut SettingsHandler) -> NoteOptions {
let mut note_options = NoteOptions::default();
note_options.set(
NoteOptions::Textmode,
args.is_flag_set(ColumnsFlag::Textmode),
);
note_options.set(
NoteOptions::ScrambleText,
args.is_flag_set(ColumnsFlag::Scramble),
);
note_options.set(
NoteOptions::HideMedia,
args.is_flag_set(ColumnsFlag::NoMedia),
);
note_options.set(
NoteOptions::RepliesNewestFirst,
settings_handler.show_replies_newest_first(),
);
note_options
}
/*
fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
let stroke = ui.style().interact(&response).fg_stroke;
let radius = egui::lerp(2.0..=3.0, openness);
ui.painter()
.circle_filled(response.rect.center(), radius, stroke.color);
}
*/
#[profiling::function]
fn render_damus_mobile(
app: &mut Damus,
app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> Option<AppAction> {
//let routes = app.timelines[0].routes.clone();
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
let mut app_action: Option<AppAction> = None;
StripBuilder::new(ui)
.size(Size::remainder()) // top cell
.size(Size::exact(Damus::toolbar_height())) // bottom cell
.vertical(|mut strip| {
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav(
active_col,
ui.available_rect_before_wrap(),
app,
app_ctx,
ui,
)
.process_render_nav_response(app, app_ctx, ui);
if let Some(r) = &r {
match r {
ProcessNavResult::SwitchOccurred => {
if !app.options.contains(AppOptions::TmpColumns) {
storage::save_decks_cache(app_ctx.path, &app.decks_cache);
}
}
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
}
}
hovering_post_button(ui, app, app_ctx, rect);
});
strip.cell(|ui| {
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);
}
});
});
app_action
}
fn hovering_post_button(
ui: &mut egui::Ui,
app: &mut Damus,
app_ctx: &mut AppContext,
mut rect: egui::Rect,
) {
let should_show_compose = should_show_compose_button(&app.decks_cache, app_ctx.accounts);
let btn_id = ui.id().with("hover_post_btn");
let button_y = ui
.ctx()
.animate_bool_responsive(btn_id, should_show_compose);
rect.min.x = rect.max.x - (if is_narrow(ui.ctx()) { 60.0 } else { 100.0 } * button_y);
rect.min.y = rect.max.y - 100.0;
rect.max.x += 48.0 * (1.0 - button_y);
let darkmode = ui.ctx().style().visuals.dark_mode;
// only show the compose button on profile pages and on home
let compose_resp = ui
.put(rect, ui::post::compose_note_button(darkmode))
.on_hover_cursor(egui::CursorIcon::PointingHand);
if compose_resp.clicked() && !app.columns(app_ctx.accounts).columns().is_empty() {
// just use the some side panel logic as the desktop
DesktopSidePanel::perform_action(
&mut app.decks_cache,
app_ctx.accounts,
SidePanelAction::ComposeNote,
app_ctx.i18n,
);
}
}
/// Should we show the compose button? When in threads we should hide it, etc
fn should_show_compose_button(decks: &DecksCache, accounts: &Accounts) -> bool {
let Some(col) = decks.selected_column(accounts) else {
return false;
};
match col.router().top() {
Route::Timeline(timeline_kind) => {
match timeline_kind {
TimelineKind::List(list_kind) => match list_kind {
ListKind::Contact(_pk) => true,
},
TimelineKind::Algo(_pk) => true,
TimelineKind::Profile(_pk) => true,
TimelineKind::Universe => true,
TimelineKind::Generic(_) => true,
TimelineKind::Hashtag(_) => true,
// no!
TimelineKind::Search(_) => false,
TimelineKind::Notifications(_) => false,
}
}
Route::Thread(_) => false,
Route::Accounts(_) => false,
Route::Reply(_) => false,
Route::Quote(_) => false,
Route::Relays => false,
Route::Settings => false,
Route::ComposeNote => false,
Route::AddColumn(_) => false,
Route::EditProfile(_) => false,
Route::Support => false,
Route::NewDeck => false,
Route::Search => false,
Route::EditDeck(_) => false,
Route::Wallet(_) => false,
Route::CustomizeZapAmount(_) => false,
}
}
#[profiling::function]
fn render_damus_desktop(
app: &mut Damus,
app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui,
) -> Option<AppAction> {
let screen_size = ui.ctx().screen_rect().width();
let calc_panel_width = (screen_size
/ get_active_columns(app_ctx.accounts, &app.decks_cache).num_columns() as f32)
- 30.0;
let min_width = 320.0;
let need_scroll = calc_panel_width < min_width;
let panel_sizes = if need_scroll {
Size::exact(min_width)
} else {
Size::remainder()
};
ui.spacing_mut().item_spacing.x = 0.0;
if need_scroll {
egui::ScrollArea::horizontal()
.show(ui, |ui| timelines_view(ui, panel_sizes, app, app_ctx))
.inner
} else {
timelines_view(ui, panel_sizes, app, app_ctx)
}
}
fn timelines_view(
ui: &mut egui::Ui,
sizes: Size,
app: &mut Damus,
ctx: &mut AppContext<'_>,
) -> Option<AppAction> {
let num_cols = get_active_columns(ctx.accounts, &app.decks_cache).num_columns();
let mut side_panel_action: Option<nav::SwitchingAction> = None;
let mut responses = Vec::with_capacity(num_cols);
StripBuilder::new(ui)
.size(Size::exact(ui::side_panel::SIDE_PANEL_WIDTH))
.sizes(sizes, num_cols)
.clip(true)
.horizontal(|mut strip| {
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
let side_panel = DesktopSidePanel::new(
ctx.accounts.get_selected_account(),
&app.decks_cache,
ctx.i18n,
)
.show(ui);
if let Some(side_panel) = side_panel {
if side_panel.response.clicked() || side_panel.response.secondary_clicked() {
if let Some(action) = DesktopSidePanel::perform_action(
&mut app.decks_cache,
ctx.accounts,
side_panel.action,
ctx.i18n,
) {
side_panel_action = Some(action);
}
}
}
// debug
/*
ui.painter().rect(
rect,
0,
egui::Color32::RED,
egui::Stroke::new(1.0, egui::Color32::BLUE),
egui::StrokeKind::Inside,
);
*/
// vertical sidebar line
ui.painter().vline(
rect.right(),
rect.y_range(),
ui.visuals().widgets.noninteractive.bg_stroke,
);
});
for col_index in 0..num_cols {
strip.cell(|ui| {
let rect = ui.available_rect_before_wrap();
let v_line_stroke = ui.visuals().widgets.noninteractive.bg_stroke;
let inner_rect = {
let mut inner = rect;
inner.set_right(rect.right() - v_line_stroke.width);
inner
};
responses.push(nav::render_nav(col_index, inner_rect, app, ctx, ui));
// vertical line
ui.painter()
.vline(rect.right(), rect.y_range(), v_line_stroke);
// we need borrow ui context for processing, so proces
// responses in the last cell
if col_index == num_cols - 1 {}
});
//strip.cell(|ui| timeline::timeline_view(ui, app, timeline_ind));
}
});
// process the side panel action after so we don't change the number of columns during
// StripBuilder rendering
let mut save_cols = false;
if let Some(action) = side_panel_action {
save_cols = save_cols
|| action.process(
&mut app.timeline_cache,
&mut app.decks_cache,
ctx,
&mut app.subscriptions,
ui.ctx(),
);
}
let mut app_action: Option<AppAction> = None;
for response in responses {
let nav_result = response.process_render_nav_response(app, ctx, ui);
if let Some(nr) = &nav_result {
match nr {
ProcessNavResult::SwitchOccurred => save_cols = true,
ProcessNavResult::PfpClicked => {
app_action = Some(AppAction::ToggleChrome);
}
}
}
}
if app.options.contains(AppOptions::TmpColumns) {
save_cols = false;
}
if save_cols {
storage::save_decks_cache(ctx.path, &app.decks_cache);
}
app_action
}
impl notedeck::App for Damus {
fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
/*
self.app
.frame_history
.on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
*/
update_damus(self, ctx, ui.ctx());
render_damus(self, ctx, ui)
}
}
pub fn get_active_columns<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Columns {
get_decks(accounts, decks_cache).active().columns()
}
pub fn get_decks<'a>(accounts: &Accounts, decks_cache: &'a DecksCache) -> &'a Decks {
let key = accounts.selected_account_pubkey();
decks_cache.decks(key)
}
pub fn get_active_columns_mut<'a>(
i18n: &mut Localization,
accounts: &Accounts,
decks_cache: &'a mut DecksCache,
) -> &'a mut Columns {
get_decks_mut(i18n, accounts, decks_cache)
.active_mut()
.columns_mut()
}
pub fn get_decks_mut<'a>(
i18n: &mut Localization,
accounts: &Accounts,
decks_cache: &'a mut DecksCache,
) -> &'a mut Decks {
decks_cache.decks_mut(i18n, accounts.selected_account_pubkey())
}
fn columns_to_decks_cache(i18n: &mut Localization, cols: Columns, key: &[u8; 32]) -> DecksCache {
let mut account_to_decks: HashMap<Pubkey, Decks> = Default::default();
let decks = Decks::new(crate::decks::Deck::new_with_columns(
crate::decks::Deck::default_icon(),
tr!(i18n, "My Deck", "Title for the user's deck"),
cols,
));
let account = Pubkey::new(*key);
account_to_decks.insert(account, decks);
DecksCache::new(account_to_decks, i18n)
}