67 Commits

Author SHA1 Message Date
tyiu c1d3be4c07 WIP add system locale detection
Signed-off-by: Terry Yiu <git@tyiu.xyz>
2025-08-01 13:20:35 -04:00
William Casarin 6a08d4b1b2 ui/note: fix width instabilities because of spacing_mut
TODO: get rid of all spacing_mut in the codebase

Fixes: 9ff5753bca ("settings: use timed serializer, handle zoom properly...")
2025-07-31 17:54:53 -07:00
William Casarin d6d7e4c35e android: fix build
Fixes: dac786e60f ("chrome: remove duplication in app setup")
2025-07-31 17:29:06 -07:00
William Casarin c3499729f2 Merge notification dot by kernel
kernelkind (6):
      extract notifications filter to own method
      add `NotesFreshness` to `TimelineTab`
      set fresh from `TimelineCache`
      chrome: method to find whether there are unseen notifications
      paint unseen indicator
      use unseen notification indicator

Changelog-Added: Add notification dot on toolbar
2025-07-31 17:18:20 -07:00
William Casarin dac786e60f chrome: remove duplication in app setup
Also move debug warning to chrome so that headless
notedeck apps don't hit that.
2025-07-31 17:07:51 -07:00
kernelkind 41aa2db3c7 use unseen notification indicator
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:08:08 -04:00
kernelkind 10225158e5 paint unseen indicator
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:07:36 -04:00
kernelkind 557608db9b chrome: method to find whether there are unseen notifications
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:07:35 -04:00
kernelkind 8697a5cb0a set fresh from TimelineCache
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:07:28 -04:00
kernelkind 7aca39aae8 add NotesFreshness to TimelineTab
necessary for notifications indicator

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:07:24 -04:00
kernelkind aa467b9be0 extract notifications filter to own method
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-31 19:07:13 -04:00
William Casarin 09eeb57bd9 Merge notebook (feature gated)
This merges the experimental notebook app, which can be
enabled with --notebook.

We also switch to bitflags for notedeck options
2025-07-31 16:06:25 -07:00
William Casarin b1a5dd6cab add NotedeckOptions and feature flags, add notebook feature
This switches from bools to flags in our Args struct. We also add
notebook as an optional feature flag (--notebook) since its not ready.
2025-07-31 16:03:13 -07:00
William Casarin d12e5b363c notebook: move ui code into its own file
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:08:23 -07:00
William Casarin cc8bafddff notebook: remove redundant closure
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:08:23 -07:00
William Casarin 3766308ce6 notebook: fix node sizes
make sure we always allocate the correct amount of space,
even if we use less.

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:08:23 -07:00
William Casarin 17f72f6127 notebook: draw edges and arrows
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:08:22 -07:00
William Casarin f592015c0c notebook: fix heights of nodes
some nodes can overflow their contents, so let's use a scroll view to
fix

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:07:51 -07:00
William Casarin 1ab4eeb48c init notebook
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-31 15:07:50 -07:00
William Casarin a8c6baeacb make clippy happy 2025-07-31 11:55:39 -07:00
William Casarin a896a6ecfa Merge remote-tracking branch 'fernando/feat/persist_settings' 2025-07-31 11:48:57 -07:00
Fernando López Guevara f282363748 feat(notedeck): add cross-platform URI opener 2025-07-30 16:27:51 -07:00
William Casarin ba76b20ad2 Merge tagging fixes from kernel
Fixes the following:
1. space added after mention
2. can scroll the mention picker
3. don't lose focus of textedit after mention selection

kernelkind (6):
      rename `SearchResultsView` => `MentionPickerView`
      fix scroll regression
      mention-picker: re-add spacing from inner_margin
      mentions: don't lose focus after select mention
      TMP: update egui for better TextInputState handling
      insert space after mention selection

Fixes: https://github.com/damus-io/notedeck/issues/985
Fixes: https://github.com/damus-io/notedeck/issues/728
Fixes: https://github.com/damus-io/notedeck/issues/986

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-30 16:23:12 -07:00
kernelkind b04f50a9f6 insert space after mention selection
closes: https://github.com/damus-io/notedeck/issues/985

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 18:11:27 -04:00
kernelkind 233be47659 TMP: update egui for better TextInputState handling
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 17:45:12 -04:00
kernelkind 173972f920 mentions: don't lose focus after select mention
Closes: https://github.com/damus-io/notedeck/issues/728

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 17:45:09 -04:00
kernelkind 31ec21ea02 mention-picker: re-add spacing from inner_margin
shouldn't do this in Frame, for some reason that captures the drag

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 17:45:05 -04:00
kernelkind d3d8d7be4b fix scroll regression
Closes: https://github.com/damus-io/notedeck/issues/986

Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 17:45:02 -04:00
kernelkind 09dc101c1b rename SearchResultsView => MentionPickerView
Signed-off-by: kernelkind <kernelkind@gmail.com>
2025-07-30 17:44:51 -04:00
Fernando López Guevara 261477339b Update crates/notedeck/src/persist/settings_handler.rs
Co-authored-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2025-07-30 08:34:47 -03:00
Fernando López Guevara 9ff5753bca settings: use timed serializer, handle zoom properly, use custom text style for note body font size, added font size slider, added preview note 2025-07-29 21:43:26 -03:00
Fernando López Guevara b9e2fe5dd1 fix(media): add spacing 2025-07-29 21:38:04 -03:00
Fernando López Guevara d1a9e0020e fix(note-content): avoid empty text blocks
(cherry picked from commit baa7031c25d0f3d3e8952f49f6625252413559a3)
2025-07-29 21:34:28 -03:00
Fernando López Guevara 1163dd8461 feat(settings): persist settings to storage 2025-07-29 21:33:05 -03:00
Fernando López Guevara 692f4889cf update i18n comments for source client options
Co-authored-by: Terry Yiu <963907+tyiu@users.noreply.github.com>
2025-07-29 21:31:36 -03:00
Fernando López Guevara f2153f53dc feat(settings): allow sorting thread replies newest first 2025-07-29 21:30:35 -03:00
Fernando López Guevara 40764d7368 fix(settings): use localization 2025-07-29 21:22:39 -03:00
Fernando López Guevara be720c0f76 fix(settings): use localization 2025-07-29 21:21:06 -03:00
Fernando López Guevara 5848f1c355 refactor(settings): add settings sections methods 2025-07-29 21:09:33 -03:00
Fernando López Guevara 0dcf70bc15 feat(settings): persist settings to storage 2025-07-29 21:02:18 -03:00
William Casarin 0fc8e70180 ui/note: fix indented actionbar in non-wide mode
CI / Rustfmt + Clippy (push) Has been cancelled
CI / Check (android) (push) Has been cancelled
CI / Test (Linux) (push) Has been cancelled
CI / Test (macOS) (push) Has been cancelled
CI / Test (Windows) (push) Has been cancelled
CI / rpm/deb (aarch64) (push) Has been cancelled
CI / rpm/deb (x86_64) (push) Has been cancelled
CI / macOS dmg (aarch64) (push) Has been cancelled
CI / macOS dmg (x86_64) (push) Has been cancelled
CI / Windows Installer (aarch64) (push) Has been cancelled
CI / Windows Installer (x86_64) (push) Has been cancelled
CI / Upload Artifacts to Server (push) Has been cancelled
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 13:58:17 -07:00
William Casarin 2de6851fbd mediaviewer: decrease transition anim from 500ms to 300ms
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 13:45:22 -07:00
William Casarin f57d582307 ui: keep original design on non-narrow
Changed my mind

This reverts commit 6e81b98d2f.
This reverts commit 217f1e45da.
2025-07-29 13:25:19 -07:00
William Casarin 09e608ca75 media: less blurry media
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 13:17:52 -07:00
William Casarin 2bd636ce0a v0.5.9 - Better Media!
- Persist settings to storage
- New fullscreen media viewer with panning and zoom
- Changed note rendering to use the full screen width
- Fixed more wrapping issues
- Fixed crash on large images
- Fix nwc copy/paste
- Portugese translations
- Show locale language names instead of identifier

Fernando López Guevara (5):
      feat(settings): persist settings to storage
      fix(columns): render wide notes on narrow screen
      fix(media): edge-to-edge image display on narrow screen
      fix(media): use ScaledTexture
      fix(note_actionbar): add invisible label to stabilize section width ¯\_(ツ)_/¯

Terry Yiu (5):
      Add human-readable names to locales in settings
      Add Portuguese (Brazil) language and translations
      Export strings for translation
      Import translations
      Internationalize ShowNoteClientOptions labels

William Casarin (19):
      Fullscreen MediaViewer refactor
      images: always resize large images
      media: change is_narrow logic to is_scaled
      media/viewer: click anywhere to close
      media/viewer: fix broken culling
      media/viewer: fix flicker on escape-close
      media/viewer: fullscreen transition animations
      media/viewer: handle click-to-close interactions
      media/viewer: provide image-click provenance
      media/viewer: slower animation
      note/options: made wide the default
      threads: disable wide in threads
      ui/note: fix another reply_desc wrapping issue
      ui/note: simplify weird hack and make note of it
      ui/settings: fix small double clone nit
      ui/wallet: small refactor to use return instead of break
      wallet: fix nwc copy/paste

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 12:24:17 -07:00
William Casarin 79bf6cf126 media/viewer: fix flicker on escape-close
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 12:22:04 -07:00
Fernando López Guevara b8207106d7 feat(settings): persist settings to storage 2025-07-29 11:41:06 -07:00
William Casarin 5280028a82 media/viewer: fix broken culling
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 11:03:42 -07:00
William Casarin f4a6e8f9bb media: change is_narrow logic to is_scaled
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 10:59:27 -07:00
William Casarin 83fd6de076 Merge remote-tracking branch 'github/pr/1032' 2025-07-29 10:46:55 -07:00
William Casarin b80a0ab0f1 ui/settings: fix small double clone nit
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 10:32:39 -07:00
William Casarin e437a0db1c Merge Portuguese translations by terry #1036
Terry Yiu (5):
      Export strings for translation
      Add human-readable names to locales in settings
      Internationalize ShowNoteClientOptions labels
      Import translations
      Add Portuguese (Brazil) language and translations
2025-07-29 10:26:21 -07:00
William Casarin 6e81b98d2f note/options: made wide the default
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 10:24:38 -07:00
William Casarin 217f1e45da Revert "fix(columns): render wide notes on narrow screen"
We're just gonna make it default

This reverts commit 0f00dcf7a7.
2025-07-29 10:22:57 -07:00
William Casarin 96e0366787 threads: disable wide in threads
Since it breaks the reply line rendering

Fixes: 0f00dcf7a7 ("fix(columns): render wide notes on narrow screen")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 10:18:20 -07:00
William Casarin 2a85ee562c ui/note: simplify weird hack and make note of it
Fixes: https://github.com/damus-io/notedeck/issues/842
Fixes: f2e01f0e40 ("fix(note_actionbar): add invisible label to stabilize section width ¯\_(ツ)_/¯")
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-29 10:13:43 -07:00
William Casarin 1fabd347ca Merge remote-tracking branch 'github/pr/1031' 2025-07-29 10:08:43 -07:00
William Casarin 0087fe7dff media/viewer: slower animation
so you can actually see whats going on

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 16:37:53 -07:00
William Casarin 51f7744149 media/viewer: fullscreen transition animations
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 16:12:29 -07:00
William Casarin 6d393c9c37 media/viewer: provide image-click provenance
We will be using this for transitions

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 14:19:03 -07:00
William Casarin 5c8ab0ce07 media/viewer: handle click-to-close interactions
Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 12:19:45 -07:00
William Casarin 590ffa0680 media/viewer: click anywhere to close
this should help mobile ...

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 12:10:00 -07:00
William Casarin 3d18db8fd2 Fullscreen MediaViewer refactor
- Moved media related logic into notedeck instead of the ui crate,
  since they pertain to Images/ImageCache based systems

- Made RenderableMedia owned to make it less of a nightmware
  to work with and the perf should be negligible

- Added a ImageMetadata cache to Images. This is referenced
  whenever we encounter an image so we don't have to
  redo the work all of the time

- Relpaced our ad-hoc, hand(vibe?)-coded panning and zoom logic
  with the Scene widget, which is explicitly designed for
  this use case

- Extracted and detangle fullscreen media rendering from inside of note
  rendering.  We instead let the application decide what action they
  want to perform when note media is clicked on.

- We add an on_view_media action to MediaAction for the application to
  handle. The Columns app uses this toggle a FullscreenMedia app
  option bits whenever we get a MediaAction::ViewMedis(urls).

Signed-off-by: William Casarin <jb55@jb55.com>
2025-07-28 08:57:57 -07:00
Fernando López Guevara 661acb3a12 fix(media): use ScaledTexture 2025-07-25 16:35:22 -03:00
Fernando López Guevara 8306003f6f fix(media): edge-to-edge image display on narrow screen 2025-07-25 16:17:45 -03:00
Fernando López Guevara f2e01f0e40 fix(note_actionbar): add invisible label to stabilize section width ¯\_(ツ)_/¯ 2025-07-25 12:13:39 -03:00
Fernando López Guevara 0f00dcf7a7 fix(columns): render wide notes on narrow screen 2025-07-24 15:57:42 -03:00
88 changed files with 4725 additions and 2648 deletions
+2
View File
@@ -21,3 +21,5 @@ scripts/macos_build_secrets.sh
/tags /tags
.zed .zed
.lsp .lsp
.idea
local.properties
Generated
+268 -68
View File
@@ -765,6 +765,25 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-sys"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7"
dependencies = [
"objc-sys",
]
[[package]]
name = "block2"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e58aa60e59d8dbfcc36138f5f18be5f24394d33b38b24f7fd0b1caa33095f22f"
dependencies = [
"block-sys",
"objc2 0.5.2",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -989,6 +1008,7 @@ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
"num-traits", "num-traits",
"serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link",
] ]
@@ -1244,6 +1264,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [ dependencies = [
"powerfmt", "powerfmt",
"serde",
] ]
[[package]] [[package]]
@@ -1389,20 +1410,26 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]] [[package]]
name = "ecolor" name = "ecolor"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc)", "emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"serde", "serde",
] ]
[[package]] [[package]]
name = "eframe" name = "eframe"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ahash", "ahash",
"bytemuck", "bytemuck",
@@ -1438,24 +1465,25 @@ dependencies = [
[[package]] [[package]]
name = "egui" name = "egui"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"accesskit", "accesskit",
"ahash", "ahash",
"backtrace", "backtrace",
"bitflags 2.9.1", "bitflags 2.9.1",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc)", "emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"epaint", "epaint",
"log", "log",
"nohash-hasher", "nohash-hasher",
"profiling", "profiling",
"serde", "serde",
"similar",
] ]
[[package]] [[package]]
name = "egui-wgpu" name = "egui-wgpu"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ahash", "ahash",
"bytemuck", "bytemuck",
@@ -1474,7 +1502,7 @@ dependencies = [
[[package]] [[package]]
name = "egui-winit" name = "egui-winit"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ahash", "ahash",
"arboard", "arboard",
@@ -1492,7 +1520,7 @@ dependencies = [
[[package]] [[package]]
name = "egui_extras" name = "egui_extras"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ahash", "ahash",
"egui", "egui",
@@ -1509,7 +1537,7 @@ dependencies = [
[[package]] [[package]]
name = "egui_glow" name = "egui_glow"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ahash", "ahash",
"bytemuck", "bytemuck",
@@ -1588,7 +1616,7 @@ checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
[[package]] [[package]]
name = "emath" name = "emath"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"serde", "serde",
@@ -1606,7 +1634,7 @@ version = "0.3.0"
dependencies = [ dependencies = [
"bech32", "bech32",
"ewebsock", "ewebsock",
"hashbrown", "hashbrown 0.15.4",
"hex", "hex",
"mio", "mio",
"nostr 0.37.0", "nostr 0.37.0",
@@ -1686,13 +1714,13 @@ dependencies = [
[[package]] [[package]]
name = "epaint" name = "epaint"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
dependencies = [ dependencies = [
"ab_glyph", "ab_glyph",
"ahash", "ahash",
"bytemuck", "bytemuck",
"ecolor", "ecolor",
"emath 0.31.1 (git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc)", "emath 0.31.1 (git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd)",
"epaint_default_fonts", "epaint_default_fonts",
"log", "log",
"nohash-hasher", "nohash-hasher",
@@ -1704,7 +1732,7 @@ dependencies = [
[[package]] [[package]]
name = "epaint_default_fonts" name = "epaint_default_fonts"
version = "0.31.1" version = "0.31.1"
source = "git+https://github.com/damus-io/egui?rev=041d4d18b16cf8be97e0d7ef5892c87436352dfc#041d4d18b16cf8be97e0d7ef5892c87436352dfc" source = "git+https://github.com/damus-io/egui?rev=a67ab901e197ce13948ff7d00aa6e07e31a68ccd#a67ab901e197ce13948ff7d00aa6e07e31a68ccd"
[[package]] [[package]]
name = "equator" name = "equator"
@@ -2280,7 +2308,7 @@ checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"gpu-descriptor-types", "gpu-descriptor-types",
"hashbrown", "hashbrown 0.15.4",
] ]
[[package]] [[package]]
@@ -2302,6 +2330,12 @@ dependencies = [
"crunchy", "crunchy",
] ]
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.15.4" version = "0.15.4"
@@ -2346,6 +2380,17 @@ dependencies = [
"arrayvec", "arrayvec",
] ]
[[package]]
name = "hex_color"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d37f101bf4c633f7ca2e4b5e136050314503dd198e78e325ea602c327c484ef0"
dependencies = [
"arrayvec",
"rand 0.8.5",
"serde",
]
[[package]] [[package]]
name = "hex_lit" name = "hex_lit"
version = "0.1.1" version = "0.1.1"
@@ -2507,6 +2552,16 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "icrate"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb69199826926eb864697bddd27f73d9fddcffc004f5733131e15b465e30642"
dependencies = [
"block2 0.4.0",
"objc2 0.5.2",
]
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.0.0" version = "2.0.0"
@@ -2665,6 +2720,17 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408" checksum = "d0263a3d970d5c054ed9312c0057b4f3bde9c0b33836d3637361d4a9e6e7a408"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
"serde",
]
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.9.0" version = "2.9.0"
@@ -2672,7 +2738,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown", "hashbrown 0.15.4",
"serde", "serde",
] ]
@@ -2744,25 +2810,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "is-docker"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
dependencies = [
"once_cell",
]
[[package]]
name = "is-wsl"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
dependencies = [
"is-docker",
"once_cell",
]
[[package]] [[package]]
name = "itertools" name = "itertools"
version = "0.10.5" version = "0.10.5"
@@ -2879,6 +2926,19 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsoncanvas"
version = "0.1.6"
source = "git+https://github.com/jb55/jsoncanvas?rev=ae60f96e4d022cf037e086b793cacc3225bc14e5#ae60f96e4d022cf037e086b793cacc3225bc14e5"
dependencies = [
"hex_color",
"serde",
"serde_json",
"serde_with",
"thiserror 1.0.69",
"url",
]
[[package]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@@ -3201,7 +3261,7 @@ dependencies = [
"cfg_aliases", "cfg_aliases",
"codespan-reporting", "codespan-reporting",
"hexf-parse", "hexf-parse",
"indexmap", "indexmap 2.9.0",
"log", "log",
"rustc-hash 1.1.0", "rustc-hash 1.1.0",
"spirv", "spirv",
@@ -3418,21 +3478,24 @@ dependencies = [
[[package]] [[package]]
name = "notedeck" name = "notedeck"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"base32", "base32",
"bech32", "bech32",
"bincode", "bincode",
"bitflags 2.9.1",
"blurhash",
"dirs", "dirs",
"eframe", "eframe",
"egui", "egui",
"egui-winit", "egui-winit",
"egui_extras",
"ehttp", "ehttp",
"enostr", "enostr",
"fluent", "fluent",
"fluent-langneg", "fluent-langneg",
"fluent-resmgr", "fluent-resmgr",
"hashbrown", "hashbrown 0.15.4",
"hex", "hex",
"image", "image",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)", "jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -3454,6 +3517,7 @@ dependencies = [
"sha2", "sha2",
"strum", "strum",
"strum_macros", "strum_macros",
"sys-locale",
"tempfile", "tempfile",
"thiserror 2.0.12", "thiserror 2.0.12",
"tokenator", "tokenator",
@@ -3466,7 +3530,7 @@ dependencies = [
[[package]] [[package]]
name = "notedeck_chrome" name = "notedeck_chrome"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"eframe", "eframe",
"egui", "egui",
@@ -3477,6 +3541,7 @@ dependencies = [
"notedeck", "notedeck",
"notedeck_columns", "notedeck_columns",
"notedeck_dave", "notedeck_dave",
"notedeck_notebook",
"notedeck_ui", "notedeck_ui",
"profiling", "profiling",
"puffin", "puffin",
@@ -3495,7 +3560,7 @@ dependencies = [
[[package]] [[package]]
name = "notedeck_columns" name = "notedeck_columns"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bech32", "bech32",
@@ -3510,16 +3575,15 @@ dependencies = [
"egui_virtual_list", "egui_virtual_list",
"ehttp", "ehttp",
"enostr", "enostr",
"hashbrown", "hashbrown 0.15.4",
"hex", "hex",
"human_format", "human_format",
"image", "image",
"indexmap", "indexmap 2.9.0",
"nostrdb", "nostrdb",
"notedeck", "notedeck",
"notedeck_ui", "notedeck_ui",
"oot_bitset", "oot_bitset",
"open",
"opener", "opener",
"poll-promise", "poll-promise",
"pretty_assertions", "pretty_assertions",
@@ -3528,6 +3592,7 @@ dependencies = [
"puffin_egui", "puffin_egui",
"rfd", "rfd",
"rmpv", "rmpv",
"robius-open",
"security-framework 2.11.1", "security-framework 2.11.1",
"serde", "serde",
"serde_derive", "serde_derive",
@@ -3549,7 +3614,7 @@ dependencies = [
[[package]] [[package]]
name = "notedeck_dave" name = "notedeck_dave"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"async-openai", "async-openai",
"bytemuck", "bytemuck",
@@ -3571,19 +3636,27 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "notedeck_notebook"
version = "0.5.9"
dependencies = [
"egui",
"jsoncanvas",
"notedeck",
]
[[package]] [[package]]
name = "notedeck_ui" name = "notedeck_ui"
version = "0.5.8" version = "0.5.9"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"blurhash",
"eframe", "eframe",
"egui", "egui",
"egui-winit", "egui-winit",
"egui_extras", "egui_extras",
"ehttp", "ehttp",
"enostr", "enostr",
"hashbrown", "hashbrown 0.15.4",
"image", "image",
"nostrdb", "nostrdb",
"notedeck", "notedeck",
@@ -4012,17 +4085,6 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
[[package]]
name = "open"
version = "5.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
dependencies = [
"is-wsl",
"libc",
"pathdiff",
]
[[package]] [[package]]
name = "opener" name = "opener"
version = "0.8.2" version = "0.8.2"
@@ -4135,12 +4197,6 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@@ -4429,7 +4485,7 @@ source = "git+https://github.com/jb55/puffin?rev=c6a6242adaf90b6292c0f462d2acd34
dependencies = [ dependencies = [
"egui", "egui",
"egui_extras", "egui_extras",
"indexmap", "indexmap 2.9.0",
"natord", "natord",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
@@ -4757,6 +4813,26 @@ dependencies = [
"thiserror 1.0.69", "thiserror 1.0.69",
] ]
[[package]]
name = "ref-cast"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.11.1" version = "1.11.1"
@@ -4948,6 +5024,30 @@ dependencies = [
"rmp", "rmp",
] ]
[[package]]
name = "robius-android-env"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "087fcb3061ccc432658a605cb868edd44e0efb08e7a159b486f02804a7616bef"
dependencies = [
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"ndk-context",
]
[[package]]
name = "robius-open"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "243e2abbc8c1ca8ddc283056d4675b67e452fd527c3741c5318642da37840ff3"
dependencies = [
"cfg-if",
"icrate",
"jni 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
"objc2 0.5.2",
"robius-android-env",
"windows 0.54.0",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.19.0" version = "0.19.0"
@@ -5094,6 +5194,30 @@ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.59.0",
] ]
[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]]
name = "schemars"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0"
dependencies = [
"dyn-clone",
"ref-cast",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "scoped-tls" name = "scoped-tls"
version = "1.0.1" version = "1.0.1"
@@ -5247,7 +5371,7 @@ version = "1.0.140"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.9.0",
"itoa", "itoa",
"memchr", "memchr",
"ryu", "ryu",
@@ -5286,6 +5410,38 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "serde_with"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2c45cd61fefa9db6f254525d46e392b852e0e61d9a1fd36e5bd183450a556d5"
dependencies = [
"base64 0.22.1",
"chrono",
"hex",
"indexmap 1.9.3",
"indexmap 2.9.0",
"schemars 0.9.0",
"schemars 1.0.4",
"serde",
"serde_derive",
"serde_json",
"serde_with_macros",
"time",
]
[[package]]
name = "serde_with_macros"
version = "3.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de90945e6565ce0d9a25098082ed4ee4002e047cb59892c318d66821e14bb30f"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.104",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.6" version = "0.10.6"
@@ -5347,6 +5503,12 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "similar"
version = "2.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
[[package]] [[package]]
name = "simplecss" name = "simplecss"
version = "0.2.2" version = "0.2.2"
@@ -5560,6 +5722,15 @@ dependencies = [
"syn 2.0.104", "syn 2.0.104",
] ]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "sysinfo" name = "sysinfo"
version = "0.30.13" version = "0.30.13"
@@ -5880,7 +6051,7 @@ version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [ dependencies = [
"indexmap", "indexmap 2.9.0",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime",
@@ -6663,7 +6834,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"cfg_aliases", "cfg_aliases",
"document-features", "document-features",
"indexmap", "indexmap 2.9.0",
"log", "log",
"naga", "naga",
"once_cell", "once_cell",
@@ -6784,6 +6955,16 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9252e5725dbed82865af151df558e754e4a3c2c30818359eb17465f1346a1b49"
dependencies = [
"windows-core 0.54.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.58.0" version = "0.58.0"
@@ -6803,6 +6984,16 @@ dependencies = [
"windows-targets 0.52.6", "windows-targets 0.52.6",
] ]
[[package]]
name = "windows-core"
version = "0.54.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12661b9c89351d684a50a8a643ce5f608e20243b9fb84687800163429f161d65"
dependencies = [
"windows-result 0.1.2",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.58.0" version = "0.58.0"
@@ -6879,6 +7070,15 @@ version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]]
name = "windows-result"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.2.0" version = "0.2.0"
+13 -8
View File
@@ -1,11 +1,12 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
package.version = "0.5.8" package.version = "0.5.9"
members = [ members = [
"crates/notedeck", "crates/notedeck",
"crates/notedeck_chrome", "crates/notedeck_chrome",
"crates/notedeck_columns", "crates/notedeck_columns",
"crates/notedeck_dave", "crates/notedeck_dave",
"crates/notedeck_notebook",
"crates/notedeck_ui", "crates/notedeck_ui",
"crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui", "crates/enostr", "crates/tokenator", "crates/notedeck_dave", "crates/notedeck_ui",
@@ -48,10 +49,11 @@ notedeck = { path = "crates/notedeck" }
notedeck_chrome = { path = "crates/notedeck_chrome" } notedeck_chrome = { path = "crates/notedeck_chrome" }
notedeck_columns = { path = "crates/notedeck_columns" } notedeck_columns = { path = "crates/notedeck_columns" }
notedeck_dave = { path = "crates/notedeck_dave" } notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_notebook = { path = "crates/notedeck_notebook" }
notedeck_ui = { path = "crates/notedeck_ui" } notedeck_ui = { path = "crates/notedeck_ui" }
tokenator = { path = "crates/tokenator" } tokenator = { path = "crates/tokenator" }
once_cell = "1.19.0" once_cell = "1.19.0"
open = "5.3.0" robius-open = "0.1"
poll-promise = { version = "0.3.0", features = ["tokio"] } poll-promise = { version = "0.3.0", features = ["tokio"] }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" } puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" } puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
@@ -67,6 +69,7 @@ tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tempfile = "3.13.0" tempfile = "3.13.0"
unic-langid = { version = "0.9.6", features = ["macros"] } unic-langid = { version = "0.9.6", features = ["macros"] }
sys-locale = "0.3"
url = "2.5.2" url = "2.5.2"
urlencoding = "2.1.3" urlencoding = "2.1.3"
uuid = { version = "1.10.0", features = ["v4"] } uuid = { version = "1.10.0", features = ["v4"] }
@@ -82,6 +85,8 @@ hashbrown = "0.15.2"
openai-api-rs = "6.0.3" openai-api-rs = "6.0.3"
re_memory = "0.23.4" re_memory = "0.23.4"
oot_bitset = "0.1.1" oot_bitset = "0.1.1"
blurhash = "0.2.3"
[profile.small] [profile.small]
inherits = 'release' inherits = 'release'
@@ -99,12 +104,12 @@ strip = true # Strip symbols from binary*
#egui_extras = { path = "/home/jb55/dev/github/emilk/egui/crates/egui_extras" } #egui_extras = { path = "/home/jb55/dev/github/emilk/egui/crates/egui_extras" }
#epaint = { path = "/home/jb55/dev/github/emilk/egui/crates/epaint" } #epaint = { path = "/home/jb55/dev/github/emilk/egui/crates/epaint" }
egui = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } egui = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
eframe = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } eframe = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-winit = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } egui-winit = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } egui-wgpu = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
egui_extras = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } egui_extras = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
epaint = { git = "https://github.com/damus-io/egui", rev = "041d4d18b16cf8be97e0d7ef5892c87436352dfc" } epaint = { git = "https://github.com/damus-io/egui", rev = "a67ab901e197ce13948ff7d00aa6e07e31a68ccd" }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" } puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" } puffin_egui = { git = "https://github.com/jb55/puffin", package = "puffin_egui", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
#winit = { git = "https://github.com/damus-io/winit", rev = "14d61a74bee0c9863abe7ef28efae2c4d8bd3743" } #winit = { git = "https://github.com/damus-io/winit", rev = "14d61a74bee0c9863abe7ef28efae2c4d8bd3743" }
+4
View File
@@ -9,11 +9,13 @@ nostrdb = { workspace = true }
jni = { workspace = true } jni = { workspace = true }
url = { workspace = true } url = { workspace = true }
strum = { workspace = true } strum = { workspace = true }
blurhash = { workspace = true }
strum_macros = { workspace = true } strum_macros = { workspace = true }
dirs = { workspace = true } dirs = { workspace = true }
enostr = { workspace = true } enostr = { workspace = true }
nostr = { workspace = true } nostr = { workspace = true }
egui = { workspace = true } egui = { workspace = true }
egui_extras = { workspace = true }
eframe = { workspace = true } eframe = { workspace = true }
image = { workspace = true } image = { workspace = true }
base32 = { workspace = true } base32 = { workspace = true }
@@ -43,8 +45,10 @@ fluent = { workspace = true }
fluent-resmgr = { workspace = true } fluent-resmgr = { workspace = true }
fluent-langneg = { workspace = true } fluent-langneg = { workspace = true }
unic-langid = { workspace = true } unic-langid = { workspace = true }
sys-locale = { workspace = true }
once_cell = { workspace = true } once_cell = { workspace = true }
md5 = { workspace = true } md5 = { workspace = true }
bitflags = { workspace = true }
regex = "1" regex = "1"
[dev-dependencies] [dev-dependencies]
+79 -21
View File
@@ -1,13 +1,14 @@
use crate::account::FALLBACK_PUBKEY; use crate::account::FALLBACK_PUBKEY;
use crate::i18n::Localization; use crate::i18n::Localization;
use crate::persist::{AppSizeHandler, ZoomHandler}; use crate::persist::{AppSizeHandler, SettingsHandler};
use crate::wallet::GlobalWallet; use crate::wallet::GlobalWallet;
use crate::zaps::Zaps; use crate::zaps::Zaps;
use crate::Error;
use crate::JobPool; use crate::JobPool;
use crate::NotedeckOptions;
use crate::{ use crate::{
frame_history::FrameHistory, AccountStorage, Accounts, AppContext, Args, DataPath, frame_history::FrameHistory, AccountStorage, Accounts, AppContext, Args, DataPath,
DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, ThemeHandler, DataPathType, Directory, Images, NoteAction, NoteCache, RelayDebugView, UnknownIds,
UnknownIds,
}; };
use egui::Margin; use egui::Margin;
use egui::ThemePreference; use egui::ThemePreference;
@@ -19,6 +20,7 @@ use std::collections::BTreeSet;
use std::path::Path; use std::path::Path;
use std::rc::Rc; use std::rc::Rc;
use tracing::{error, info}; use tracing::{error, info};
use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
pub enum AppAction { pub enum AppAction {
Note(NoteAction), Note(NoteAction),
@@ -40,9 +42,8 @@ pub struct Notedeck {
global_wallet: GlobalWallet, global_wallet: GlobalWallet,
path: DataPath, path: DataPath,
args: Args, args: Args,
theme: ThemeHandler, settings: SettingsHandler,
app: Option<Rc<RefCell<dyn App>>>, app: Option<Rc<RefCell<dyn App>>>,
zoom: ZoomHandler,
app_size: AppSizeHandler, app_size: AppSizeHandler,
unrecognized_args: BTreeSet<String>, unrecognized_args: BTreeSet<String>,
clipboard: Clipboard, clipboard: Clipboard,
@@ -99,10 +100,18 @@ impl eframe::App for Notedeck {
render_notedeck(self, ctx); render_notedeck(self, ctx);
self.zoom.try_save_zoom_factor(ctx); self.settings.update_batch(|settings| {
settings.zoom_factor = ctx.zoom_factor();
settings.locale = self.i18n.get_current_locale().to_string();
settings.theme = if ctx.style().visuals.dark_mode {
ThemePreference::Dark
} else {
ThemePreference::Light
};
});
self.app_size.try_save_app_size(ctx); self.app_size.try_save_app_size(ctx);
if self.args.relay_debug { if self.args.options.contains(NotedeckOptions::RelayDebug) {
if self.pool.debug.is_none() { if self.pool.debug.is_none() {
self.pool.use_debug(); self.pool.use_debug();
} }
@@ -159,10 +168,11 @@ impl Notedeck {
1024usize * 1024usize * 1024usize * 1024usize 1024usize * 1024usize * 1024usize * 1024usize
}; };
let theme = ThemeHandler::new(&path); let settings = SettingsHandler::new(&path).load();
let config = Config::new().set_ingester_threads(2).set_mapsize(map_size); let config = Config::new().set_ingester_threads(2).set_mapsize(map_size);
let keystore = if parsed_args.use_keystore { let keystore = if parsed_args.options.contains(NotedeckOptions::UseKeystore) {
let keys_path = path.path(DataPathType::Keys); let keys_path = path.path(DataPathType::Keys);
let selected_key_path = path.path(DataPathType::SelectedKey); let selected_key_path = path.path(DataPathType::SelectedKey);
Some(AccountStorage::new( Some(AccountStorage::new(
@@ -213,12 +223,8 @@ impl Notedeck {
let img_cache = Images::new(img_cache_dir); let img_cache = Images::new(img_cache_dir);
let note_cache = NoteCache::default(); let note_cache = NoteCache::default();
let zoom = ZoomHandler::new(&path);
let app_size = AppSizeHandler::new(&path);
if let Some(z) = zoom.get_zoom_factor() { let app_size = AppSizeHandler::new(&path);
ctx.set_zoom_factor(z);
}
// migrate // migrate
if let Err(e) = img_cache.migrate_v0() { if let Err(e) = img_cache.migrate_v0() {
@@ -231,15 +237,22 @@ impl Notedeck {
// Initialize localization // Initialize localization
let mut i18n = Localization::new(); let mut i18n = Localization::new();
let setting_locale: Result<LanguageIdentifier, LanguageIdentifierError> =
settings.locale().parse();
if setting_locale.is_ok() {
if let Err(err) = i18n.set_locale(setting_locale.unwrap()) {
error!("{err}");
}
}
if let Some(locale) = &parsed_args.locale { if let Some(locale) = &parsed_args.locale {
if let Err(err) = i18n.set_locale(locale.to_owned()) { if let Err(err) = i18n.set_locale(locale.to_owned()) {
error!("{err}"); error!("{err}");
} }
} }
// Initialize global i18n context
//crate::i18n::init_global_i18n(i18n.clone());
Self { Self {
ndb, ndb,
img_cache, img_cache,
@@ -250,9 +263,8 @@ impl Notedeck {
global_wallet, global_wallet,
path: path.clone(), path: path.clone(),
args: parsed_args, args: parsed_args,
theme, settings,
app: None, app: None,
zoom,
app_size, app_size,
unrecognized_args, unrecognized_args,
frame_history: FrameHistory::default(), frame_history: FrameHistory::default(),
@@ -263,6 +275,44 @@ impl Notedeck {
} }
} }
/// Setup egui context
pub fn setup(&self, ctx: &egui::Context) {
// Initialize global i18n context
//crate::i18n::init_global_i18n(i18n.clone());
crate::setup::setup_egui_context(
ctx,
self.args.options,
self.theme(),
self.note_body_font_size(),
self.zoom_factor(),
);
}
/// ensure we recognized all the arguments
pub fn check_args(&self, other_app_args: &BTreeSet<String>) -> Result<(), Error> {
let completely_unrecognized: Vec<String> = self
.unrecognized_args()
.intersection(other_app_args)
.cloned()
.collect();
if !completely_unrecognized.is_empty() {
let err = format!("Unrecognized arguments: {:?}", completely_unrecognized);
tracing::error!("{}", &err);
return Err(Error::Generic(err));
}
Ok(())
}
#[inline]
pub fn options(&self) -> NotedeckOptions {
self.args.options
}
pub fn has_option(&self, option: NotedeckOptions) -> bool {
self.options().contains(option)
}
pub fn app<A: App + 'static>(mut self, app: A) -> Self { pub fn app<A: App + 'static>(mut self, app: A) -> Self {
self.set_app(app); self.set_app(app);
self self
@@ -279,7 +329,7 @@ impl Notedeck {
global_wallet: &mut self.global_wallet, global_wallet: &mut self.global_wallet,
path: &self.path, path: &self.path,
args: &self.args, args: &self.args,
theme: &mut self.theme, settings: &mut self.settings,
clipboard: &mut self.clipboard, clipboard: &mut self.clipboard,
zaps: &mut self.zaps, zaps: &mut self.zaps,
frame_history: &mut self.frame_history, frame_history: &mut self.frame_history,
@@ -297,7 +347,15 @@ impl Notedeck {
} }
pub fn theme(&self) -> ThemePreference { pub fn theme(&self) -> ThemePreference {
self.theme.load() self.settings.theme()
}
pub fn note_body_font_size(&self) -> f32 {
self.settings.note_body_font_size()
}
pub fn zoom_factor(&self) -> f32 {
self.settings.zoom_factor()
} }
pub fn unrecognized_args(&self) -> &BTreeSet<String> { pub fn unrecognized_args(&self) -> &BTreeSet<String> {
+14 -26
View File
@@ -1,23 +1,15 @@
use std::collections::BTreeSet; use std::collections::BTreeSet;
use crate::NotedeckOptions;
use enostr::{Keypair, Pubkey, SecretKey}; use enostr::{Keypair, Pubkey, SecretKey};
use tracing::error; use tracing::error;
use unic_langid::{LanguageIdentifier, LanguageIdentifierError}; use unic_langid::{LanguageIdentifier, LanguageIdentifierError};
pub struct Args { pub struct Args {
pub relays: Vec<String>, pub relays: Vec<String>,
pub is_mobile: Option<bool>,
pub locale: Option<LanguageIdentifier>, pub locale: Option<LanguageIdentifier>,
pub show_note_client: bool,
pub keys: Vec<Keypair>, pub keys: Vec<Keypair>,
pub light: bool, pub options: NotedeckOptions,
pub debug: bool,
pub relay_debug: bool,
/// Enable when running tests so we don't panic on app startup
pub tests: bool,
pub use_keystore: bool,
pub dbpath: Option<String>, pub dbpath: Option<String>,
pub datapath: Option<String>, pub datapath: Option<String>,
} }
@@ -28,14 +20,8 @@ impl Args {
let mut unrecognized_args = BTreeSet::new(); let mut unrecognized_args = BTreeSet::new();
let mut res = Args { let mut res = Args {
relays: vec![], relays: vec![],
is_mobile: None,
keys: vec![], keys: vec![],
light: false, options: NotedeckOptions::default(),
show_note_client: false,
debug: false,
relay_debug: false,
tests: false,
use_keystore: true,
dbpath: None, dbpath: None,
datapath: None, datapath: None,
locale: None, locale: None,
@@ -47,9 +33,9 @@ impl Args {
let arg = &args[i]; let arg = &args[i];
if arg == "--mobile" { if arg == "--mobile" {
res.is_mobile = Some(true); res.options.set(NotedeckOptions::Mobile, true);
} else if arg == "--light" { } else if arg == "--light" {
res.light = true; res.options.set(NotedeckOptions::LightTheme, true);
} else if arg == "--locale" { } else if arg == "--locale" {
i += 1; i += 1;
let Some(locale) = args.get(i) else { let Some(locale) = args.get(i) else {
@@ -68,11 +54,11 @@ impl Args {
} }
} }
} else if arg == "--dark" { } else if arg == "--dark" {
res.light = false; res.options.set(NotedeckOptions::LightTheme, false);
} else if arg == "--debug" { } else if arg == "--debug" {
res.debug = true; res.options.set(NotedeckOptions::Debug, true);
} else if arg == "--testrunner" { } else if arg == "--testrunner" {
res.tests = true; res.options.set(NotedeckOptions::Tests, true);
} else if arg == "--pub" || arg == "--npub" { } else if arg == "--pub" || arg == "--npub" {
i += 1; i += 1;
let pubstr = if let Some(next_arg) = args.get(i) { let pubstr = if let Some(next_arg) = args.get(i) {
@@ -135,11 +121,13 @@ impl Args {
}; };
res.relays.push(relay.clone()); res.relays.push(relay.clone());
} else if arg == "--no-keystore" { } else if arg == "--no-keystore" {
res.use_keystore = false; res.options.set(NotedeckOptions::UseKeystore, true);
} else if arg == "--relay-debug" { } else if arg == "--relay-debug" {
res.relay_debug = true; res.options.set(NotedeckOptions::RelayDebug, true);
} else if arg == "--show-note-client" { } else if arg == "--show-client" {
res.show_note_client = true; res.options.set(NotedeckOptions::ShowClient, true);
} else if arg == "--notebook" {
res.options.set(NotedeckOptions::FeatureNotebook, true);
} else { } else {
unrecognized_args.insert(arg.clone()); unrecognized_args.insert(arg.clone());
} }
+2 -2
View File
@@ -1,6 +1,6 @@
use crate::{ use crate::{
account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization, account::accounts::Accounts, frame_history::FrameHistory, i18n::Localization,
wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, ThemeHandler, wallet::GlobalWallet, zaps::Zaps, Args, DataPath, Images, JobPool, NoteCache, SettingsHandler,
UnknownIds, UnknownIds,
}; };
use egui_winit::clipboard::Clipboard; use egui_winit::clipboard::Clipboard;
@@ -20,7 +20,7 @@ pub struct AppContext<'a> {
pub global_wallet: &'a mut GlobalWallet, pub global_wallet: &'a mut GlobalWallet,
pub path: &'a DataPath, pub path: &'a DataPath,
pub args: &'a Args, pub args: &'a Args,
pub theme: &'a mut ThemeHandler, pub settings: &'a mut SettingsHandler,
pub clipboard: &'a mut Clipboard, pub clipboard: &'a mut Clipboard,
pub zaps: &'a mut Zaps, pub zaps: &'a mut Zaps,
pub frame_history: &'a mut FrameHistory, pub frame_history: &'a mut FrameHistory,
+152
View File
@@ -1,4 +1,9 @@
use crate::{ui, NotedeckTextStyle}; use crate::{ui, NotedeckTextStyle};
use egui::FontData;
use egui::FontDefinitions;
use egui::FontTweak;
use std::collections::BTreeMap;
use std::sync::Arc;
pub enum NamedFontFamily { pub enum NamedFontFamily {
Medium, Medium,
@@ -31,6 +36,7 @@ pub fn desktop_font_size(text_style: &NotedeckTextStyle) -> f32 {
NotedeckTextStyle::Button => 13.0, NotedeckTextStyle::Button => 13.0,
NotedeckTextStyle::Small => 12.0, NotedeckTextStyle::Small => 12.0,
NotedeckTextStyle::Tiny => 10.0, NotedeckTextStyle::Tiny => 10.0,
NotedeckTextStyle::NoteBody => 16.0,
} }
} }
@@ -46,6 +52,7 @@ pub fn mobile_font_size(text_style: &NotedeckTextStyle) -> f32 {
NotedeckTextStyle::Button => 13.0, NotedeckTextStyle::Button => 13.0,
NotedeckTextStyle::Small => 12.0, NotedeckTextStyle::Small => 12.0,
NotedeckTextStyle::Tiny => 10.0, NotedeckTextStyle::Tiny => 10.0,
NotedeckTextStyle::NoteBody => 13.0,
} }
} }
@@ -56,3 +63,148 @@ pub fn get_font_size(ctx: &egui::Context, text_style: &NotedeckTextStyle) -> f32
desktop_font_size(text_style) desktop_font_size(text_style)
} }
} }
// Use gossip's approach to font loading. This includes japanese fonts
// for rending stuff from japanese users.
pub fn setup_fonts(ctx: &egui::Context) {
let mut font_data: BTreeMap<String, Arc<FontData>> = BTreeMap::new();
let mut families = BTreeMap::new();
font_data.insert(
"Onest".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestRegular1602-hint.ttf"
))),
);
font_data.insert(
"OnestMedium".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestMedium1602-hint.ttf"
))),
);
font_data.insert(
"DejaVuSans".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/DejaVuSansSansEmoji.ttf"
))),
);
font_data.insert(
"OnestBold".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestBold1602-hint.ttf"
))),
);
/*
font_data.insert(
"DejaVuSansBold".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf"
)),
);
font_data.insert(
"DejaVuSans".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")),
);
font_data.insert(
"DejaVuSansBold".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf"
)),
);
*/
font_data.insert(
"Inconsolata".to_owned(),
Arc::new(
FontData::from_static(include_bytes!(
"../../../assets/fonts/Inconsolata-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.22, // This font is smaller than DejaVuSans
y_offset_factor: -0.18, // and too low
y_offset: 0.0,
baseline_offset_factor: 0.0,
}),
),
);
font_data.insert(
"NotoSansCJK".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansCJK-Regular.ttc"
))),
);
font_data.insert(
"NotoSansThai".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansThai-Regular.ttf"
))),
);
// Some good looking emojis. Use as first priority:
font_data.insert(
"NotoEmoji".to_owned(),
Arc::new(
FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoEmoji-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.1, // make them a touch larger
y_offset_factor: 0.0,
y_offset: 0.0,
baseline_offset_factor: 0.0,
}),
),
);
let base_fonts = vec![
"DejaVuSans".to_owned(),
"NotoEmoji".to_owned(),
"NotoSansCJK".to_owned(),
"NotoSansThai".to_owned(),
];
let mut proportional = vec!["Onest".to_owned()];
proportional.extend(base_fonts.clone());
let mut medium = vec!["OnestMedium".to_owned()];
medium.extend(base_fonts.clone());
let mut mono = vec!["Inconsolata".to_owned()];
mono.extend(base_fonts.clone());
let mut bold = vec!["OnestBold".to_owned()];
bold.extend(base_fonts.clone());
let emoji = vec!["NotoEmoji".to_owned()];
families.insert(egui::FontFamily::Proportional, proportional);
families.insert(egui::FontFamily::Monospace, mono);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Medium.as_str().into()),
medium,
);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
bold,
);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()),
emoji,
);
tracing::debug!("fonts: {:?}", families);
let defs = FontDefinitions {
font_data,
families,
};
ctx.set_fonts(defs);
}
+232 -19
View File
@@ -3,6 +3,7 @@ use fluent::{FluentArgs, FluentBundle, FluentResource};
use fluent_langneg::negotiate_languages; use fluent_langneg::negotiate_languages;
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashMap; use std::collections::HashMap;
use sys_locale;
use unic_langid::{langid, LanguageIdentifier}; use unic_langid::{langid, LanguageIdentifier};
const EN_US: LanguageIdentifier = langid!("en-US"); const EN_US: LanguageIdentifier = langid!("en-US");
@@ -101,10 +102,6 @@ pub struct Localization {
impl Default for Localization { impl Default for Localization {
fn default() -> Self { fn default() -> Self {
// Default to English (US)
let default_locale = &EN_US;
let fallback_locale = default_locale.to_owned();
// Build available locales list // Build available locales list
let available_locales = vec![ let available_locales = vec![
EN_US.clone(), EN_US.clone(),
@@ -132,8 +129,20 @@ impl Default for Localization {
(ZH_TW, ZH_TW_NATIVE_NAME.to_owned()), (ZH_TW, ZH_TW_NATIVE_NAME.to_owned()),
]); ]);
// Detect system locale and find best match
let current_locale = Self::negotiate_system_locale_with_preferences(&available_locales);
// Fallback locale is always EN_US
let fallback_locale = EN_US.clone();
tracing::info!(
"Localization initialized - Selected locale: {}, Fallback: {}",
current_locale,
fallback_locale
);
Self { Self {
current_locale: default_locale.to_owned(), current_locale,
available_locales, available_locales,
fallback_locale, fallback_locale,
locale_native_names, locale_native_names,
@@ -159,6 +168,150 @@ impl Localization {
} }
} }
/// Extract just the language and region from locale string (e.g., "fr-FR-u-mu-celsius" -> "fr-FR")
fn extract_language_region(locale_str: &str) -> String {
// Split by '-' and analyze the parts
let parts: Vec<&str> = locale_str.split('-').collect();
if parts.len() >= 2 {
// Check if the second part looks like a region
let second_part = parts[1];
if (second_part.len() >= 2) {
format!("{}-{}", parts[0], parts[1])
} else {
// Second part is not a region, probably an extension (e.g., "u", "t", "x")
// Just return the language part
parts[0].to_string()
}
} else {
// Only one part, return as is
locale_str.to_string()
}
}
/// Negotiate the best locale from all system preferences against available locales
fn negotiate_system_locale_with_preferences(
available_locales: &[LanguageIdentifier],
) -> LanguageIdentifier {
// Get all system preferred locales in descending order
let mut system_locales: Vec<String> = sys_locale::get_locales().collect();
if system_locales.is_empty() {
tracing::info!("No system locales detected, using fallback: en-US");
return EN_US.clone();
}
tracing::info!("System preferred locales: {:?}", system_locales);
// If we only got one locale, it might be that the system only returns the primary locale
// In this case, we can try to add common fallbacks based on the detected locale
if system_locales.len() == 1 {
let primary = &system_locales[0];
// Try to parse the primary locale, handling extensions
let primary_lang = if let Ok(locale) = primary.parse::<LanguageIdentifier>() {
locale.language.as_str().to_string()
} else {
// If parsing fails, try extracting language-region
// let stripped = Self::extract_language_region(primary);
// if let Ok(locale) = stripped.parse::<LanguageIdentifier>() {
// locale.language.as_str().to_string()
// } else {
tracing::info!("Could not parse primary locale: {}", primary);
"unknown".to_string()
// }
};
tracing::info!(
"Only one system locale detected: {} (language: {})",
primary,
primary_lang
);
// Add common fallbacks for the detected language
match primary_lang.as_str() {
"uk" => {
// For Ukrainian, add common fallbacks
system_locales.push("es-ES".to_string());
system_locales.push("en-US".to_string());
tracing::info!("Added fallbacks for Ukrainian: {:?}", system_locales);
}
"es" => {
// For Spanish, add English fallback
system_locales.push("en-US".to_string());
tracing::info!("Added fallback for Spanish: {:?}", system_locales);
}
_ => {
// For other languages, add English fallback
system_locales.push("en-US".to_string());
tracing::info!("Added fallback for {}: {:?}", primary_lang, system_locales);
}
}
}
// Convert system locale strings to LanguageIdentifiers, handling extensions
let mut parsed_system_locales = Vec::new();
for locale_str in system_locales {
// Try to parse the locale string directly first
if let Ok(locale) = locale_str.parse::<LanguageIdentifier>() {
parsed_system_locales.push(locale);
continue;
}
// If parsing fails, try extracting just language-region
// let stripped_locale = Self::extract_language_region(&locale_str);
// if let Ok(locale) = stripped_locale.parse::<LanguageIdentifier>() {
// parsed_system_locales.push(locale);
// continue;
// }
tracing::info!("Failed to parse locale string: {}", locale_str);
}
if parsed_system_locales.is_empty() {
tracing::info!("No valid system locales parsed, using fallback: en-US");
return EN_US.clone();
}
// First try exact matches with fluent_langneg
let fallback = &EN_US;
let negotiated = negotiate_languages(
&parsed_system_locales,
available_locales,
Some(fallback),
fluent_langneg::NegotiationStrategy::Filtering,
);
if let Some(result) = negotiated.first() {
tracing::info!(
"Exact match found: {} from preferences: {:?}",
result,
parsed_system_locales
);
return (*result).clone();
}
// If no exact match, try language-only fallbacks
tracing::info!("No exact matches found, trying language-only fallbacks");
for system_locale in &parsed_system_locales {
let system_lang = system_locale.language.as_str();
// Look for any available locale with the same language
for available_locale in available_locales {
if available_locale.language.as_str() == system_lang {
tracing::debug!(
"Language match found: {} (system: {})",
available_locale,
system_locale
);
return available_locale.clone();
}
}
}
tracing::info!("No language matches found, using fallback: en-US");
EN_US.clone()
}
/// Gets a localized string by its ID /// Gets a localized string by its ID
pub fn get_string(&mut self, id: IntlKey<'_>) -> Result<String, IntlError> { pub fn get_string(&mut self, id: IntlKey<'_>) -> Result<String, IntlError> {
self.get_cached_string(id, None) self.get_cached_string(id, None)
@@ -458,20 +611,6 @@ impl Localization {
Ok(()) Ok(())
} }
/// Negotiates the best locale from a list of preferred locales
pub fn negotiate_locale(&self, preferred: &[LanguageIdentifier]) -> LanguageIdentifier {
let available = self.available_locales.clone();
let negotiated = negotiate_languages(
preferred,
&available,
Some(&self.fallback_locale),
fluent_langneg::NegotiationStrategy::Filtering,
);
negotiated
.first()
.map_or(self.fallback_locale.clone(), |v| (*v).clone())
}
} }
/// Statistics about cache usage /// Statistics about cache usage
@@ -484,6 +623,80 @@ pub struct CacheStats {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*;
#[test]
fn test_extract_language_region() {
// Test that we extract just language and region from various locale formats
// Test locales with extensions
let unicode_locale = "fr-FR-u-mu-celsius";
let extracted = Localization::extract_language_region(unicode_locale);
assert_eq!(extracted, "fr-FR");
let transformed_locale = "en-US-t-0-abc123";
let extracted = Localization::extract_language_region(transformed_locale);
assert_eq!(extracted, "en-US");
let private_locale = "de-DE-x-phonebk";
let extracted = Localization::extract_language_region(private_locale);
assert_eq!(extracted, "de-DE");
// Test simple locale (no extensions)
let simple_locale = "en-US";
let extracted = Localization::extract_language_region(simple_locale);
assert_eq!(extracted, "en-US");
// Test language-only locale
let lang_only = "en";
let extracted = Localization::extract_language_region(lang_only);
assert_eq!(extracted, "en");
// Test language with extensions (no region)
let lang_with_extensions = "fr-u-mu-celsius";
let extracted = Localization::extract_language_region(lang_with_extensions);
assert_eq!(extracted, "fr");
// Test language with other extension types (no region)
let lang_with_t_ext = "en-t-0-abc123";
let extracted = Localization::extract_language_region(lang_with_t_ext);
assert_eq!(extracted, "en");
let lang_with_x_ext = "de-x-phonebk";
let extracted = Localization::extract_language_region(lang_with_x_ext);
assert_eq!(extracted, "de");
// Test locale with numeric region code
let numeric_region = "es-419-u-mu-celsius";
let extracted = Localization::extract_language_region(numeric_region);
assert_eq!(extracted, "es-419");
// Test locale with 3-letter region code
let three_letter_region = "en-USA-t-0-abc123";
let extracted = Localization::extract_language_region(three_letter_region);
assert_eq!(extracted, "en-USA");
// Test locale with 2-letter region code
let two_letter_region = "fr-FR-u-mu-celsius";
let extracted = Localization::extract_language_region(two_letter_region);
assert_eq!(extracted, "fr-FR");
// Test complex locale with multiple parts
let complex_locale = "zh-CN-u-ca-chinese-x-private";
let extracted = Localization::extract_language_region(complex_locale);
assert_eq!(extracted, "zh-CN");
// Verify that extracted locales can be parsed
let test_cases = ["fr-FR", "en-US", "de-DE", "en", "zh-CN"];
for extracted in test_cases {
if let Ok(locale) = extracted.parse::<LanguageIdentifier>() {
tracing::info!("Successfully parsed extracted locale: {}", locale);
} else {
tracing::error!("Failed to parse extracted locale: {}", extracted);
panic!("Should parse locale after extraction");
}
}
}
// //
// TODO(jb55): write tests that work, i broke all these during the refacto // TODO(jb55): write tests that work, i broke all these during the refacto
+99 -1
View File
@@ -1,4 +1,9 @@
use crate::media::gif::ensure_latest_texture_from_cache;
use crate::media::images::ImageType;
use crate::urls::{UrlCache, UrlMimes}; use crate::urls::{UrlCache, UrlMimes};
use crate::ImageMetadata;
use crate::ObfuscationType;
use crate::RenderableMedia;
use crate::Result; use crate::Result;
use egui::TextureHandle; use egui::TextureHandle;
use image::{Delay, Frame}; use image::{Delay, Frame};
@@ -21,7 +26,7 @@ use tracing::warn;
#[derive(Default)] #[derive(Default)]
pub struct TexturesCache { pub struct TexturesCache {
cache: hashbrown::HashMap<String, TextureStateInternal>, pub cache: hashbrown::HashMap<String, TextureStateInternal>,
} }
impl TexturesCache { impl TexturesCache {
@@ -141,6 +146,12 @@ pub enum TextureState<'a> {
Loaded(&'a mut TexturedImage), Loaded(&'a mut TexturedImage),
} }
impl<'a> TextureState<'a> {
pub fn is_loaded(&self) -> bool {
matches!(self, Self::Loaded(_))
}
}
impl<'a> From<&'a mut TextureStateInternal> for TextureState<'a> { impl<'a> From<&'a mut TextureStateInternal> for TextureState<'a> {
fn from(value: &'a mut TextureStateInternal) -> Self { fn from(value: &'a mut TextureStateInternal) -> Self {
match value { match value {
@@ -402,6 +413,8 @@ pub struct Images {
pub static_imgs: MediaCache, pub static_imgs: MediaCache,
pub gifs: MediaCache, pub gifs: MediaCache,
pub urls: UrlMimes, pub urls: UrlMimes,
/// cached imeta data
pub metadata: HashMap<String, ImageMetadata>,
pub gif_states: GifStateMap, pub gif_states: GifStateMap,
} }
@@ -414,6 +427,7 @@ impl Images {
gifs: MediaCache::new(&path, MediaCacheType::Gif), gifs: MediaCache::new(&path, MediaCacheType::Gif),
urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))), urls: UrlMimes::new(UrlCache::new(path.join(UrlCache::rel_dir()))),
gif_states: Default::default(), gif_states: Default::default(),
metadata: Default::default(),
} }
} }
@@ -422,6 +436,58 @@ impl Images {
self.gifs.migrate_v0() self.gifs.migrate_v0()
} }
pub fn get_renderable_media(&mut self, url: &str) -> Option<RenderableMedia> {
Self::find_renderable_media(&mut self.urls, &self.metadata, url)
}
pub fn find_renderable_media(
urls: &mut UrlMimes,
imeta: &HashMap<String, ImageMetadata>,
url: &str,
) -> Option<RenderableMedia> {
let media_type = crate::urls::supported_mime_hosted_at_url(urls, url)?;
let obfuscation_type = match imeta.get(url) {
Some(blur) => ObfuscationType::Blurhash(blur.clone()),
None => ObfuscationType::Default,
};
Some(RenderableMedia {
url: url.to_string(),
media_type,
obfuscation_type,
})
}
pub fn latest_texture(
&mut self,
ui: &mut egui::Ui,
url: &str,
img_type: ImageType,
) -> Option<TextureHandle> {
let cache_type = crate::urls::supported_mime_hosted_at_url(&mut self.urls, url)?;
let cache_dir = self.get_cache(cache_type).cache_dir.clone();
let is_loaded = self
.get_cache_mut(cache_type)
.textures_cache
.handle_and_get_or_insert(url, || {
crate::media::images::fetch_img(&cache_dir, ui.ctx(), url, img_type, cache_type)
})
.is_loaded();
if !is_loaded {
return None;
}
let cache = match cache_type {
MediaCacheType::Image => &mut self.static_imgs,
MediaCacheType::Gif => &mut self.gifs,
};
ensure_latest_texture_from_cache(ui, url, &mut self.gif_states, &mut cache.textures_cache)
}
pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache { pub fn get_cache(&self, cache_type: MediaCacheType) -> &MediaCache {
match cache_type { match cache_type {
MediaCacheType::Image => &self.static_imgs, MediaCacheType::Image => &self.static_imgs,
@@ -465,3 +531,35 @@ pub struct GifState {
pub next_frame_time: Option<SystemTime>, pub next_frame_time: Option<SystemTime>,
pub last_frame_index: usize, pub last_frame_index: usize,
} }
pub struct LatestTexture {
pub texture: TextureHandle,
pub request_next_repaint: Option<SystemTime>,
}
pub fn get_render_state<'a>(
ctx: &egui::Context,
images: &'a mut Images,
cache_type: MediaCacheType,
url: &str,
img_type: ImageType,
) -> RenderState<'a> {
let cache = match cache_type {
MediaCacheType::Image => &mut images.static_imgs,
MediaCacheType::Gif => &mut images.gifs,
};
let texture_state = cache.textures_cache.handle_and_get_or_insert(url, || {
crate::media::images::fetch_img(&cache.cache_dir, ctx, url, img_type, cache_type)
});
RenderState {
texture_state,
gifs: &mut images.gif_states,
}
}
pub struct RenderState<'a> {
pub texture_state: TextureState<'a>,
pub gifs: &'a mut GifStateMap,
}
+1
View File
@@ -23,6 +23,7 @@ impl JobPool {
pub fn new(num_threads: usize) -> Self { pub fn new(num_threads: usize) -> Self {
let (tx, rx) = mpsc::channel::<Job>(); let (tx, rx) = mpsc::channel::<Job>();
// TODO(jb55) why not mpmc here !???
let arc_rx = Arc::new(Mutex::new(rx)); let arc_rx = Arc::new(Mutex::new(rx));
for _ in 0..num_threads { for _ in 0..num_threads {
let arc_rx_clone = arc_rx.clone(); let arc_rx_clone = arc_rx.clone();
@@ -1,6 +1,6 @@
use crate::JobPool;
use egui::TextureHandle; use egui::TextureHandle;
use hashbrown::{hash_map::RawEntryMut, HashMap}; use hashbrown::{hash_map::RawEntryMut, HashMap};
use notedeck::JobPool;
use poll_promise::Promise; use poll_promise::Promise;
#[derive(Default)] #[derive(Default)]
+15 -2
View File
@@ -12,16 +12,20 @@ mod frame_history;
pub mod i18n; pub mod i18n;
mod imgcache; mod imgcache;
mod job_pool; mod job_pool;
mod jobs;
pub mod media;
mod muted; mod muted;
pub mod name; pub mod name;
pub mod note; pub mod note;
mod notecache; mod notecache;
mod options;
mod persist; mod persist;
pub mod platform; pub mod platform;
pub mod profile; pub mod profile;
pub mod relay_debug; pub mod relay_debug;
pub mod relayspec; pub mod relayspec;
mod result; mod result;
mod setup;
pub mod storage; pub mod storage;
mod style; mod style;
pub mod theme; pub mod theme;
@@ -47,10 +51,18 @@ pub use filter::{FilterState, FilterStates, UnifiedSubscription};
pub use fonts::NamedFontFamily; pub use fonts::NamedFontFamily;
pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization}; pub use i18n::{CacheStats, FluentArgs, FluentValue, LanguageIdentifier, Localization};
pub use imgcache::{ pub use imgcache::{
Animation, GifState, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache, get_render_state, Animation, GifState, GifStateMap, ImageFrame, Images, LatestTexture,
MediaCacheType, TextureFrame, TextureState, TexturedImage, TexturesCache, LoadableTextureState, MediaCache, MediaCacheType, RenderState, TextureFrame, TextureState,
TexturedImage, TexturesCache,
}; };
pub use job_pool::JobPool; pub use job_pool::JobPool;
pub use jobs::{
BlurhashParams, Job, JobError, JobId, JobParams, JobParamsOwned, JobState, JobsCache,
};
pub use media::{
compute_blurhash, update_imeta_blurhashes, ImageMetadata, ImageType, MediaAction,
ObfuscationType, PixelDimensions, PointDimensions, RenderableMedia,
};
pub use muted::{MuteFun, Muted}; pub use muted::{MuteFun, Muted};
pub use name::NostrName; pub use name::NostrName;
pub use note::{ pub use note::{
@@ -58,6 +70,7 @@ pub use note::{
RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction, RootIdError, RootNoteId, RootNoteIdBuf, ScrollInfo, ZapAction,
}; };
pub use notecache::{CachedNote, NoteCache}; pub use notecache::{CachedNote, NoteCache};
pub use options::NotedeckOptions;
pub use persist::*; pub use persist::*;
pub use profile::get_profile_url; pub use profile::get_profile_url;
pub use relay_debug::RelayDebugView; pub use relay_debug::RelayDebugView;
+127
View File
@@ -0,0 +1,127 @@
use crate::{Images, MediaCacheType, TexturedImage};
use poll_promise::Promise;
/// Tracks where media was on the screen so that
/// we can do fun animations when opening the
/// Media Viewer
#[derive(Debug, Clone)]
pub struct MediaInfo {
/// The original screen position where it
/// was rendered from. This is not where
/// it should be rendered in the scene.
pub original_position: egui::Rect,
pub url: String,
}
/// Contains various information for when a user
/// clicks a piece of media. It contains the current
/// location on screen for each piece of media.
///
/// Viewers can use this to smoothly transition from
/// the timeline to the viewer
#[derive(Debug, Clone, Default)]
pub struct ViewMediaInfo {
pub clicked_index: usize,
pub medias: Vec<MediaInfo>,
}
impl ViewMediaInfo {
pub fn clicked_media(&self) -> &MediaInfo {
&self.medias[self.clicked_index]
}
}
/// Actions generated by media ui interactions
pub enum MediaAction {
/// An image was clicked on in a carousel, we have
/// the opportunity to open into a fullscreen media viewer
/// with a list of url values
ViewMedias(ViewMediaInfo),
FetchImage {
url: String,
cache_type: MediaCacheType,
no_pfp_promise: Promise<Option<Result<TexturedImage, crate::Error>>>,
},
DoneLoading {
url: String,
cache_type: MediaCacheType,
},
}
impl std::fmt::Debug for MediaAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::ViewMedias(ViewMediaInfo {
clicked_index,
medias,
}) => f
.debug_struct("ViewMedias")
.field("clicked_index", clicked_index)
.field("media", medias)
.finish(),
Self::FetchImage {
url,
cache_type,
no_pfp_promise,
} => f
.debug_struct("FetchNoPfpImage")
.field("url", url)
.field("cache_type", cache_type)
.field("no_pfp_promise ready", &no_pfp_promise.ready().is_some())
.finish(),
Self::DoneLoading { url, cache_type } => f
.debug_struct("DoneLoading")
.field("url", url)
.field("cache_type", cache_type)
.finish(),
}
}
}
impl MediaAction {
/// Handle view media actions
pub fn on_view_media(&self, handler: impl FnOnce(&ViewMediaInfo)) {
if let MediaAction::ViewMedias(view_medias) = self {
handler(view_medias)
}
}
/// Default processing logic for Media Actions. We don't handle ViewMedias here since
/// this may be app specific ?
pub fn process_default_media_actions(self, images: &mut Images) {
match self {
MediaAction::ViewMedias(_urls) => {
// NOTE(jb55): don't assume we want to show a fullscreen
// media viewer we can use on_view_media for that. We
// also don't want to have a notedeck_ui dependency in
// the notedeck lib (MediaViewerState)
//
// In general our notedeck crate should be pretty
// agnostic to functionallity in general unless it low
// level like image rendering.
//
//mview_state.set_urls(urls);
}
MediaAction::FetchImage {
url,
cache_type,
no_pfp_promise: promise,
} => {
images
.get_cache_mut(cache_type)
.textures_cache
.insert_pending(&url, promise);
}
MediaAction::DoneLoading { url, cache_type } => {
let cache = match cache_type {
MediaCacheType::Image => &mut images.static_imgs,
MediaCacheType::Gif => &mut images.gifs,
};
cache.textures_cache.move_to_loaded(&url);
}
}
}
}
@@ -5,8 +5,8 @@ use nostrdb::Note;
use crate::jobs::{Job, JobError, JobParamsOwned}; use crate::jobs::{Job, JobError, JobParamsOwned};
#[derive(Clone)] #[derive(Clone)]
pub struct Blur<'a> { pub struct ImageMetadata {
pub blurhash: &'a str, pub blurhash: String,
pub dimensions: Option<PixelDimensions>, // width and height in pixels pub dimensions: Option<PixelDimensions>, // width and height in pixels
} }
@@ -44,7 +44,7 @@ impl PointDimensions {
} }
} }
impl Blur<'_> { impl ImageMetadata {
pub fn scaled_pixel_dimensions( pub fn scaled_pixel_dimensions(
&self, &self,
ui: &egui::Ui, ui: &egui::Ui,
@@ -75,9 +75,8 @@ impl Blur<'_> {
} }
} }
pub fn imeta_blurhashes<'a>(note: &'a Note) -> HashMap<&'a str, Blur<'a>> { /// Find blurhashes in image metadata and update our cache
let mut blurs = HashMap::new(); pub fn update_imeta_blurhashes(note: &Note, blurs: &mut HashMap<String, ImageMetadata>) {
for tag in note.tags() { for tag in note.tags() {
let mut tag_iter = tag.into_iter(); let mut tag_iter = tag.into_iter();
if tag_iter if tag_iter
@@ -93,13 +92,11 @@ pub fn imeta_blurhashes<'a>(note: &'a Note) -> HashMap<&'a str, Blur<'a>> {
continue; continue;
}; };
blurs.insert(url, blur); blurs.insert(url.to_string(), blur);
}
} }
blurs fn find_blur(tag_iter: nostrdb::TagIter<'_>) -> Option<(String, ImageMetadata)> {
}
fn find_blur(tag_iter: nostrdb::TagIter) -> Option<(&str, Blur)> {
let mut url = None; let mut url = None;
let mut blurhash = None; let mut blurhash = None;
let mut dims = None; let mut dims = None;
@@ -138,21 +135,21 @@ fn find_blur(tag_iter: nostrdb::TagIter) -> Option<(&str, Blur)> {
}); });
Some(( Some((
url, url.to_string(),
Blur { ImageMetadata {
blurhash, blurhash: blurhash.to_string(),
dimensions, dimensions,
}, },
)) ))
} }
#[derive(Clone)] #[derive(Clone)]
pub enum ObfuscationType<'a> { pub enum ObfuscationType {
Blurhash(Blur<'a>), Blurhash(ImageMetadata),
Default, Default,
} }
pub(crate) fn compute_blurhash( pub fn compute_blurhash(
params: Option<JobParamsOwned>, params: Option<JobParamsOwned>,
dims: PixelDimensions, dims: PixelDimensions,
) -> Result<Job, JobError> { ) -> Result<Job, JobError> {
@@ -185,9 +182,9 @@ fn generate_blurhash_texturehandle(
url: &str, url: &str,
width: u32, width: u32,
height: u32, height: u32,
) -> notedeck::Result<egui::TextureHandle> { ) -> Result<egui::TextureHandle, crate::Error> {
let bytes = blurhash::decode(blurhash, width, height, 1.0) let bytes = blurhash::decode(blurhash, width, height, 1.0)
.map_err(|e| notedeck::Error::Generic(e.to_string()))?; .map_err(|e| crate::Error::Generic(e.to_string()))?;
let img = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &bytes); let img = egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], &bytes);
Ok(ctx.load_texture(url, img, Default::default())) Ok(ctx.load_texture(url, img, Default::default()))
@@ -3,37 +3,32 @@ use std::{
time::{Instant, SystemTime}, time::{Instant, SystemTime},
}; };
use crate::{GifState, GifStateMap, TextureState, TexturedImage, TexturesCache};
use egui::TextureHandle; use egui::TextureHandle;
use notedeck::{GifState, GifStateMap, TexturedImage};
pub struct LatextTexture<'a> { pub fn ensure_latest_texture_from_cache(
pub texture: &'a TextureHandle, ui: &egui::Ui,
pub request_next_repaint: Option<SystemTime>,
}
/// This is necessary because other repaint calls can effectively steal our repaint request.
/// So we must keep on requesting to repaint at our desired time to ensure our repaint goes through.
/// See [`egui::Context::request_repaint_after`]
pub fn handle_repaint<'a>(ui: &egui::Ui, latest: LatextTexture<'a>) -> &'a TextureHandle {
if let Some(_repaint) = latest.request_next_repaint {
// 24fps for gif is fine
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(41));
}
latest.texture
}
#[must_use = "caller should pass the return value to `gif::handle_repaint`"]
pub fn retrieve_latest_texture<'a>(
url: &str, url: &str,
gifs: &'a mut GifStateMap, gifs: &mut GifStateMap,
cached_image: &'a mut TexturedImage, textures: &mut TexturesCache,
) -> LatextTexture<'a> { ) -> Option<TextureHandle> {
match cached_image { let tstate = textures.cache.get_mut(url)?;
TexturedImage::Static(texture) => LatextTexture {
texture, let TextureState::Loaded(img) = tstate.into() else {
request_next_repaint: None, return None;
}, };
Some(ensure_latest_texture(ui, url, gifs, img))
}
pub fn ensure_latest_texture(
ui: &egui::Ui,
url: &str,
gifs: &mut GifStateMap,
img: &mut TexturedImage,
) -> TextureHandle {
match img {
TexturedImage::Static(handle) => handle.clone(),
TexturedImage::Animated(animation) => { TexturedImage::Animated(animation) => {
if let Some(receiver) = &animation.receiver { if let Some(receiver) = &animation.receiver {
loop { loop {
@@ -115,12 +110,12 @@ pub fn retrieve_latest_texture<'a>(
if let Some(req) = request_next_repaint { if let Some(req) = request_next_repaint {
tracing::trace!("requesting repaint for {url} after {req:?}"); tracing::trace!("requesting repaint for {url} after {req:?}");
// 24fps for gif is fine
ui.ctx()
.request_repaint_after(std::time::Duration::from_millis(41));
} }
LatextTexture { texture.clone()
texture,
request_next_repaint,
}
} }
} }
} }
+475
View File
@@ -0,0 +1,475 @@
use crate::{Animation, ImageFrame, MediaCache, MediaCacheType, TextureFrame, TexturedImage};
use egui::{pos2, Color32, ColorImage, Context, Rect, Sense, SizeHint};
use image::codecs::gif::GifDecoder;
use image::imageops::FilterType;
use image::{AnimationDecoder, DynamicImage, FlatSamples, Frame};
use poll_promise::Promise;
use std::collections::VecDeque;
use std::io::Cursor;
use std::path::PathBuf;
use std::path::{self, Path};
use std::sync::mpsc;
use std::sync::mpsc::SyncSender;
use std::thread;
use std::time::Duration;
use tokio::fs;
// NOTE(jb55): chatgpt wrote this because I was too dumb to
pub fn aspect_fill(
ui: &mut egui::Ui,
sense: Sense,
texture_id: egui::TextureId,
aspect_ratio: f32,
) -> egui::Response {
let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout
let frame_ratio = frame.width() / frame.height();
let (width, height) = if frame_ratio > aspect_ratio {
// Frame is wider than the content
(frame.width(), frame.width() / aspect_ratio)
} else {
// Frame is taller than the content
(frame.height() * aspect_ratio, frame.height())
};
let content_rect = Rect::from_min_size(
frame.min
+ egui::vec2(
(frame.width() - width) / 2.0,
(frame.height() - height) / 2.0,
),
egui::vec2(width, height),
);
// Set the clipping rectangle to the frame
//let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle
//ui.set_clip_rect(frame);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let (response, painter) = ui.allocate_painter(ui.available_size(), sense);
// Draw the texture within the calculated rect, potentially clipping it
painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill());
painter.image(texture_id, content_rect, uv, Color32::WHITE);
// Restore the original clipping rectangle
//ui.set_clip_rect(clip_rect);
response
}
#[profiling::function]
pub fn round_image(image: &mut ColorImage) {
// The radius to the edge of of the avatar circle
let edge_radius = image.size[0] as f32 / 2.0;
let edge_radius_squared = edge_radius * edge_radius;
for (pixnum, pixel) in image.pixels.iter_mut().enumerate() {
// y coordinate
let uy = pixnum / image.size[0];
let y = uy as f32;
let y_offset = edge_radius - y;
// x coordinate
let ux = pixnum % image.size[0];
let x = ux as f32;
let x_offset = edge_radius - x;
// The radius to this pixel (may be inside or outside the circle)
let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset;
// If inside of the avatar circle
if pixel_radius_squared <= edge_radius_squared {
// squareroot to find how many pixels we are from the edge
let pixel_radius: f32 = pixel_radius_squared.sqrt();
let distance = edge_radius - pixel_radius;
// If we are within 1 pixel of the edge, we should fade, to
// antialias the edge of the circle. 1 pixel from the edge should
// be 100% of the original color, and right on the edge should be
// 0% of the original color.
if distance <= 1.0 {
*pixel = Color32::from_rgba_premultiplied(
(pixel.r() as f32 * distance) as u8,
(pixel.g() as f32 * distance) as u8,
(pixel.b() as f32 * distance) as u8,
(pixel.a() as f32 * distance) as u8,
);
}
} else {
// Outside of the avatar circle
*pixel = Color32::TRANSPARENT;
}
}
}
/// If the image's longest dimension is greater than max_edge, downscale
fn resize_image_if_too_big(
image: image::DynamicImage,
max_edge: u32,
filter: FilterType,
) -> image::DynamicImage {
// if we have no size hint, resize to something reasonable
let w = image.width();
let h = image.height();
let long = w.max(h);
if long > max_edge {
let scale = max_edge as f32 / long as f32;
let new_w = (w as f32 * scale).round() as u32;
let new_h = (h as f32 * scale).round() as u32;
image.resize(new_w, new_h, filter)
} else {
image
}
}
///
/// Process an image, resizing so we don't blow up video memory or even crash
///
/// For profile pictures, make them round and small to fit the size hint
/// For everything else, either:
///
/// - resize to the size hint
/// - keep the size if the longest dimension is less than MAX_IMG_LENGTH
/// - resize if any larger, using [`resize_image_if_too_big`]
///
#[profiling::function]
fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage {
const MAX_IMG_LENGTH: u32 = 2048;
const FILTER_TYPE: FilterType = FilterType::CatmullRom;
match imgtyp {
ImageType::Content(size_hint) => {
let image = match size_hint {
None => resize_image_if_too_big(image, MAX_IMG_LENGTH, FILTER_TYPE),
Some((w, h)) => image.resize(w, h, FILTER_TYPE),
};
let image_buffer = image.into_rgba8();
ColorImage::from_rgba_unmultiplied(
[
image_buffer.width() as usize,
image_buffer.height() as usize,
],
image_buffer.as_flat_samples().as_slice(),
)
}
ImageType::Profile(size) => {
// Crop square
let smaller = image.width().min(image.height());
if image.width() > smaller {
let excess = image.width() - smaller;
image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height());
} else if image.height() > smaller {
let excess = image.height() - smaller;
image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess);
}
let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage
let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer)
let mut color_image = ColorImage::from_rgba_unmultiplied(
[
image_buffer.width() as usize,
image_buffer.height() as usize,
],
image_buffer.as_flat_samples().as_slice(),
);
round_image(&mut color_image);
color_image
}
}
}
#[profiling::function]
fn parse_img_response(
response: ehttp::Response,
imgtyp: ImageType,
) -> Result<ColorImage, crate::Error> {
let content_type = response.content_type().unwrap_or_default();
let size_hint = match imgtyp {
ImageType::Profile(size) => SizeHint::Size(size, size),
ImageType::Content(Some((w, h))) => SizeHint::Size(w, h),
ImageType::Content(None) => SizeHint::default(),
};
if content_type.starts_with("image/svg") {
profiling::scope!("load_svg");
let mut color_image =
egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?;
round_image(&mut color_image);
Ok(color_image)
} else if content_type.starts_with("image/") {
profiling::scope!("load_from_memory");
let dyn_image = image::load_from_memory(&response.bytes)?;
Ok(process_image(imgtyp, dyn_image))
} else {
Err(format!("Expected image, found content-type {content_type:?}").into())
}
}
fn fetch_img_from_disk(
ctx: &egui::Context,
url: &str,
path: &path::Path,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, crate::Error>>> {
let ctx = ctx.clone();
let url = url.to_owned();
let path = path.to_owned();
Promise::spawn_async(async move {
Some(async_fetch_img_from_disk(ctx, url, &path, cache_type).await)
})
}
async fn async_fetch_img_from_disk(
ctx: egui::Context,
url: String,
path: &path::Path,
cache_type: MediaCacheType,
) -> Result<TexturedImage, crate::Error> {
match cache_type {
MediaCacheType::Image => {
let data = fs::read(path).await?;
let image_buffer = image::load_from_memory(&data).map_err(crate::Error::Image)?;
let img = buffer_to_color_image(
image_buffer.as_flat_samples_u8(),
image_buffer.width(),
image_buffer.height(),
);
Ok(TexturedImage::Static(ctx.load_texture(
&url,
img,
Default::default(),
)))
}
MediaCacheType::Gif => {
let gif_bytes = fs::read(path).await?; // Read entire file into a Vec<u8>
generate_gif(ctx, url, path, gif_bytes, false, |i| {
buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height())
})
}
}
}
fn generate_gif(
ctx: egui::Context,
url: String,
path: &path::Path,
data: Vec<u8>,
write_to_disk: bool,
process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static,
) -> Result<TexturedImage, crate::Error> {
let decoder = {
let reader = Cursor::new(data.as_slice());
GifDecoder::new(reader)?
};
let (tex_input, tex_output) = mpsc::sync_channel(4);
let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk {
let (inp, out) = mpsc::sync_channel(4);
(Some(inp), Some(out))
} else {
(None, None)
};
let mut frames: VecDeque<Frame> = decoder
.into_frames()
.collect::<std::result::Result<VecDeque<_>, image::ImageError>>()
.map_err(|e| crate::Error::Generic(e.to_string()))?;
let first_frame = frames.pop_front().map(|frame| {
generate_animation_frame(
&ctx,
&url,
0,
frame,
maybe_encoder_input.as_ref(),
process_to_egui,
)
});
let cur_url = url.clone();
thread::spawn(move || {
for (index, frame) in frames.into_iter().enumerate() {
let texture_frame = generate_animation_frame(
&ctx,
&cur_url,
index,
frame,
maybe_encoder_input.as_ref(),
process_to_egui,
);
if tex_input.send(texture_frame).is_err() {
tracing::debug!("AnimationTextureFrame mpsc stopped abruptly");
break;
}
}
});
if let Some(encoder_output) = maybe_encoder_output {
let path = path.to_owned();
thread::spawn(move || {
let mut imgs = Vec::new();
while let Ok(img) = encoder_output.recv() {
imgs.push(img);
}
if let Err(e) = MediaCache::write_gif(&path, &url, imgs) {
tracing::error!("Could not write gif to disk: {e}");
}
});
}
first_frame.map_or_else(
|| {
Err(crate::Error::Generic(
"first frame not found for gif".to_owned(),
))
},
|first_frame| {
Ok(TexturedImage::Animated(Animation {
other_frames: Default::default(),
receiver: Some(tex_output),
first_frame,
}))
},
)
}
fn generate_animation_frame(
ctx: &egui::Context,
url: &str,
index: usize,
frame: image::Frame,
maybe_encoder_input: Option<&SyncSender<ImageFrame>>,
process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static,
) -> TextureFrame {
let delay = Duration::from(frame.delay());
let img = DynamicImage::ImageRgba8(frame.into_buffer());
let color_img = process_to_egui(img);
if let Some(sender) = maybe_encoder_input {
if let Err(e) = sender.send(ImageFrame {
delay,
image: color_img.clone(),
}) {
tracing::error!("ImageFrame mpsc unexpectedly closed: {e}");
}
}
TextureFrame {
delay,
texture: ctx.load_texture(format!("{url}{index}"), color_img, Default::default()),
}
}
fn buffer_to_color_image(
samples: Option<FlatSamples<&[u8]>>,
width: u32,
height: u32,
) -> ColorImage {
// TODO(jb55): remove unwrap here
let flat_samples = samples.unwrap();
ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice())
}
pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>, crate::Error> {
std::fs::read(path).map_err(|e| crate::Error::Generic(e.to_string()))
}
/// Controls type-specific handling
#[derive(Debug, Clone, Copy)]
pub enum ImageType {
/// Profile Image (size)
Profile(u32),
/// Content Image with optional size hint
Content(Option<(u32, u32)>),
}
pub fn fetch_img(
img_cache_path: &Path,
ctx: &egui::Context,
url: &str,
imgtyp: ImageType,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, crate::Error>>> {
let key = MediaCache::key(url);
let path = img_cache_path.join(key);
if path.exists() {
fetch_img_from_disk(ctx, url, &path, cache_type)
} else {
fetch_img_from_net(img_cache_path, ctx, url, imgtyp, cache_type)
}
// TODO: fetch image from local cache
}
fn fetch_img_from_net(
cache_path: &path::Path,
ctx: &egui::Context,
url: &str,
imgtyp: ImageType,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, crate::Error>>> {
let (sender, promise) = Promise::new();
let request = ehttp::Request::get(url);
let ctx = ctx.clone();
let cloned_url = url.to_owned();
let cache_path = cache_path.to_owned();
ehttp::fetch(request, move |response| {
let handle = response.map_err(crate::Error::Generic).and_then(|resp| {
match cache_type {
MediaCacheType::Image => {
let img = parse_img_response(resp, imgtyp);
img.map(|img| {
let texture_handle =
ctx.load_texture(&cloned_url, img.clone(), Default::default());
// write to disk
std::thread::spawn(move || {
MediaCache::write(&cache_path, &cloned_url, img)
});
TexturedImage::Static(texture_handle)
})
}
MediaCacheType::Gif => {
let gif_bytes = resp.bytes;
generate_gif(
ctx.clone(),
cloned_url,
&cache_path,
gif_bytes,
true,
move |img| process_image(imgtyp, img),
)
}
}
});
sender.send(Some(handle)); // send the results back to the UI thread.
ctx.request_repaint();
});
promise
}
pub fn fetch_no_pfp_promise(
ctx: &Context,
cache: &MediaCache,
) -> Promise<Option<Result<TexturedImage, crate::Error>>> {
crate::media::images::fetch_img(
&cache.cache_dir,
ctx,
crate::profile::no_pfp_url(),
ImageType::Profile(128),
MediaCacheType::Image,
)
}
+1
View File
@@ -0,0 +1 @@
+14
View File
@@ -0,0 +1,14 @@
pub mod action;
pub mod blur;
pub mod gif;
pub mod images;
pub mod imeta;
pub mod renderable;
pub use action::{MediaAction, MediaInfo, ViewMediaInfo};
pub use blur::{
compute_blurhash, update_imeta_blurhashes, ImageMetadata, ObfuscationType, PixelDimensions,
PointDimensions,
};
pub use images::ImageType;
pub use renderable::RenderableMedia;
+9
View File
@@ -0,0 +1,9 @@
use super::ObfuscationType;
use crate::MediaCacheType;
/// Media that is prepared for rendering. Use [`Images::get_renderable_media`] to get these
pub struct RenderableMedia {
pub url: String,
pub media_type: MediaCacheType,
pub obfuscation_type: ObfuscationType,
}
+1 -61
View File
@@ -1,8 +1,7 @@
use super::context::ContextSelection; use super::context::ContextSelection;
use crate::{zaps::NoteZapTargetOwned, Images, MediaCacheType, TexturedImage}; use crate::{zaps::NoteZapTargetOwned, MediaAction};
use egui::Vec2; use egui::Vec2;
use enostr::{NoteId, Pubkey}; use enostr::{NoteId, Pubkey};
use poll_promise::Promise;
#[derive(Debug)] #[derive(Debug)]
pub struct ScrollInfo { pub struct ScrollInfo {
@@ -61,62 +60,3 @@ pub struct ZapTargetAmount {
pub target: NoteZapTargetOwned, pub target: NoteZapTargetOwned,
pub specified_msats: Option<u64>, // if None use default amount pub specified_msats: Option<u64>, // if None use default amount
} }
pub enum MediaAction {
FetchImage {
url: String,
cache_type: MediaCacheType,
no_pfp_promise: Promise<Option<Result<TexturedImage, crate::Error>>>,
},
DoneLoading {
url: String,
cache_type: MediaCacheType,
},
}
impl std::fmt::Debug for MediaAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::FetchImage {
url,
cache_type,
no_pfp_promise,
} => f
.debug_struct("FetchNoPfpImage")
.field("url", url)
.field("cache_type", cache_type)
.field("no_pfp_promise ready", &no_pfp_promise.ready().is_some())
.finish(),
Self::DoneLoading { url, cache_type } => f
.debug_struct("DoneLoading")
.field("url", url)
.field("cache_type", cache_type)
.finish(),
}
}
}
impl MediaAction {
pub fn process(self, images: &mut Images) {
match self {
MediaAction::FetchImage {
url,
cache_type,
no_pfp_promise: promise,
} => {
images
.get_cache_mut(cache_type)
.textures_cache
.insert_pending(&url, promise);
}
MediaAction::DoneLoading { url, cache_type } => {
let cache = match cache_type {
MediaCacheType::Image => &mut images.static_imgs,
MediaCacheType::Gif => &mut images.gifs,
};
cache.textures_cache.move_to_loaded(&url);
}
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
mod action; mod action;
mod context; mod context;
pub use action::{MediaAction, NoteAction, ScrollInfo, ZapAction, ZapTargetAmount}; pub use action::{NoteAction, ScrollInfo, ZapAction, ZapTargetAmount};
pub use context::{BroadcastContext, ContextSelection, NoteContextSelection}; pub use context::{BroadcastContext, ContextSelection, NoteContextSelection};
use crate::Accounts; use crate::Accounts;
+39
View File
@@ -0,0 +1,39 @@
use bitflags::bitflags;
bitflags! {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NotedeckOptions: u64 {
// ===== Settings ======
/// Are we on light theme?
const LightTheme = 1 << 0;
/// Debug controls, fps stats
const Debug = 1 << 1;
/// Show relay debug window?
const RelayDebug = 1 << 2;
/// Are we running as tests?
const Tests = 1 << 3;
/// Use keystore?
const UseKeystore = 1 << 4;
/// Show client on notes?
const ShowClient = 1 << 5;
/// Simulate is_compiled_as_mobile ?
const Mobile = 1 << 6;
// ===== Feature Flags ======
/// Is notebook enabled?
const FeatureNotebook = 1 << 32;
}
}
impl Default for NotedeckOptions {
fn default() -> Self {
NotedeckOptions::UseKeystore
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
mod app_size; mod app_size;
mod theme_handler; mod settings_handler;
mod token_handler; mod token_handler;
mod zoom;
pub use app_size::AppSizeHandler; pub use app_size::AppSizeHandler;
pub use theme_handler::ThemeHandler; pub use settings_handler::Settings;
pub use settings_handler::SettingsHandler;
pub use settings_handler::DEFAULT_NOTE_BODY_FONT_SIZE;
pub use token_handler::TokenHandler; pub use token_handler::TokenHandler;
pub use zoom::ZoomHandler;
@@ -0,0 +1,253 @@
use crate::{
storage::delete_file, timed_serializer::TimedSerializer, DataPath, DataPathType, Directory,
};
use egui::ThemePreference;
use serde::{Deserialize, Serialize};
use tracing::{error, info};
const THEME_FILE: &str = "theme.txt";
const ZOOM_FACTOR_FILE: &str = "zoom_level.json";
const SETTINGS_FILE: &str = "settings.json";
const DEFAULT_THEME: ThemePreference = ThemePreference::Dark;
const DEFAULT_LOCALE: &str = "en-US";
const DEFAULT_ZOOM_FACTOR: f32 = 1.0;
const DEFAULT_SHOW_SOURCE_CLIENT: &str = "hide";
const DEFAULT_SHOW_REPLIES_NEWEST_FIRST: bool = false;
#[cfg(any(target_os = "android", target_os = "ios"))]
pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 13.0;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
pub const DEFAULT_NOTE_BODY_FONT_SIZE: f32 = 16.0;
fn deserialize_theme(serialized_theme: &str) -> Option<ThemePreference> {
match serialized_theme {
"dark" => Some(ThemePreference::Dark),
"light" => Some(ThemePreference::Light),
"system" => Some(ThemePreference::System),
_ => None,
}
}
#[derive(Serialize, Deserialize, PartialEq, Clone)]
pub struct Settings {
pub theme: ThemePreference,
pub locale: String,
pub zoom_factor: f32,
pub show_source_client: String,
pub show_replies_newest_first: bool,
pub note_body_font_size: f32,
}
impl Default for Settings {
fn default() -> Self {
Self {
theme: DEFAULT_THEME,
locale: DEFAULT_LOCALE.to_string(),
zoom_factor: DEFAULT_ZOOM_FACTOR,
show_source_client: DEFAULT_SHOW_SOURCE_CLIENT.to_string(),
show_replies_newest_first: DEFAULT_SHOW_REPLIES_NEWEST_FIRST,
note_body_font_size: DEFAULT_NOTE_BODY_FONT_SIZE,
}
}
}
pub struct SettingsHandler {
directory: Directory,
serializer: TimedSerializer<Settings>,
current_settings: Option<Settings>,
}
impl SettingsHandler {
fn read_from_theme_file(&self) -> Option<ThemePreference> {
match self.directory.get_file(THEME_FILE.to_string()) {
Ok(contents) => deserialize_theme(contents.trim()),
Err(_) => None,
}
}
fn read_from_zomfactor_file(&self) -> Option<f32> {
match self.directory.get_file(ZOOM_FACTOR_FILE.to_string()) {
Ok(contents) => serde_json::from_str::<f32>(&contents).ok(),
Err(_) => None,
}
}
fn migrate_to_settings_file(&mut self) -> bool {
let mut settings = Settings::default();
let mut migrated = false;
// if theme.txt exists migrate
if let Some(theme_from_file) = self.read_from_theme_file() {
info!("migrating theme preference from theme.txt file");
_ = delete_file(&self.directory.file_path, THEME_FILE.to_string());
settings.theme = theme_from_file;
migrated = true;
} else {
info!("theme.txt file not found, using default theme");
};
// if zoom_factor.txt exists migrate
if let Some(zom_factor) = self.read_from_zomfactor_file() {
info!("migrating theme preference from zom_factor file");
_ = delete_file(&self.directory.file_path, ZOOM_FACTOR_FILE.to_string());
settings.zoom_factor = zom_factor;
migrated = true;
} else {
info!("zoom_factor.txt exists migrate file not found, using default zoom factor");
};
if migrated {
self.current_settings = Some(settings);
self.try_save_settings();
}
migrated
}
pub fn new(path: &DataPath) -> Self {
let directory = Directory::new(path.path(DataPathType::Setting));
let serializer =
TimedSerializer::new(path, DataPathType::Setting, "settings.json".to_owned());
Self {
directory,
serializer,
current_settings: None,
}
}
pub fn load(mut self) -> Self {
if self.migrate_to_settings_file() {
return self;
}
match self.directory.get_file(SETTINGS_FILE.to_string()) {
Ok(contents_str) => {
// Parse JSON content
match serde_json::from_str::<Settings>(&contents_str) {
Ok(settings) => {
self.current_settings = Some(settings);
}
Err(_) => {
error!("Invalid settings format. Using defaults");
self.current_settings = Some(Settings::default());
}
}
}
Err(_) => {
error!("Could not read settings. Using defaults");
self.current_settings = Some(Settings::default());
}
}
self
}
pub(crate) fn try_save_settings(&mut self) {
let settings = self.get_settings_mut().clone();
self.serializer.try_save(settings);
}
pub fn get_settings_mut(&mut self) -> &mut Settings {
if self.current_settings.is_none() {
self.current_settings = Some(Settings::default());
}
self.current_settings.as_mut().unwrap()
}
pub fn set_theme(&mut self, theme: ThemePreference) {
self.get_settings_mut().theme = theme;
self.try_save_settings();
}
pub fn set_locale<S>(&mut self, locale: S)
where
S: Into<String>,
{
self.get_settings_mut().locale = locale.into();
self.try_save_settings();
}
pub fn set_zoom_factor(&mut self, zoom_factor: f32) {
self.get_settings_mut().zoom_factor = zoom_factor;
self.try_save_settings();
}
pub fn set_show_source_client<S>(&mut self, option: S)
where
S: Into<String>,
{
self.get_settings_mut().show_source_client = option.into();
self.try_save_settings();
}
pub fn set_show_replies_newest_first(&mut self, value: bool) {
self.get_settings_mut().show_replies_newest_first = value;
self.try_save_settings();
}
pub fn set_note_body_font_size(&mut self, value: f32) {
self.get_settings_mut().note_body_font_size = value;
self.try_save_settings();
}
pub fn update_batch<F>(&mut self, update_fn: F)
where
F: FnOnce(&mut Settings),
{
let settings = self.get_settings_mut();
update_fn(settings);
self.try_save_settings();
}
pub fn update_settings(&mut self, new_settings: Settings) {
self.current_settings = Some(new_settings);
self.try_save_settings();
}
pub fn theme(&self) -> ThemePreference {
self.current_settings
.as_ref()
.map(|s| s.theme)
.unwrap_or(DEFAULT_THEME)
}
pub fn locale(&self) -> String {
self.current_settings
.as_ref()
.map(|s| s.locale.clone())
.unwrap_or_else(|| DEFAULT_LOCALE.to_string())
}
pub fn zoom_factor(&self) -> f32 {
self.current_settings
.as_ref()
.map(|s| s.zoom_factor)
.unwrap_or(DEFAULT_ZOOM_FACTOR)
}
pub fn show_source_client(&self) -> String {
self.current_settings
.as_ref()
.map(|s| s.show_source_client.to_string())
.unwrap_or(DEFAULT_SHOW_SOURCE_CLIENT.to_string())
}
pub fn show_replies_newest_first(&self) -> bool {
self.current_settings
.as_ref()
.map(|s| s.show_replies_newest_first)
.unwrap_or(DEFAULT_SHOW_REPLIES_NEWEST_FIRST)
}
pub fn is_loaded(&self) -> bool {
self.current_settings.is_some()
}
pub fn note_body_font_size(&self) -> f32 {
self.current_settings
.as_ref()
.map(|s| s.note_body_font_size)
.unwrap_or(DEFAULT_NOTE_BODY_FONT_SIZE)
}
}
@@ -1,76 +0,0 @@
use egui::ThemePreference;
use tracing::{error, info};
use crate::{storage, DataPath, DataPathType, Directory};
pub struct ThemeHandler {
directory: Directory,
fallback_theme: ThemePreference,
}
const THEME_FILE: &str = "theme.txt";
impl ThemeHandler {
pub fn new(path: &DataPath) -> Self {
let directory = Directory::new(path.path(DataPathType::Setting));
let fallback_theme = ThemePreference::Dark;
Self {
directory,
fallback_theme,
}
}
pub fn load(&self) -> ThemePreference {
match self.directory.get_file(THEME_FILE.to_owned()) {
Ok(contents) => match deserialize_theme(contents) {
Some(theme) => theme,
None => {
error!(
"Could not deserialize theme. Using fallback {:?} instead",
self.fallback_theme
);
self.fallback_theme
}
},
Err(e) => {
error!(
"Could not read {} file: {:?}\nUsing fallback {:?} instead",
THEME_FILE, e, self.fallback_theme
);
self.fallback_theme
}
}
}
pub fn save(&self, theme: ThemePreference) {
match storage::write_file(
&self.directory.file_path,
THEME_FILE.to_owned(),
&theme_to_serialized(&theme),
) {
Ok(_) => info!(
"Successfully saved {:?} theme change to {}",
theme, THEME_FILE
),
Err(_) => error!("Could not save {:?} theme change to {}", theme, THEME_FILE),
}
}
}
fn theme_to_serialized(theme: &ThemePreference) -> String {
match theme {
ThemePreference::Dark => "dark",
ThemePreference::Light => "light",
ThemePreference::System => "system",
}
.to_owned()
}
fn deserialize_theme(serialized_theme: String) -> Option<ThemePreference> {
match serialized_theme.as_str() {
"dark" => Some(ThemePreference::Dark),
"light" => Some(ThemePreference::Light),
"system" => Some(ThemePreference::System),
_ => None,
}
}
-26
View File
@@ -1,26 +0,0 @@
use crate::{DataPath, DataPathType};
use egui::Context;
use crate::timed_serializer::TimedSerializer;
pub struct ZoomHandler {
serializer: TimedSerializer<f32>,
}
impl ZoomHandler {
pub fn new(path: &DataPath) -> Self {
let serializer =
TimedSerializer::new(path, DataPathType::Setting, "zoom_level.json".to_owned());
Self { serializer }
}
pub fn try_save_zoom_factor(&mut self, ctx: &Context) {
let cur_zoom_level = ctx.zoom_factor();
self.serializer.try_save(cur_zoom_level);
}
pub fn get_zoom_factor(&self) -> Option<f32> {
self.serializer.get_item()
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ pub extern "C" fn Java_com_damus_notedeck_KeyboardHeightHelper_nativeKeyboardHei
debug!("updating virtual keyboard height {}", height); debug!("updating virtual keyboard height {}", height);
// Convert and store atomically // Convert and store atomically
KEYBOARD_HEIGHT.store(height as i32, Ordering::SeqCst); KEYBOARD_HEIGHT.store(height, Ordering::SeqCst);
} }
/// Gets the current Android virtual keyboard height. Useful for transforming /// Gets the current Android virtual keyboard height. Useful for transforming
+47
View File
@@ -0,0 +1,47 @@
use crate::fonts;
use crate::theme;
use crate::NotedeckOptions;
use crate::NotedeckTextStyle;
use egui::FontId;
use egui::ThemePreference;
pub fn setup_egui_context(
ctx: &egui::Context,
options: NotedeckOptions,
theme: ThemePreference,
note_body_font_size: f32,
zoom_factor: f32,
) {
let is_mobile = options.contains(NotedeckOptions::Mobile) || crate::ui::is_compiled_as_mobile();
let is_oled = crate::ui::is_oled();
ctx.options_mut(|o| {
tracing::info!("Loaded theme {:?} from disk", theme);
o.theme_preference = theme;
});
ctx.set_visuals_of(egui::Theme::Dark, theme::dark_mode(is_oled));
ctx.set_visuals_of(egui::Theme::Light, theme::light_mode());
fonts::setup_fonts(ctx);
if crate::ui::is_compiled_as_mobile() {
ctx.set_pixels_per_point(ctx.pixels_per_point() + 0.2);
}
egui_extras::install_image_loaders(ctx);
ctx.options_mut(|o| {
o.input_options.max_click_duration = 0.4;
});
ctx.all_styles_mut(|style| crate::theme::add_custom_style(is_mobile, style));
ctx.set_zoom_factor(zoom_factor);
let mut style = (*ctx.style()).clone();
style.text_styles.insert(
NotedeckTextStyle::NoteBody.text_style(),
FontId::proportional(note_body_font_size),
);
ctx.set_style(style);
}
+3
View File
@@ -15,6 +15,7 @@ pub enum NotedeckTextStyle {
Button, Button,
Small, Small,
Tiny, Tiny,
NoteBody,
} }
impl NotedeckTextStyle { impl NotedeckTextStyle {
@@ -29,6 +30,7 @@ impl NotedeckTextStyle {
Self::Button => TextStyle::Button, Self::Button => TextStyle::Button,
Self::Small => TextStyle::Small, Self::Small => TextStyle::Small,
Self::Tiny => TextStyle::Name("Tiny".into()), Self::Tiny => TextStyle::Name("Tiny".into()),
Self::NoteBody => TextStyle::Name("NoteBody".into()),
} }
} }
@@ -43,6 +45,7 @@ impl NotedeckTextStyle {
Self::Button => FontFamily::Proportional, Self::Button => FontFamily::Proportional,
Self::Small => FontFamily::Proportional, Self::Small => FontFamily::Proportional,
Self::Tiny => FontFamily::Proportional, Self::Tiny => FontFamily::Proportional,
Self::NoteBody => FontFamily::Proportional,
} }
} }
+160 -4
View File
@@ -1,7 +1,35 @@
use egui::{ use crate::{fonts, NotedeckTextStyle};
style::{Selection, WidgetVisuals, Widgets}, use egui::style::Interaction;
Color32, CornerRadius, Stroke, Visuals, use egui::style::Selection;
}; use egui::style::WidgetVisuals;
use egui::style::Widgets;
use egui::Color32;
use egui::CornerRadius;
use egui::FontId;
use egui::Stroke;
use egui::Style;
use egui::Visuals;
use strum::IntoEnumIterator;
pub const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5);
const PURPLE_ALT: Color32 = Color32::from_rgb(0x82, 0x56, 0xDD);
//pub const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52);
pub const GRAY_SECONDARY: Color32 = Color32::from_rgb(0x8A, 0x8A, 0x8A);
const BLACK: Color32 = Color32::from_rgb(0x00, 0x00, 0x00);
const RED_700: Color32 = Color32::from_rgb(0xC7, 0x37, 0x5A);
const ORANGE_700: Color32 = Color32::from_rgb(0xF6, 0xB1, 0x4A);
// BACKGROUNDS
const SEMI_DARKER_BG: Color32 = Color32::from_rgb(0x39, 0x39, 0x39);
const DARKER_BG: Color32 = Color32::from_rgb(0x1F, 0x1F, 0x1F);
const DARK_BG: Color32 = Color32::from_rgb(0x2C, 0x2C, 0x2C);
const DARK_ISH_BG: Color32 = Color32::from_rgb(0x25, 0x25, 0x25);
const SEMI_DARK_BG: Color32 = Color32::from_rgb(0x44, 0x44, 0x44);
const LIGHTER_GRAY: Color32 = Color32::from_rgb(0xf8, 0xf8, 0xf8);
const LIGHT_GRAY: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); // 78%
const DARKER_GRAY: Color32 = Color32::from_rgb(0xa5, 0xa5, 0xa5); // 65%
const EVEN_DARKER_GRAY: Color32 = Color32::from_rgb(0x89, 0x89, 0x89); // 54%
pub struct ColorTheme { pub struct ColorTheme {
// VISUALS // VISUALS
@@ -86,3 +114,131 @@ pub fn create_themed_visuals(theme: ColorTheme, default: Visuals) -> Visuals {
..default ..default
} }
} }
pub fn desktop_dark_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: DARKER_BG,
extreme_bg_color: DARK_ISH_BG,
text_color: Color32::WHITE,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: DARK_ISH_BG,
window_stroke_color: DARK_BG,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: DARK_ISH_BG,
noninteractive_weak_bg_fill: DARK_BG,
noninteractive_bg_stroke_color: SEMI_DARKER_BG,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: SEMI_DARKER_BG,
inactive_bg_fill: Color32::from_rgb(0x25, 0x25, 0x25),
inactive_weak_bg_fill: SEMI_DARK_BG,
}
}
pub fn mobile_dark_color_theme() -> ColorTheme {
ColorTheme {
panel_fill: Color32::BLACK,
noninteractive_weak_bg_fill: Color32::from_rgb(0x1F, 0x1F, 0x1F),
..desktop_dark_color_theme()
}
}
pub fn light_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: Color32::WHITE,
extreme_bg_color: LIGHTER_GRAY,
text_color: BLACK,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: Color32::WHITE,
window_stroke_color: DARKER_GRAY,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: Color32::WHITE,
noninteractive_weak_bg_fill: LIGHTER_GRAY,
noninteractive_bg_stroke_color: LIGHT_GRAY,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: EVEN_DARKER_GRAY,
inactive_bg_fill: LIGHTER_GRAY,
inactive_weak_bg_fill: LIGHTER_GRAY,
}
}
/// Create custom text sizes for any FontSizes
pub fn add_custom_style(is_mobile: bool, style: &mut Style) {
let font_size = if is_mobile {
fonts::mobile_font_size
} else {
fonts::desktop_font_size
};
style.text_styles = NotedeckTextStyle::iter()
.map(|text_style| {
(
text_style.text_style(),
FontId::new(font_size(&text_style), text_style.font_family()),
)
})
.collect();
style.interaction = Interaction {
tooltip_delay: 0.1,
show_tooltips_only_when_still: false,
..Interaction::default()
};
// debug: show callstack for the current widget on hover if all
// modifier keys are pressed down.
/*
#[cfg(feature = "debug-widget-callstack")]
{
#[cfg(not(debug_assertions))]
compile_error!(
"The `debug-widget-callstack` feature requires a debug build, \
release builds are unsupported."
);
style.debug.debug_on_hover_with_all_modifiers = true;
}
// debug: show an overlay on all interactive widgets
#[cfg(feature = "debug-interactive-widgets")]
{
#[cfg(not(debug_assertions))]
compile_error!(
"The `debug-interactive-widgets` feature requires a debug build, \
release builds are unsupported."
);
style.debug.show_interactive_widgets = true;
}
*/
}
pub fn light_mode() -> Visuals {
create_themed_visuals(crate::theme::light_color_theme(), Visuals::light())
}
pub fn dark_mode(is_oled: bool) -> Visuals {
create_themed_visuals(
if is_oled {
mobile_dark_color_theme()
} else {
desktop_dark_color_theme()
},
Visuals::dark(),
)
}
+8 -8
View File
@@ -2,16 +2,16 @@ use crate::debouncer::Debouncer;
use crate::{storage, DataPath, DataPathType, Directory}; use crate::{storage, DataPath, DataPathType, Directory};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::time::Duration; use std::time::Duration;
use tracing::info; // Adjust this import path as needed use tracing::info;
pub struct TimedSerializer<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> { pub struct TimedSerializer<T: PartialEq + Clone + Serialize + for<'de> Deserialize<'de>> {
directory: Directory, directory: Directory,
file_name: String, file_name: String,
debouncer: Debouncer, debouncer: Debouncer,
saved_item: Option<T>, saved_item: Option<T>,
} }
impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerializer<T> { impl<T: PartialEq + Clone + Serialize + for<'de> Deserialize<'de>> TimedSerializer<T> {
pub fn new(path: &DataPath, path_type: DataPathType, file_name: String) -> Self { pub fn new(path: &DataPath, path_type: DataPathType, file_name: String) -> Self {
let directory = Directory::new(path.path(path_type)); let directory = Directory::new(path.path(path_type));
let delay = Duration::from_millis(1000); let delay = Duration::from_millis(1000);
@@ -30,11 +30,11 @@ impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerialize
self self
} }
// returns whether successful /// Returns whether it actually wrote the new value
pub fn try_save(&mut self, cur_item: T) -> bool { pub fn try_save(&mut self, cur_item: T) -> bool {
if self.debouncer.should_act() { if self.debouncer.should_act() {
if let Some(saved_item) = self.saved_item { if let Some(ref saved_item) = self.saved_item {
if saved_item != cur_item { if *saved_item != cur_item {
return self.save(cur_item); return self.save(cur_item);
} }
} else { } else {
@@ -45,8 +45,8 @@ impl<T: PartialEq + Copy + Serialize + for<'de> Deserialize<'de>> TimedSerialize
} }
pub fn get_item(&self) -> Option<T> { pub fn get_item(&self) -> Option<T> {
if self.saved_item.is_some() { if let Some(ref item) = self.saved_item {
return self.saved_item; return Some(item.clone());
} }
if let Ok(file_contents) = self.directory.get_file(self.file_name.clone()) { if let Ok(file_contents) = self.directory.get_file(self.file_name.clone()) {
if let Ok(item) = serde_json::from_str::<T>(&file_contents) { if let Ok(item) = serde_json::from_str::<T>(&file_contents) {
+12 -1
View File
@@ -1,8 +1,19 @@
use crate::NotedeckTextStyle;
pub const NARROW_SCREEN_WIDTH: f32 = 550.0;
pub fn richtext_small<S>(text: S) -> egui::RichText
where
S: Into<String>,
{
egui::RichText::new(text).text_style(NotedeckTextStyle::Small.text_style())
}
/// Determine if the screen is narrow. This is useful for detecting mobile /// Determine if the screen is narrow. This is useful for detecting mobile
/// contexts, but with the nuance that we may also have a wide android tablet. /// contexts, but with the nuance that we may also have a wide android tablet.
pub fn is_narrow(ctx: &egui::Context) -> bool { pub fn is_narrow(ctx: &egui::Context) -> bool {
let screen_size = ctx.input(|c| c.screen_rect().size()); let screen_size = ctx.input(|c| c.screen_rect().size());
screen_size.x < 550.0 screen_size.x < NARROW_SCREEN_WIDTH
} }
pub fn is_oled() -> bool { pub fn is_oled() -> bool {
+7
View File
@@ -16,6 +16,7 @@ egui = { workspace = true }
notedeck_columns = { workspace = true } notedeck_columns = { workspace = true }
notedeck_ui = { workspace = true } notedeck_ui = { workspace = true }
notedeck_dave = { workspace = true } notedeck_dave = { workspace = true }
notedeck_notebook = { workspace = true }
notedeck = { workspace = true } notedeck = { workspace = true }
nostrdb = { workspace = true } nostrdb = { workspace = true }
puffin = { workspace = true, optional = true } puffin = { workspace = true, optional = true }
@@ -63,6 +64,12 @@ short_description = "The nostr browser"
identifier = "com.damus.notedeck" identifier = "com.damus.notedeck"
icon = ["assets/app_icon.icns"] icon = ["assets/app_icon.icns"]
[package.metadata.android.manifest.queries]
intent = [
{ action = ["android.intent.action.MAIN"] },
]
[package.metadata.android] [package.metadata.android]
package = "com.damus.app" package = "com.damus.app"
apk_name = "Notedeck" apk_name = "Notedeck"
@@ -23,9 +23,16 @@
</activity> </activity>
</application> </application>
<queries>
<intent>
<action android:name="android.intent.action.MAIN" />
</intent>
</queries>
<uses-feature android:name="android.hardware.vulkan.level" <uses-feature android:name="android.hardware.vulkan.level"
android:required="true" android:required="true"
android:version="1" /> android:version="1" />
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+3 -29
View File
@@ -2,13 +2,9 @@
//use egui_android::run_android; //use egui_android::run_android;
use egui_winit::winit::platform::android::activity::AndroidApp; use egui_winit::winit::platform::android::activity::AndroidApp;
use notedeck::enostr::Error;
use notedeck_columns::Damus;
use notedeck_dave::Dave;
use crate::{app::NotedeckApp, chrome::Chrome, setup::setup_chrome}; use crate::chrome::Chrome;
use notedeck::Notedeck; use notedeck::Notedeck;
use tracing::error;
#[no_mangle] #[no_mangle]
#[tokio::main] #[tokio::main]
@@ -69,30 +65,8 @@ pub async fn android_main(app: AndroidApp) {
Box::new(move |cc| { Box::new(move |cc| {
let ctx = &cc.egui_ctx; let ctx = &cc.egui_ctx;
let mut notedeck = Notedeck::new(ctx, path, &app_args); let mut notedeck = Notedeck::new(ctx, path, &app_args);
setup_chrome(ctx, &notedeck.args(), notedeck.theme()); notedeck.setup(ctx);
let chrome = Chrome::new_with_apps(cc, &app_args, &mut notedeck)?;
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::new();
// ensure we recognized all the arguments
let completely_unrecognized: Vec<String> = notedeck
.unrecognized_args()
.intersection(columns.unrecognized_args())
.cloned()
.collect();
if !completely_unrecognized.is_empty() {
error!("Unrecognized arguments: {:?}", completely_unrecognized);
return Err(Error::Empty.into());
}
chrome.add_app(NotedeckApp::Columns(columns));
chrome.add_app(NotedeckApp::Dave(dave));
// test dav
chrome.set_active(0);
notedeck.set_app(chrome); notedeck.set_app(chrome);
Ok(Box::new(notedeck)) Ok(Box::new(notedeck))
+5 -2
View File
@@ -1,11 +1,13 @@
use notedeck::{AppAction, AppContext}; use notedeck::{AppAction, AppContext};
use notedeck_columns::Damus; use notedeck_columns::Damus;
use notedeck_dave::Dave; use notedeck_dave::Dave;
use notedeck_notebook::Notebook;
#[allow(clippy::large_enum_variant)] #[allow(clippy::large_enum_variant)]
pub enum NotedeckApp { pub enum NotedeckApp {
Dave(Dave), Dave(Box<Dave>),
Columns(Damus), Columns(Box<Damus>),
Notebook(Box<Notebook>),
Other(Box<dyn notedeck::App>), Other(Box<dyn notedeck::App>),
} }
@@ -14,6 +16,7 @@ impl notedeck::App for NotedeckApp {
match self { match self {
NotedeckApp::Dave(dave) => dave.update(ctx, ui), NotedeckApp::Dave(dave) => dave.update(ctx, ui),
NotedeckApp::Columns(columns) => columns.update(ctx, ui), NotedeckApp::Columns(columns) => columns.update(ctx, ui),
NotedeckApp::Notebook(notebook) => notebook.update(ctx, ui),
NotedeckApp::Other(other) => other.update(ctx, ui), NotedeckApp::Other(other) => other.update(ctx, ui),
} }
} }
+167 -20
View File
@@ -2,16 +2,22 @@
//#[cfg(target_arch = "wasm32")] //#[cfg(target_arch = "wasm32")]
//use wasm_bindgen::prelude::*; //use wasm_bindgen::prelude::*;
use crate::app::NotedeckApp; use crate::app::NotedeckApp;
use eframe::CreationContext;
use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget}; use egui::{vec2, Button, Color32, Label, Layout, Rect, RichText, ThemePreference, Widget};
use egui_extras::{Size, StripBuilder}; use egui_extras::{Size, StripBuilder};
use nostrdb::{ProfileRecord, Transaction}; use nostrdb::{ProfileRecord, Transaction};
use notedeck::Error;
use notedeck::{ use notedeck::{
tr, App, AppAction, AppContext, Localization, NotedeckTextStyle, UserAccount, WalletType, tr, App, AppAction, AppContext, Localization, Notedeck, NotedeckOptions, NotedeckTextStyle,
UserAccount, WalletType,
}; };
use notedeck_columns::{ use notedeck_columns::{
column::SelectionResult, timeline::kind::ListKind, timeline::TimelineKind, Damus, column::SelectionResult,
timeline::{kind::ListKind, TimelineKind},
Damus,
}; };
use notedeck_dave::{Dave, DaveAvatar}; use notedeck_dave::{Dave, DaveAvatar};
use notedeck_notebook::Notebook;
use notedeck_ui::{app_images, AnimationHelper, ProfilePic}; use notedeck_ui::{app_images, AnimationHelper, ProfilePic};
static ICON_WIDTH: f32 = 40.0; static ICON_WIDTH: f32 = 40.0;
@@ -112,10 +118,8 @@ impl ChromePanelAction {
fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) { fn process(&self, ctx: &mut AppContext, chrome: &mut Chrome, ui: &mut egui::Ui) {
match self { match self {
Self::SaveTheme(theme) => { Self::SaveTheme(theme) => {
ui.ctx().options_mut(|o| { ui.ctx().set_theme(*theme);
o.theme_preference = *theme; ctx.settings.set_theme(*theme);
});
ctx.theme.save(*theme);
} }
Self::Toolbar(toolbar_action) => match toolbar_action { Self::Toolbar(toolbar_action) => match toolbar_action {
@@ -168,9 +172,49 @@ impl ChromePanelAction {
} }
} }
/// 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 { impl Chrome {
pub fn new() -> Self { /// Create a new chrome with the default app setup
Chrome::default() 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()));
}
chrome.set_active(0);
Ok(chrome)
} }
pub fn toggle(&mut self) { pub fn toggle(&mut self) {
@@ -201,6 +245,16 @@ impl Chrome {
None None
} }
fn get_notebook(&mut self) -> Option<&mut Notebook> {
for app in &mut self.apps {
if let NotedeckApp::Notebook(notebook) = app {
return Some(notebook);
}
}
None
}
fn switch_to_dave(&mut self) { fn switch_to_dave(&mut self) {
for (i, app) in self.apps.iter().enumerate() { for (i, app) in self.apps.iter().enumerate() {
if let NotedeckApp::Dave(_) = app { if let NotedeckApp::Dave(_) = app {
@@ -209,6 +263,14 @@ impl Chrome {
} }
} }
fn switch_to_notebook(&mut self) {
for (i, app) in self.apps.iter().enumerate() {
if let NotedeckApp::Notebook(_) = app {
self.active = i as i32;
}
}
}
fn switch_to_columns(&mut self) { fn switch_to_columns(&mut self) {
for (i, app) in self.apps.iter().enumerate() { for (i, app) in self.apps.iter().enumerate() {
if let NotedeckApp::Columns(_) = app { if let NotedeckApp::Columns(_) = app {
@@ -325,7 +387,12 @@ impl Chrome {
}); });
strip.cell(|ui| { strip.cell(|ui| {
if let Some(action) = self.toolbar(ui) { let pk = ctx.accounts.get_selected_account().key.pubkey;
let unseen_notification =
unseen_notification(self.get_columns_app(), ctx.ndb, pk);
if let Some(action) = self.toolbar(ui, unseen_notification) {
got_action = Some(ChromePanelAction::Toolbar(action)) got_action = Some(ChromePanelAction::Toolbar(action))
} }
}); });
@@ -334,7 +401,7 @@ impl Chrome {
got_action got_action
} }
fn toolbar(&mut self, ui: &mut egui::Ui) -> Option<ToolbarAction> { fn toolbar(&mut self, ui: &mut egui::Ui, unseen_notification: bool) -> Option<ToolbarAction> {
use egui_tabs::{TabColor, Tabs}; use egui_tabs::{TabColor, Tabs};
let rect = ui.available_rect_before_wrap(); let rect = ui.available_rect_before_wrap();
@@ -378,7 +445,9 @@ impl Chrome {
action = Some(ToolbarAction::Dave); action = Some(ToolbarAction::Dave);
} }
} }
} else if index == 2 && notifications_button(ui, btn_size).clicked() { } else if index == 2
&& notifications_button(ui, btn_size, unseen_notification).clicked()
{
action = Some(ToolbarAction::Notifications); action = Some(ToolbarAction::Notifications);
} }
@@ -430,14 +499,12 @@ impl Chrome {
ui.add(milestone_name(i18n)); ui.add(milestone_name(i18n));
ui.add_space(16.0); ui.add_space(16.0);
//let dark_mode = ui.ctx().style().visuals.dark_mode; //let dark_mode = ui.ctx().style().visuals.dark_mode;
{
if columns_button(ui) if columns_button(ui)
.on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked() .clicked()
{ {
self.active = 0; self.active = 0;
} }
}
ui.add_space(32.0); ui.add_space(32.0);
if let Some(dave) = self.get_dave() { if let Some(dave) = self.get_dave() {
@@ -448,8 +515,50 @@ impl Chrome {
self.switch_to_dave(); self.switch_to_dave();
} }
} }
//ui.add_space(32.0);
if let Some(_notebook) = self.get_notebook() {
if notebook_button(ui)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.clicked()
{
self.switch_to_notebook();
} }
} }
}
}
fn unseen_notification(
columns: Option<&mut Damus>,
ndb: &nostrdb::Ndb,
current_pk: notedeck::enostr::Pubkey,
) -> bool {
let Some(columns) = columns else {
return false;
};
let Some(tl) = columns
.timeline_cache
.get_mut(&TimelineKind::Notifications(current_pk))
else {
return false;
};
let freshness = &mut tl.current_view_mut().freshness;
freshness.update(|timestamp_last_viewed| {
let filter = notedeck_columns::timeline::kind::notifications_filter(&current_pk)
.since_mut(timestamp_last_viewed);
let txn = Transaction::new(ndb).expect("txn");
let Some(res) = ndb.query(&txn, &[filter], 1).ok() else {
return false;
};
!res.is_empty()
});
freshness.has_unseen()
}
impl notedeck::App for Chrome { impl notedeck::App for Chrome {
fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> Option<AppAction> { fn update(&mut self, ctx: &mut notedeck::AppContext, ui: &mut egui::Ui) -> Option<AppAction> {
@@ -504,6 +613,7 @@ fn expanding_button(
light_img: egui::Image, light_img: egui::Image,
dark_img: egui::Image, dark_img: egui::Image,
ui: &mut egui::Ui, ui: &mut egui::Ui,
unseen_indicator: bool,
) -> egui::Response { ) -> egui::Response {
let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget let max_size = ICON_WIDTH * ICON_EXPANSION_MULTIPLE; // max size of the widget
let img = if ui.visuals().dark_mode { let img = if ui.visuals().dark_mode {
@@ -515,16 +625,34 @@ fn expanding_button(
let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size)); let helper = AnimationHelper::new(ui, name, egui::vec2(max_size, max_size));
let cur_img_size = helper.scale_1d_pos(img_size); let cur_img_size = helper.scale_1d_pos(img_size);
img.paint_at(
ui, let paint_rect = helper
helper
.get_animation_rect() .get_animation_rect()
.shrink((max_size - cur_img_size) / 2.0), .shrink((max_size - cur_img_size) / 2.0);
); img.paint_at(ui, paint_rect);
if unseen_indicator {
paint_unseen_indicator(ui, paint_rect, helper.scale_1d_pos(3.0));
}
helper.take_animation_response() helper.take_animation_response()
} }
fn paint_unseen_indicator(ui: &mut egui::Ui, rect: egui::Rect, radius: f32) {
let center = rect.center();
let top_right = rect.right_top();
let distance = center.distance(top_right);
let midpoint = {
let mut cur = center;
cur.x += distance / 2.0;
cur.y -= distance / 2.0;
cur
};
let painter = ui.painter_at(rect);
painter.circle_filled(midpoint, radius, notedeck_ui::colors::PINK);
}
fn support_button(ui: &mut egui::Ui) -> egui::Response { fn support_button(ui: &mut egui::Ui) -> egui::Response {
expanding_button( expanding_button(
"help-button", "help-button",
@@ -532,6 +660,7 @@ fn support_button(ui: &mut egui::Ui) -> egui::Response {
app_images::help_light_image(), app_images::help_light_image(),
app_images::help_dark_image(), app_images::help_dark_image(),
ui, ui,
false,
) )
} }
@@ -542,16 +671,18 @@ fn settings_button(ui: &mut egui::Ui) -> egui::Response {
app_images::settings_light_image(), app_images::settings_light_image(),
app_images::settings_dark_image(), app_images::settings_dark_image(),
ui, ui,
false,
) )
} }
fn notifications_button(ui: &mut egui::Ui, size: f32) -> egui::Response { fn notifications_button(ui: &mut egui::Ui, size: f32, unseen_indicator: bool) -> egui::Response {
expanding_button( expanding_button(
"notifications-button", "notifications-button",
size, size,
app_images::notifications_light_image(), app_images::notifications_light_image(),
app_images::notifications_dark_image(), app_images::notifications_dark_image(),
ui, ui,
unseen_indicator,
) )
} }
@@ -562,6 +693,7 @@ fn home_button(ui: &mut egui::Ui, size: f32) -> egui::Response {
app_images::home_light_image(), app_images::home_light_image(),
app_images::home_dark_image(), app_images::home_dark_image(),
ui, ui,
false,
) )
} }
@@ -572,6 +704,7 @@ fn columns_button(ui: &mut egui::Ui) -> egui::Response {
app_images::columns_image(), app_images::columns_image(),
app_images::columns_image(), app_images::columns_image(),
ui, ui,
false,
) )
} }
@@ -582,6 +715,18 @@ fn accounts_button(ui: &mut egui::Ui) -> egui::Response {
app_images::accounts_image().tint(ui.visuals().text_color()), app_images::accounts_image().tint(ui.visuals().text_color()),
app_images::accounts_image(), app_images::accounts_image(),
ui, 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,
) )
} }
@@ -695,6 +840,7 @@ fn chrome_handle_app_action(
ctx.global_wallet, ctx.global_wallet,
ctx.zaps, ctx.zaps,
ctx.img_cache, ctx.img_cache,
&mut columns.view_state,
ui, ui,
); );
@@ -750,6 +896,7 @@ fn columns_route_to_profile(
ctx.global_wallet, ctx.global_wallet,
ctx.zaps, ctx.zaps,
ctx.img_cache, ctx.img_cache,
&mut columns.view_state,
ui, ui,
); );
@@ -861,7 +1008,7 @@ fn bottomup_sidebar(
.add(wallet_button()) .add(wallet_button())
.on_hover_cursor(egui::CursorIcon::PointingHand); .on_hover_cursor(egui::CursorIcon::PointingHand);
if ctx.args.debug { if ctx.args.options.contains(NotedeckOptions::Debug) {
ui.weak(format!("{}", ctx.frame_history.fps() as i32)); ui.weak(format!("{}", ctx.frame_history.fps() as i32));
ui.weak(format!( ui.weak(format!(
"{:10.1}", "{:10.1}",
-151
View File
@@ -1,151 +0,0 @@
use egui::{FontData, FontDefinitions, FontTweak};
use std::collections::BTreeMap;
use std::sync::Arc;
use tracing::debug;
use notedeck::fonts::NamedFontFamily;
// Use gossip's approach to font loading. This includes japanese fonts
// for rending stuff from japanese users.
pub fn setup_fonts(ctx: &egui::Context) {
let mut font_data: BTreeMap<String, Arc<FontData>> = BTreeMap::new();
let mut families = BTreeMap::new();
font_data.insert(
"Onest".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestRegular1602-hint.ttf"
))),
);
font_data.insert(
"OnestMedium".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestMedium1602-hint.ttf"
))),
);
font_data.insert(
"DejaVuSans".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/DejaVuSansSansEmoji.ttf"
))),
);
font_data.insert(
"OnestBold".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/onest/OnestBold1602-hint.ttf"
))),
);
/*
font_data.insert(
"DejaVuSansBold".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf"
)),
);
font_data.insert(
"DejaVuSans".to_owned(),
FontData::from_static(include_bytes!("../assets/fonts/DejaVuSansSansEmoji.ttf")),
);
font_data.insert(
"DejaVuSansBold".to_owned(),
FontData::from_static(include_bytes!(
"../assets/fonts/DejaVuSans-Bold-SansEmoji.ttf"
)),
);
*/
font_data.insert(
"Inconsolata".to_owned(),
Arc::new(
FontData::from_static(include_bytes!(
"../../../assets/fonts/Inconsolata-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.22, // This font is smaller than DejaVuSans
y_offset_factor: -0.18, // and too low
y_offset: 0.0,
baseline_offset_factor: 0.0,
}),
),
);
font_data.insert(
"NotoSansCJK".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansCJK-Regular.ttc"
))),
);
font_data.insert(
"NotoSansThai".to_owned(),
Arc::new(FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoSansThai-Regular.ttf"
))),
);
// Some good looking emojis. Use as first priority:
font_data.insert(
"NotoEmoji".to_owned(),
Arc::new(
FontData::from_static(include_bytes!(
"../../../assets/fonts/NotoEmoji-Regular.ttf"
))
.tweak(FontTweak {
scale: 1.1, // make them a touch larger
y_offset_factor: 0.0,
y_offset: 0.0,
baseline_offset_factor: 0.0,
}),
),
);
let base_fonts = vec![
"DejaVuSans".to_owned(),
"NotoEmoji".to_owned(),
"NotoSansCJK".to_owned(),
"NotoSansThai".to_owned(),
];
let mut proportional = vec!["Onest".to_owned()];
proportional.extend(base_fonts.clone());
let mut medium = vec!["OnestMedium".to_owned()];
medium.extend(base_fonts.clone());
let mut mono = vec!["Inconsolata".to_owned()];
mono.extend(base_fonts.clone());
let mut bold = vec!["OnestBold".to_owned()];
bold.extend(base_fonts.clone());
let emoji = vec!["NotoEmoji".to_owned()];
families.insert(egui::FontFamily::Proportional, proportional);
families.insert(egui::FontFamily::Monospace, mono);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Medium.as_str().into()),
medium,
);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Bold.as_str().into()),
bold,
);
families.insert(
egui::FontFamily::Name(NamedFontFamily::Emoji.as_str().into()),
emoji,
);
debug!("fonts: {:?}", families);
let defs = FontDefinitions {
font_data,
families,
};
ctx.set_fonts(defs);
}
-2
View File
@@ -1,6 +1,4 @@
pub mod fonts;
pub mod setup; pub mod setup;
pub mod theme;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
mod android; mod android;
+5 -32
View File
@@ -9,15 +9,8 @@ use re_memory::AccountingAllocator;
static GLOBAL: AccountingAllocator<std::alloc::System> = static GLOBAL: AccountingAllocator<std::alloc::System> =
AccountingAllocator::new(std::alloc::System); AccountingAllocator::new(std::alloc::System);
use notedeck::enostr::Error;
use notedeck::{DataPath, DataPathType, Notedeck}; use notedeck::{DataPath, DataPathType, Notedeck};
use notedeck_chrome::{ use notedeck_chrome::{setup::generate_native_options, Chrome};
setup::{generate_native_options, setup_chrome},
Chrome, NotedeckApp,
};
use notedeck_columns::Damus;
use notedeck_dave::Dave;
use tracing::error;
use tracing_appender::non_blocking::WorkerGuard; use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
@@ -93,29 +86,8 @@ async fn main() {
let ctx = &cc.egui_ctx; let ctx = &cc.egui_ctx;
let mut notedeck = Notedeck::new(ctx, base_path, &args); let mut notedeck = Notedeck::new(ctx, base_path, &args);
notedeck.setup(ctx);
let mut chrome = Chrome::new(); let chrome = Chrome::new_with_apps(cc, &args, &mut notedeck)?;
let columns = Damus::new(&mut notedeck.app_context(), &args);
let dave = Dave::new(cc.wgpu_render_state.as_ref());
setup_chrome(ctx, notedeck.args(), notedeck.theme());
// ensure we recognized all the arguments
let completely_unrecognized: Vec<String> = notedeck
.unrecognized_args()
.intersection(columns.unrecognized_args())
.cloned()
.collect();
if !completely_unrecognized.is_empty() {
error!("Unrecognized arguments: {:?}", completely_unrecognized);
return Err(Error::Empty.into());
}
chrome.add_app(NotedeckApp::Columns(columns));
chrome.add_app(NotedeckApp::Dave(dave));
chrome.set_active(0);
notedeck.set_app(chrome); notedeck.set_app(chrome);
Ok(Box::new(notedeck)) Ok(Box::new(notedeck))
@@ -149,7 +121,8 @@ pub fn main() {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{Damus, Notedeck}; use super::Notedeck;
use notedeck_columns::Damus;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
fn create_tmp_dir() -> PathBuf { fn create_tmp_dir() -> PathBuf {
+7 -1
View File
@@ -38,7 +38,13 @@ impl PreviewRunner {
"unrecognized args: {:?}", "unrecognized args: {:?}",
notedeck.unrecognized_args() notedeck.unrecognized_args()
); );
setup_chrome(ctx, notedeck.args(), notedeck.theme()); setup_chrome(
ctx,
notedeck.args(),
notedeck.theme(),
notedeck.note_body_font_size(),
notedeck.zoom_factor(),
);
notedeck.set_app(PreviewApp::new(preview)); notedeck.set_app(PreviewApp::new(preview));
-51
View File
@@ -1,57 +1,6 @@
use crate::{fonts, theme};
use eframe::NativeOptions; use eframe::NativeOptions;
use egui::ThemePreference;
use notedeck::{AppSizeHandler, DataPath}; use notedeck::{AppSizeHandler, DataPath};
use notedeck_ui::app_images; use notedeck_ui::app_images;
use tracing::info;
pub fn setup_chrome(ctx: &egui::Context, args: &notedeck::Args, theme: ThemePreference) {
let is_mobile = args
.is_mobile
.unwrap_or(notedeck::ui::is_compiled_as_mobile());
let is_oled = notedeck::ui::is_oled();
// Some people have been running notedeck in debug, let's catch that!
if !args.tests && cfg!(debug_assertions) && !args.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!();
}
ctx.options_mut(|o| {
info!("Loaded theme {:?} from disk", theme);
o.theme_preference = theme;
});
ctx.set_visuals_of(egui::Theme::Dark, theme::dark_mode(is_oled));
ctx.set_visuals_of(egui::Theme::Light, theme::light_mode());
setup_cc(ctx, is_mobile);
}
pub fn setup_cc(ctx: &egui::Context, is_mobile: bool) {
fonts::setup_fonts(ctx);
if notedeck::ui::is_compiled_as_mobile() {
ctx.set_pixels_per_point(ctx.pixels_per_point() + 0.2);
}
//ctx.set_pixels_per_point(1.0);
//
//
//ctx.tessellation_options_mut(|to| to.feathering = false);
egui_extras::install_image_loaders(ctx);
ctx.options_mut(|o| {
o.input_options.max_click_duration = 0.4;
});
ctx.all_styles_mut(|style| theme::add_custom_style(is_mobile, style));
}
pub fn generate_native_options(paths: DataPath) -> NativeOptions { pub fn generate_native_options(paths: DataPath) -> NativeOptions {
let window_builder = Box::new(move |builder: egui::ViewportBuilder| { let window_builder = Box::new(move |builder: egui::ViewportBuilder| {
-149
View File
@@ -1,149 +0,0 @@
use egui::{style::Interaction, Color32, FontId, Style, Visuals};
use notedeck::{ColorTheme, NotedeckTextStyle};
use strum::IntoEnumIterator;
pub const PURPLE: Color32 = Color32::from_rgb(0xCC, 0x43, 0xC5);
const PURPLE_ALT: Color32 = Color32::from_rgb(0x82, 0x56, 0xDD);
//pub const DARK_BG: Color32 = egui::Color32::from_rgb(40, 44, 52);
pub const GRAY_SECONDARY: Color32 = Color32::from_rgb(0x8A, 0x8A, 0x8A);
const BLACK: Color32 = Color32::from_rgb(0x00, 0x00, 0x00);
const RED_700: Color32 = Color32::from_rgb(0xC7, 0x37, 0x5A);
const ORANGE_700: Color32 = Color32::from_rgb(0xF6, 0xB1, 0x4A);
// BACKGROUNDS
const SEMI_DARKER_BG: Color32 = Color32::from_rgb(0x39, 0x39, 0x39);
const DARKER_BG: Color32 = Color32::from_rgb(0x1F, 0x1F, 0x1F);
const DARK_BG: Color32 = Color32::from_rgb(0x2C, 0x2C, 0x2C);
const DARK_ISH_BG: Color32 = Color32::from_rgb(0x25, 0x25, 0x25);
const SEMI_DARK_BG: Color32 = Color32::from_rgb(0x44, 0x44, 0x44);
const LIGHTER_GRAY: Color32 = Color32::from_rgb(0xf8, 0xf8, 0xf8);
const LIGHT_GRAY: Color32 = Color32::from_rgb(0xc8, 0xc8, 0xc8); // 78%
const DARKER_GRAY: Color32 = Color32::from_rgb(0xa5, 0xa5, 0xa5); // 65%
const EVEN_DARKER_GRAY: Color32 = Color32::from_rgb(0x89, 0x89, 0x89); // 54%
pub fn desktop_dark_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: DARKER_BG,
extreme_bg_color: DARK_ISH_BG,
text_color: Color32::WHITE,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: DARK_ISH_BG,
window_stroke_color: DARK_BG,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: DARK_ISH_BG,
noninteractive_weak_bg_fill: DARK_BG,
noninteractive_bg_stroke_color: SEMI_DARKER_BG,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: SEMI_DARKER_BG,
inactive_bg_fill: Color32::from_rgb(0x25, 0x25, 0x25),
inactive_weak_bg_fill: SEMI_DARK_BG,
}
}
pub fn mobile_dark_color_theme() -> ColorTheme {
ColorTheme {
panel_fill: Color32::BLACK,
noninteractive_weak_bg_fill: Color32::from_rgb(0x1F, 0x1F, 0x1F),
..desktop_dark_color_theme()
}
}
pub fn light_color_theme() -> ColorTheme {
ColorTheme {
// VISUALS
panel_fill: Color32::WHITE,
extreme_bg_color: LIGHTER_GRAY,
text_color: BLACK,
err_fg_color: RED_700,
warn_fg_color: ORANGE_700,
hyperlink_color: PURPLE,
selection_color: PURPLE_ALT,
// WINDOW
window_fill: Color32::WHITE,
window_stroke_color: DARKER_GRAY,
// NONINTERACTIVE WIDGET
noninteractive_bg_fill: Color32::WHITE,
noninteractive_weak_bg_fill: LIGHTER_GRAY,
noninteractive_bg_stroke_color: LIGHT_GRAY,
noninteractive_fg_stroke_color: GRAY_SECONDARY,
// INACTIVE WIDGET
inactive_bg_stroke_color: EVEN_DARKER_GRAY,
inactive_bg_fill: LIGHTER_GRAY,
inactive_weak_bg_fill: LIGHTER_GRAY,
}
}
pub fn light_mode() -> Visuals {
notedeck::theme::create_themed_visuals(light_color_theme(), Visuals::light())
}
pub fn dark_mode(is_oled: bool) -> Visuals {
notedeck::theme::create_themed_visuals(
if is_oled {
mobile_dark_color_theme()
} else {
desktop_dark_color_theme()
},
Visuals::dark(),
)
}
/// Create custom text sizes for any FontSizes
pub fn add_custom_style(is_mobile: bool, style: &mut Style) {
let font_size = if is_mobile {
notedeck::fonts::mobile_font_size
} else {
notedeck::fonts::desktop_font_size
};
style.text_styles = NotedeckTextStyle::iter()
.map(|text_style| {
(
text_style.text_style(),
FontId::new(font_size(&text_style), text_style.font_family()),
)
})
.collect();
style.interaction = Interaction {
tooltip_delay: 0.1,
show_tooltips_only_when_still: false,
..Interaction::default()
};
// debug: show callstack for the current widget on hover if all
// modifier keys are pressed down.
#[cfg(feature = "debug-widget-callstack")]
{
#[cfg(not(debug_assertions))]
compile_error!(
"The `debug-widget-callstack` feature requires a debug build, \
release builds are unsupported."
);
style.debug.debug_on_hover_with_all_modifiers = true;
}
// debug: show an overlay on all interactive widgets
#[cfg(feature = "debug-interactive-widgets")]
{
#[cfg(not(debug_assertions))]
compile_error!(
"The `debug-interactive-widgets` feature requires a debug build, \
release builds are unsupported."
);
style.debug.show_interactive_widgets = true;
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ image = { workspace = true }
indexmap = { workspace = true } indexmap = { workspace = true }
nostrdb = { workspace = true } nostrdb = { workspace = true }
notedeck_ui = { workspace = true } notedeck_ui = { workspace = true }
open = { workspace = true } robius-open = { workspace = true }
poll-promise = { workspace = true } poll-promise = { workspace = true }
puffin = { workspace = true, optional = true } puffin = { workspace = true, optional = true }
puffin_egui = { workspace = true, optional = true } puffin_egui = { workspace = true, optional = true }
+15 -1
View File
@@ -8,6 +8,7 @@ use crate::{
}, },
ThreadSelection, TimelineCache, TimelineKind, ThreadSelection, TimelineCache, TimelineKind,
}, },
view_state::ViewState,
}; };
use enostr::{NoteId, Pubkey, RelayPool}; use enostr::{NoteId, Pubkey, RelayPool};
@@ -16,6 +17,7 @@ use notedeck::{
get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache, get_wallet_for, note::ZapTargetAmount, Accounts, GlobalWallet, Images, NoteAction, NoteCache,
NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps, NoteZapTargetOwned, UnknownIds, ZapAction, ZapTarget, ZappingError, Zaps,
}; };
use notedeck_ui::media::MediaViewerFlags;
use tracing::error; use tracing::error;
pub struct NewNotes { pub struct NewNotes {
@@ -51,6 +53,7 @@ fn execute_note_action(
global_wallet: &mut GlobalWallet, global_wallet: &mut GlobalWallet,
zaps: &mut Zaps, zaps: &mut Zaps,
images: &mut Images, images: &mut Images,
view_state: &mut ViewState,
router_type: RouterType, router_type: RouterType,
ui: &mut egui::Ui, ui: &mut egui::Ui,
col: usize, col: usize,
@@ -153,7 +156,16 @@ fn execute_note_action(
} }
}, },
NoteAction::Media(media_action) => { NoteAction::Media(media_action) => {
media_action.process(images); media_action.on_view_media(|medias| {
view_state.media_viewer.media_info = medias.clone();
tracing::debug!("on_view_media {:?}", &medias);
view_state
.media_viewer
.flags
.set(MediaViewerFlags::Open, true);
});
media_action.process_default_media_actions(images)
} }
} }
@@ -180,6 +192,7 @@ pub fn execute_and_process_note_action(
global_wallet: &mut GlobalWallet, global_wallet: &mut GlobalWallet,
zaps: &mut Zaps, zaps: &mut Zaps,
images: &mut Images, images: &mut Images,
view_state: &mut ViewState,
ui: &mut egui::Ui, ui: &mut egui::Ui,
) -> Option<RouterAction> { ) -> Option<RouterAction> {
let router_type = { let router_type = {
@@ -204,6 +217,7 @@ pub fn execute_and_process_note_action(
global_wallet, global_wallet,
zaps, zaps,
images, images,
view_state,
router_type, router_type,
ui, ui,
col, col,
+87 -30
View File
@@ -10,19 +10,21 @@ use crate::{
subscriptions::{SubKind, Subscriptions}, subscriptions::{SubKind, Subscriptions},
support::Support, support::Support,
timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind}, timeline::{self, kind::ListKind, thread::Threads, TimelineCache, TimelineKind},
ui::{self, DesktopSidePanel, SidePanelAction}, ui::{self, DesktopSidePanel, ShowSourceClientOption, SidePanelAction},
view_state::ViewState, view_state::ViewState,
Result, Result,
}; };
use egui_extras::{Size, StripBuilder}; use egui_extras::{Size, StripBuilder};
use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool}; use enostr::{ClientMessage, PoolRelay, Pubkey, RelayEvent, RelayMessage, RelayPool};
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::{ use notedeck::{
tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState, tr, ui::is_narrow, Accounts, AppAction, AppContext, DataPath, DataPathType, FilterState,
Localization, UnknownIds, Images, JobsCache, Localization, NotedeckOptions, SettingsHandler, UnknownIds,
};
use notedeck_ui::{
media::{MediaViewer, MediaViewerFlags, MediaViewerState},
NoteOptions,
}; };
use notedeck_ui::{jobs::JobsCache, NoteOptions};
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::path::Path; use std::path::Path;
use std::time::Duration; use std::time::Duration;
@@ -359,18 +361,54 @@ fn render_damus(
app_ctx: &mut AppContext<'_>, app_ctx: &mut AppContext<'_>,
ui: &mut egui::Ui, ui: &mut egui::Ui,
) -> Option<AppAction> { ) -> Option<AppAction> {
damus
.note_options
.set(NoteOptions::Wide, is_narrow(ui.ctx()));
let app_action = if notedeck::ui::is_narrow(ui.ctx()) { let app_action = if notedeck::ui::is_narrow(ui.ctx()) {
render_damus_mobile(damus, app_ctx, ui) render_damus_mobile(damus, app_ctx, ui)
} else { } else {
render_damus_desktop(damus, app_ctx, ui) 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 // We use this for keeping timestamps and things up to date
ui.ctx().request_repaint_after(Duration::from_secs(5)); ui.ctx().request_repaint_after(Duration::from_secs(5));
app_action 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 { fn determine_key_storage_type() -> KeyStorageType {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -404,6 +442,14 @@ impl Damus {
let mut options = AppOptions::default(); let mut options = AppOptions::default();
let tmp_columns = !parsed_args.columns.is_empty(); let tmp_columns = !parsed_args.columns.is_empty();
options.set(AppOptions::TmpColumns, tmp_columns); 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 { let decks_cache = if tmp_columns {
info!("DecksCache: loading from command line arguments"); info!("DecksCache: loading from command line arguments");
@@ -448,34 +494,11 @@ impl Damus {
// cache.add_deck_default(*pk); // cache.add_deck_default(*pk);
//} //}
}; };
let settings = &app_context.settings;
let support = Support::new(app_context.path); let support = Support::new(app_context.path);
let mut note_options = NoteOptions::default();
note_options.set( let note_options = get_note_options(parsed_args, settings);
NoteOptions::Textmode,
parsed_args.is_flag_set(ColumnsFlag::Textmode),
);
note_options.set(
NoteOptions::ScrambleText,
parsed_args.is_flag_set(ColumnsFlag::Scramble),
);
note_options.set(
NoteOptions::HideMedia,
parsed_args.is_flag_set(ColumnsFlag::NoMedia),
);
note_options.set(
NoteOptions::ShowNoteClientTop,
parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientTop),
);
note_options.set(
NoteOptions::ShowNoteClientBottom,
parsed_args.is_flag_set(ColumnsFlag::ShowNoteClientBottom),
);
options.set(AppOptions::Debug, app_context.args.debug);
options.set(
AppOptions::SinceOptimize,
parsed_args.is_flag_set(ColumnsFlag::SinceOptimize),
);
let jobs = JobsCache::default(); let jobs = JobsCache::default();
@@ -557,6 +580,39 @@ impl Damus {
} }
} }
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::ShowNoteClientTop,
ShowSourceClientOption::Top == settings_handler.show_source_client().into()
|| args.is_flag_set(ColumnsFlag::ShowNoteClientTop),
);
note_options.set(
NoteOptions::ShowNoteClientBottom,
ShowSourceClientOption::Bottom == settings_handler.show_source_client().into()
|| args.is_flag_set(ColumnsFlag::ShowNoteClientBottom),
);
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) { fn circle_icon(ui: &mut egui::Ui, openness: f32, response: &egui::Response) {
let stroke = ui.style().interact(&response).fg_stroke; let stroke = ui.style().interact(&response).fg_stroke;
@@ -578,6 +634,7 @@ fn render_damus_mobile(
let mut app_action: Option<AppAction> = None; let mut app_action: Option<AppAction> = None;
let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize; let active_col = app.columns_mut(app_ctx.i18n, app_ctx.accounts).selected as usize;
if !app.columns(app_ctx.accounts).columns().is_empty() { if !app.columns(app_ctx.accounts).columns().is_empty() {
let r = nav::render_nav( let r = nav::render_nav(
active_col, active_col,
+2 -2
View File
@@ -11,7 +11,7 @@ use sha2::{Digest, Sha256};
use url::Url; use url::Url;
use crate::Error; use crate::Error;
use notedeck_ui::images::fetch_binary_from_disk; use notedeck::media::images::fetch_binary_from_disk;
pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap(); pub const NOSTR_BUILD_URL: fn() -> Url = || Url::parse("http://nostr.build").unwrap();
const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json"; const NIP96_WELL_KNOWN: &str = ".well-known/nostr/nip96.json";
@@ -143,7 +143,7 @@ pub fn nip96_upload(
Err(e) => { Err(e) => {
return Promise::from_ready(Err(Error::Generic(format!( return Promise::from_ready(Err(Error::Generic(format!(
"could not read contents of file to upload: {e}" "could not read contents of file to upload: {e}"
)))) ))));
} }
}; };
+11 -30
View File
@@ -21,7 +21,7 @@ use crate::{
note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType, QuoteRepostView}, note::{custom_zap::CustomZapView, NewPostAction, PostAction, PostType, QuoteRepostView},
profile::EditProfileView, profile::EditProfileView,
search::{FocusState, SearchView}, search::{FocusState, SearchView},
settings::{SettingsAction, ShowNoteClientOptions}, settings::SettingsAction,
support::SupportView, support::SupportView,
wallet::{get_default_zap_state, WalletAction, WalletState, WalletView}, wallet::{get_default_zap_state, WalletAction, WalletState, WalletView},
AccountsView, PostReplyView, PostView, ProfileView, RelayView, SettingsView, ThreadView, AccountsView, PostReplyView, PostView, ProfileView, RelayView, SettingsView, ThreadView,
@@ -37,7 +37,6 @@ use notedeck::{
get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteContext, get_current_default_msats, tr, ui::is_narrow, Accounts, AppContext, NoteAction, NoteContext,
RelayAction, RelayAction,
}; };
use notedeck_ui::NoteOptions;
use tracing::error; use tracing::error;
/// The result of processing a nav response /// The result of processing a nav response
@@ -459,6 +458,7 @@ fn process_render_nav_action(
ctx.global_wallet, ctx.global_wallet,
ctx.zaps, ctx.zaps,
ctx.img_cache, ctx.img_cache,
&mut app.view_state,
ui, ui,
) )
} }
@@ -486,7 +486,7 @@ fn process_render_nav_action(
None None
} }
RenderNavAction::SettingsAction(action) => { RenderNavAction::SettingsAction(action) => {
action.process_settings_action(app, ctx.theme, ctx.i18n, ctx.img_cache, ui.ctx()) action.process_settings_action(app, ctx.settings, ctx.i18n, ctx.img_cache, ui.ctx())
} }
}; };
@@ -545,6 +545,8 @@ fn render_nav_body(
scroll_to_top, scroll_to_top,
); );
app.timeline_cache.set_fresh(kind);
// always clear the scroll_to_top request // always clear the scroll_to_top request
if scroll_to_top { if scroll_to_top {
app.options.remove(AppOptions::ScrollToTop); app.options.remove(AppOptions::ScrollToTop);
@@ -581,35 +583,14 @@ fn render_nav_body(
.ui(ui) .ui(ui)
.map(RenderNavAction::RelayAction), .map(RenderNavAction::RelayAction),
Route::Settings => { Route::Settings => SettingsView::new(
let mut show_note_client = if app.note_options.contains(NoteOptions::ShowNoteClientTop) ctx.settings.get_settings_mut(),
{ &mut note_context,
ShowNoteClientOptions::Top &mut app.note_options,
} else if app.note_options.contains(NoteOptions::ShowNoteClientBottom) { &mut app.jobs,
ShowNoteClientOptions::Bottom
} else {
ShowNoteClientOptions::Hide
};
let mut theme: String = (if ui.visuals().dark_mode {
"Dark"
} else {
"Light"
})
.into();
let mut selected_language: String = ctx.i18n.get_current_locale().to_string();
SettingsView::new(
ctx.img_cache,
&mut selected_language,
&mut theme,
&mut show_note_client,
ctx.i18n,
) )
.ui(ui) .ui(ui)
.map(RenderNavAction::SettingsAction) .map(RenderNavAction::SettingsAction),
}
Route::Reply(id) => { Route::Reply(id) => {
let txn = if let Ok(txn) = Transaction::new(ctx.ndb) { let txn = if let Ok(txn) = Transaction::new(ctx.ndb) {
txn txn
+57 -13
View File
@@ -1,4 +1,8 @@
use egui::{text::LayoutJob, TextBuffer, TextFormat}; use egui::{
text::{CCursor, CCursorRange, LayoutJob},
text_edit::TextEditOutput,
TextBuffer, TextEdit, TextFormat,
};
use enostr::{FullKeypair, Pubkey}; use enostr::{FullKeypair, Pubkey};
use nostrdb::{Note, NoteBuilder, NoteReply}; use nostrdb::{Note, NoteBuilder, NoteReply};
use std::{ use std::{
@@ -270,6 +274,36 @@ impl Default for PostBuffer {
} }
} }
/// New cursor index (indexed by characters) after operation is performed
#[must_use = "must call MentionSelectedResponse::process"]
pub struct MentionSelectedResponse {
pub next_cursor_index: usize,
}
impl MentionSelectedResponse {
pub fn process(&self, ctx: &egui::Context, text_edit_output: &TextEditOutput) {
let text_edit_id = text_edit_output.response.id;
let Some(mut before_state) = TextEdit::load_state(ctx, text_edit_id) else {
return;
};
let mut new_cursor = text_edit_output
.galley
.from_ccursor(CCursor::new(self.next_cursor_index));
new_cursor.ccursor.prefer_next_row = true;
before_state
.cursor
.set_char_range(Some(CCursorRange::one(CCursor::new(
self.next_cursor_index,
))));
ctx.memory_mut(|mem| mem.request_focus(text_edit_id));
TextEdit::store_state(ctx, text_edit_id, before_state);
}
}
impl PostBuffer { impl PostBuffer {
pub fn get_new_mentions_key(&mut self) -> usize { pub fn get_new_mentions_key(&mut self) -> usize {
let prev = self.mentions_key; let prev = self.mentions_key;
@@ -319,15 +353,21 @@ impl PostBuffer {
mention_key: usize, mention_key: usize,
full_name: &str, full_name: &str,
pk: Pubkey, pk: Pubkey,
) { ) -> Option<MentionSelectedResponse> {
if let Some(info) = self.mentions.get(&mention_key) { let Some(info) = self.mentions.get(&mention_key) else {
let text_start_index = info.start_index + 1;
self.delete_char_range(text_start_index..info.end_index);
self.insert_text(full_name, text_start_index);
self.select_full_mention(mention_key, pk);
} else {
error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions); error!("Error selecting mention for index: {mention_key}. Have the following mentions: {:?}", self.mentions);
} return None;
};
let text_start_index = info.start_index + 1; // increment by one to exclude the mention indicator, '@'
self.delete_char_range(text_start_index..info.end_index);
let text_chars_inserted = self.insert_text(full_name, text_start_index);
self.select_full_mention(mention_key, pk);
let space_chars_inserted = self.insert_text(" ", text_start_index + text_chars_inserted);
Some(MentionSelectedResponse {
next_cursor_index: text_start_index + text_chars_inserted + space_chars_inserted,
})
} }
pub fn delete_mention(&mut self, mention_key: usize) { pub fn delete_mention(&mut self, mention_key: usize) {
@@ -919,7 +959,7 @@ mod tests {
buf.select_mention_and_replace_name(0, "jb55", JB55()); buf.select_mention_and_replace_name(0, "jb55", JB55());
assert_eq!(buf.as_str(), "@jb55 "); assert_eq!(buf.as_str(), "@jb55 ");
buf.insert_text(" test", 5); buf.insert_text("test", 6);
assert_eq!(buf.as_str(), "@jb55 test"); assert_eq!(buf.as_str(), "@jb55 test");
assert_eq!(buf.mentions.len(), 1); assert_eq!(buf.mentions.len(), 1);
@@ -1201,16 +1241,20 @@ mod tests {
buf.insert_text("@jb", 0); buf.insert_text("@jb", 0);
buf.select_mention_and_replace_name(0, "jb55", JB55()); buf.select_mention_and_replace_name(0, "jb55", JB55());
buf.insert_text(" test ", 5); buf.insert_text("test ", 6);
assert_eq!(buf.as_str(), "@jb55 test ");
buf.insert_text("@kernel", 11); buf.insert_text("@kernel", 11);
buf.select_mention_and_replace_name(1, "KernelKind", KK()); buf.select_mention_and_replace_name(1, "KernelKind", KK());
buf.insert_text(" test", 22); assert_eq!(buf.as_str(), "@jb55 test @KernelKind ");
buf.insert_text("test", 23);
assert_eq!(buf.as_str(), "@jb55 test @KernelKind test"); assert_eq!(buf.as_str(), "@jb55 test @KernelKind test");
assert_eq!(buf.mentions.len(), 2); assert_eq!(buf.mentions.len(), 2);
buf.insert_text(" ", 5);
buf.insert_text("@els", 6); buf.insert_text("@els", 6);
assert_eq!(buf.as_str(), "@jb55 @elstest @KernelKind test");
assert_eq!(buf.mentions.len(), 3); assert_eq!(buf.mentions.len(), 3);
assert_eq!(buf.mentions.get(&2).unwrap().bounds(), 6..10); assert_eq!(buf.mentions.get(&2).unwrap().bounds(), 6..10);
buf.select_mention_and_replace_name(2, "elsat", JB55()); buf.select_mention_and_replace_name(2, "elsat", JB55());
+1 -1
View File
@@ -28,7 +28,7 @@ impl Support {
} }
static MAX_LOG_LINES: usize = 500; static MAX_LOG_LINES: usize = 500;
static SUPPORT_EMAIL: &str = "support@damus.io"; pub static SUPPORT_EMAIL: &str = "support+notedeck@damus.io";
static EMAIL_TEMPLATE: &str = concat!("version ", env!("CARGO_PKG_VERSION"), "\nCommit hash: ", env!("GIT_COMMIT_HASH"), "\n\nDescribe the bug you have encountered:\n<-- your statement here -->\n\n===== Paste your log below =====\n\n"); static EMAIL_TEMPLATE: &str = concat!("version ", env!("CARGO_PKG_VERSION"), "\nCommit hash: ", env!("GIT_COMMIT_HASH"), "\n\nDescribe the bug you have encountered:\n<-- your statement here -->\n\n===== Paste your log below =====\n\n");
impl Support { impl Support {
@@ -221,6 +221,14 @@ impl TimelineCache {
pub fn num_timelines(&self) -> usize { pub fn num_timelines(&self) -> usize {
self.timelines.len() self.timelines.len()
} }
pub fn set_fresh(&mut self, kind: &TimelineKind) {
let Some(tl) = self.get_mut(kind) else {
return;
};
tl.current_view_mut().freshness.set_fresh();
}
} }
/// Look for new thread notes since our last fetch /// Look for new thread notes since our last fetch
+12 -10
View File
@@ -471,11 +471,9 @@ impl TimelineKind {
}, },
// TODO: still need to update this to fetch likes, zaps, etc // TODO: still need to update this to fetch likes, zaps, etc
TimelineKind::Notifications(pubkey) => FilterState::ready(vec![Filter::new() TimelineKind::Notifications(pubkey) => {
.pubkeys([pubkey.bytes()]) FilterState::ready(vec![notifications_filter(pubkey)])
.kinds([1]) }
.limit(default_limit())
.build()]),
TimelineKind::Hashtag(hashtag) => { TimelineKind::Hashtag(hashtag) => {
let filters = hashtag let filters = hashtag
@@ -573,11 +571,7 @@ impl TimelineKind {
)), )),
TimelineKind::Notifications(pk) => { TimelineKind::Notifications(pk) => {
let notifications_filter = Filter::new() let notifications_filter = notifications_filter(&pk);
.pubkeys([pk.bytes()])
.kinds([1])
.limit(default_limit())
.build();
Some(Timeline::new( Some(Timeline::new(
TimelineKind::notifications(pk), TimelineKind::notifications(pk),
@@ -628,6 +622,14 @@ impl TimelineKind {
} }
} }
pub fn notifications_filter(pk: &Pubkey) -> Filter {
Filter::new()
.pubkeys([pk.bytes()])
.kinds([1])
.limit(default_limit())
.build()
}
#[derive(Debug)] #[derive(Debug)]
pub struct TitleNeedsDb<'a> { pub struct TitleNeedsDb<'a> {
kind: &'a TimelineKind, kind: &'a TimelineKind,
+106 -2
View File
@@ -8,6 +8,7 @@ use crate::{
use notedeck::{ use notedeck::{
contacts::hybrid_contacts_filter, contacts::hybrid_contacts_filter,
debouncer::Debouncer,
filter::{self, HybridFilter}, filter::{self, HybridFilter},
tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, Localization, tr, Accounts, CachedNote, ContactState, FilterError, FilterState, FilterStates, Localization,
NoteCache, NoteRef, UnknownIds, NoteCache, NoteRef, UnknownIds,
@@ -16,8 +17,11 @@ use notedeck::{
use egui_virtual_list::VirtualList; use egui_virtual_list::VirtualList;
use enostr::{PoolRelay, Pubkey, RelayPool}; use enostr::{PoolRelay, Pubkey, RelayPool};
use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction}; use nostrdb::{Filter, Ndb, Note, NoteKey, Transaction};
use std::cell::RefCell; use std::{
use std::rc::Rc; cell::RefCell,
time::{Duration, UNIX_EPOCH},
};
use std::{rc::Rc, time::SystemTime};
use tracing::{debug, error, info, warn}; use tracing::{debug, error, info, warn};
@@ -103,6 +107,7 @@ pub struct TimelineTab {
pub selection: i32, pub selection: i32,
pub filter: ViewFilter, pub filter: ViewFilter,
pub list: Rc<RefCell<VirtualList>>, pub list: Rc<RefCell<VirtualList>>,
pub freshness: NotesFreshness,
} }
impl TimelineTab { impl TimelineTab {
@@ -138,6 +143,7 @@ impl TimelineTab {
selection, selection,
filter, filter,
list, list,
freshness: NotesFreshness::default(),
} }
} }
@@ -780,3 +786,101 @@ pub fn is_timeline_ready(
} }
} }
} }
#[derive(Debug)]
pub struct NotesFreshness {
debouncer: Debouncer,
state: NotesFreshnessState,
}
#[derive(Debug)]
enum NotesFreshnessState {
Fresh {
timestamp_viewed: u64,
},
Stale {
have_unseen: bool,
timestamp_last_viewed: u64,
},
}
impl Default for NotesFreshness {
fn default() -> Self {
Self {
debouncer: Debouncer::new(Duration::from_secs(2)),
state: NotesFreshnessState::Stale {
have_unseen: true,
timestamp_last_viewed: 0,
},
}
}
}
impl NotesFreshness {
pub fn set_fresh(&mut self) {
if !self.debouncer.should_act() {
return;
}
self.state = NotesFreshnessState::Fresh {
timestamp_viewed: timestamp_now(),
};
self.debouncer.bounce();
}
pub fn update(&mut self, check_have_unseen: impl FnOnce(u64) -> bool) {
if !self.debouncer.should_act() {
return;
}
match &self.state {
NotesFreshnessState::Fresh { timestamp_viewed } => {
let Ok(dur) = SystemTime::now()
.duration_since(UNIX_EPOCH + Duration::from_secs(*timestamp_viewed))
else {
return;
};
if dur > Duration::from_secs(2) {
self.state = NotesFreshnessState::Stale {
have_unseen: check_have_unseen(*timestamp_viewed),
timestamp_last_viewed: *timestamp_viewed,
};
}
}
NotesFreshnessState::Stale {
have_unseen,
timestamp_last_viewed,
} => {
if *have_unseen {
return;
}
self.state = NotesFreshnessState::Stale {
have_unseen: check_have_unseen(*timestamp_last_viewed),
timestamp_last_viewed: *timestamp_last_viewed,
};
}
}
self.debouncer.bounce();
}
pub fn has_unseen(&self) -> bool {
match &self.state {
NotesFreshnessState::Fresh {
timestamp_viewed: _,
} => false,
NotesFreshnessState::Stale {
have_unseen,
timestamp_last_viewed: _,
} => *have_unseen,
}
}
}
fn timestamp_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs()
}
@@ -6,8 +6,8 @@ use crate::{
}; };
use enostr::Pubkey; use enostr::Pubkey;
use notedeck::NoteContext; use notedeck::{JobsCache, NoteContext};
use notedeck_ui::{jobs::JobsCache, NoteOptions}; use notedeck_ui::NoteOptions;
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn render_timeline_route( pub fn render_timeline_route(
@@ -81,6 +81,9 @@ pub fn render_thread_route(
// default truncated everywher eelse // default truncated everywher eelse
note_options.set(NoteOptions::Truncate, false); note_options.set(NoteOptions::Truncate, false);
// We need the reply lines in threads
note_options.set(NoteOptions::Wide, false);
ui::ThreadView::new( ui::ThreadView::new(
threads, threads,
selection.selected_or_root(), selection.selected_or_root(),
@@ -11,19 +11,21 @@ use notedeck_ui::{
}; };
use tracing::error; use tracing::error;
pub struct SearchResultsView<'a> { /// Displays user profiles for the user to pick from.
/// Useful for manually typing a username and selecting the profile desired
pub struct MentionPickerView<'a> {
ndb: &'a Ndb, ndb: &'a Ndb,
txn: &'a Transaction, txn: &'a Transaction,
img_cache: &'a mut Images, img_cache: &'a mut Images,
results: &'a Vec<&'a [u8; 32]>, results: &'a Vec<&'a [u8; 32]>,
} }
pub enum SearchResultsResponse { pub enum MentionPickerResponse {
SelectResult(Option<usize>), SelectResult(Option<usize>),
DeleteMention, DeleteMention,
} }
impl<'a> SearchResultsView<'a> { impl<'a> MentionPickerView<'a> {
pub fn new( pub fn new(
img_cache: &'a mut Images, img_cache: &'a mut Images,
ndb: &'a Ndb, ndb: &'a Ndb,
@@ -38,8 +40,8 @@ impl<'a> SearchResultsView<'a> {
} }
} }
fn show(&mut self, ui: &mut egui::Ui, width: f32) -> SearchResultsResponse { fn show(&mut self, ui: &mut egui::Ui, width: f32) -> MentionPickerResponse {
let mut search_results_selection = None; let mut selection = None;
ui.vertical(|ui| { ui.vertical(|ui| {
for (i, res) in self.results.iter().enumerate() { for (i, res) in self.results.iter().enumerate() {
let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) { let profile = match self.ndb.get_profile_by_pubkey(self.txn, res) {
@@ -54,16 +56,16 @@ impl<'a> SearchResultsView<'a> {
.add(user_result(&profile, self.img_cache, i, width)) .add(user_result(&profile, self.img_cache, i, width))
.clicked() .clicked()
{ {
search_results_selection = Some(i) selection = Some(i)
} }
} }
}); });
SearchResultsResponse::SelectResult(search_results_selection) MentionPickerResponse::SelectResult(selection)
} }
pub fn show_in_rect(&mut self, rect: egui::Rect, ui: &mut egui::Ui) -> SearchResultsResponse { pub fn show_in_rect(&mut self, rect: egui::Rect, ui: &mut egui::Ui) -> MentionPickerResponse {
let widget_id = ui.id().with("search_results"); let widget_id = ui.id().with("mention_results");
let area_resp = egui::Area::new(widget_id) let area_resp = egui::Area::new(widget_id)
.order(egui::Order::Foreground) .order(egui::Order::Foreground)
.fixed_pos(rect.left_top()) .fixed_pos(rect.left_top())
@@ -72,10 +74,10 @@ impl<'a> SearchResultsView<'a> {
let inner_margin_size = 8.0; let inner_margin_size = 8.0;
egui::Frame::NONE egui::Frame::NONE
.fill(ui.visuals().panel_fill) .fill(ui.visuals().panel_fill)
.inner_margin(inner_margin_size)
.show(ui, |ui| { .show(ui, |ui| {
let width = rect.width() - (2.0 * inner_margin_size); let width = rect.width() - (2.0 * inner_margin_size);
ui.allocate_space(vec2(ui.available_width(), inner_margin_size));
let close_button_resp = { let close_button_resp = {
let close_button_size = 16.0; let close_button_size = 16.0;
let (close_section_rect, _) = ui.allocate_exact_size( let (close_section_rect, _) = ui.allocate_exact_size(
@@ -95,16 +97,16 @@ impl<'a> SearchResultsView<'a> {
.inner .inner
}; };
ui.add_space(8.0); ui.allocate_space(vec2(ui.available_width(), inner_margin_size));
let scroll_resp = ScrollArea::vertical() let scroll_resp = ScrollArea::vertical()
.max_width(width) .max_width(rect.width())
.auto_shrink(Vec2b::FALSE) .auto_shrink(Vec2b::FALSE)
.show(ui, |ui| self.show(ui, width)); .show(ui, |ui| self.show(ui, width));
ui.advance_cursor_after_rect(rect); ui.advance_cursor_after_rect(rect);
if close_button_resp { if close_button_resp {
SearchResultsResponse::DeleteMention MentionPickerResponse::DeleteMention
} else { } else {
scroll_resp.inner scroll_resp.inner
} }
@@ -128,7 +130,18 @@ fn user_result<'a>(
let spacing = 8.0; let spacing = 8.0;
let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body); let body_font_size = get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
let helper = AnimationHelper::new(ui, ("user_result", index), vec2(width, max_image)); let animation_rect = {
let max_width = ui.available_width();
let extra_width = (max_width - width) / 2.0;
let left = ui.cursor().left();
let (rect, _) =
ui.allocate_exact_size(vec2(width + extra_width, max_image), egui::Sense::click());
let (_, right) = rect.split_left_right_at_x(left + extra_width);
right
};
let helper = AnimationHelper::new_from_rect(ui, ("user_result", index), animation_rect);
let icon_rect = { let icon_rect = {
let r = helper.get_animation_rect(); let r = helper.get_animation_rect();
+2 -1
View File
@@ -5,13 +5,13 @@ pub mod column;
pub mod configure_deck; pub mod configure_deck;
pub mod edit_deck; pub mod edit_deck;
pub mod images; pub mod images;
pub mod mentions_picker;
pub mod note; pub mod note;
pub mod post; pub mod post;
pub mod preview; pub mod preview;
pub mod profile; pub mod profile;
pub mod relay; pub mod relay;
pub mod search; pub mod search;
pub mod search_results;
pub mod settings; pub mod settings;
pub mod side_panel; pub mod side_panel;
pub mod support; pub mod support;
@@ -26,6 +26,7 @@ pub use preview::{Preview, PreviewApp, PreviewConfig};
pub use profile::ProfileView; pub use profile::ProfileView;
pub use relay::RelayView; pub use relay::RelayView;
pub use settings::SettingsView; pub use settings::SettingsView;
pub use settings::ShowSourceClientOption;
pub use side_panel::{DesktopSidePanel, SidePanelAction}; pub use side_panel::{DesktopSidePanel, SidePanelAction};
pub use thread::ThreadView; pub use thread::ThreadView;
pub use timeline::TimelineView; pub use timeline::TimelineView;
+22 -15
View File
@@ -2,7 +2,7 @@ use crate::draft::{Draft, Drafts, MentionHint};
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
use crate::media_upload::{nostrbuild_nip96_upload, MediaPath}; use crate::media_upload::{nostrbuild_nip96_upload, MediaPath};
use crate::post::{downcast_post_buffer, MentionType, NewPost}; use crate::post::{downcast_post_buffer, MentionType, NewPost};
use crate::ui::search_results::SearchResultsView; use crate::ui::mentions_picker::MentionPickerView;
use crate::ui::{self, Preview, PreviewConfig}; use crate::ui::{self, Preview, PreviewConfig};
use crate::Result; use crate::Result;
@@ -14,13 +14,12 @@ use egui::{
}; };
use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool}; use enostr::{FilledKeypair, FullKeypair, NoteId, Pubkey, RelayPool};
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::media::gif::ensure_latest_texture;
use notedeck::{get_render_state, JobsCache, PixelDimensions, RenderState};
use notedeck_ui::{ use notedeck_ui::{
app_images, app_images,
blur::PixelDimensions,
context_menu::{input_context, PasteBehavior}, context_menu::{input_context, PasteBehavior},
gif::{handle_repaint, retrieve_latest_texture},
images::{get_render_state, RenderState},
jobs::JobsCache,
note::render_note_preview, note::render_note_preview,
NoteOptions, ProfilePic, NoteOptions, ProfilePic,
}; };
@@ -219,6 +218,7 @@ impl<'a, 'd> PostView<'a, 'd> {
out.response out.response
} }
// Displays the mention picker and handles when one is selected.
fn show_mention_hints( fn show_mention_hints(
&mut self, &mut self,
txn: &nostrdb::Transaction, txn: &nostrdb::Transaction,
@@ -274,7 +274,7 @@ impl<'a, 'd> PostView<'a, 'd> {
return; return;
}; };
let resp = SearchResultsView::new( let resp = MentionPickerView::new(
self.note_context.img_cache, self.note_context.img_cache,
self.note_context.ndb, self.note_context.ndb,
txn, txn,
@@ -282,26 +282,35 @@ impl<'a, 'd> PostView<'a, 'd> {
) )
.show_in_rect(hint_rect, ui); .show_in_rect(hint_rect, ui);
let mut selection_made = None;
match resp { match resp {
ui::search_results::SearchResultsResponse::SelectResult(selection) => { ui::mentions_picker::MentionPickerResponse::SelectResult(selection) => {
if let Some(hint_index) = selection { if let Some(hint_index) = selection {
if let Some(pk) = res.get(hint_index) { if let Some(pk) = res.get(hint_index) {
let record = self.note_context.ndb.get_profile_by_pubkey(txn, pk); let record = self.note_context.ndb.get_profile_by_pubkey(txn, pk);
if let Some(made_selection) =
self.draft.buffer.select_mention_and_replace_name( self.draft.buffer.select_mention_and_replace_name(
mention.index, mention.index,
get_display_name(record.ok().as_ref()).name(), get_display_name(record.ok().as_ref()).name(),
Pubkey::new(**pk), Pubkey::new(**pk),
); )
{
selection_made = Some(made_selection);
}
self.draft.cur_mention_hint = None; self.draft.cur_mention_hint = None;
} }
} }
} }
ui::search_results::SearchResultsResponse::DeleteMention => { ui::mentions_picker::MentionPickerResponse::DeleteMention => {
self.draft.buffer.delete_mention(mention.index) self.draft.buffer.delete_mention(mention.index)
} }
} }
if let Some(selection) = selection_made {
selection.process(ui.ctx(), textedit_output);
}
} }
fn focused(&self, ui: &egui::Ui) -> bool { fn focused(&self, ui: &egui::Ui) -> bool {
@@ -471,7 +480,7 @@ impl<'a, 'd> PostView<'a, 'd> {
self.note_context.img_cache, self.note_context.img_cache,
cache_type, cache_type,
url, url,
notedeck_ui::images::ImageType::Content(Some((width, height))), notedeck::ImageType::Content(Some((width, height))),
); );
render_post_view_media( render_post_view_media(
@@ -595,12 +604,10 @@ fn render_post_view_media(
.to_points(ui.pixels_per_point()) .to_points(ui.pixels_per_point())
.to_vec(); .to_vec();
let texture_handle = handle_repaint( let texture_handle =
ui, ensure_latest_texture(ui, url, render_state.gifs, renderable_media);
retrieve_latest_texture(url, render_state.gifs, renderable_media),
);
let img_resp = ui.add( let img_resp = ui.add(
egui::Image::new(texture_handle) egui::Image::new(&texture_handle)
.max_size(size) .max_size(size)
.corner_radius(12.0), .corner_radius(12.0),
); );
@@ -6,8 +6,8 @@ use crate::{
use egui::ScrollArea; use egui::ScrollArea;
use enostr::{FilledKeypair, NoteId}; use enostr::{FilledKeypair, NoteId};
use notedeck::NoteContext; use notedeck::{JobsCache, NoteContext};
use notedeck_ui::{jobs::JobsCache, NoteOptions}; use notedeck_ui::NoteOptions;
pub struct QuoteRepostView<'a, 'd> { pub struct QuoteRepostView<'a, 'd> {
note_context: &'a mut NoteContext<'d>, note_context: &'a mut NoteContext<'d>,
+1 -2
View File
@@ -6,8 +6,7 @@ use crate::ui::{
use egui::{Rect, Response, ScrollArea, Ui}; use egui::{Rect, Response, ScrollArea, Ui};
use enostr::{FilledKeypair, NoteId}; use enostr::{FilledKeypair, NoteId};
use notedeck::NoteContext; use notedeck::{JobsCache, NoteContext};
use notedeck_ui::jobs::JobsCache;
use notedeck_ui::{NoteOptions, NoteView, ProfilePic}; use notedeck_ui::{NoteOptions, NoteView, ProfilePic};
pub struct PostReplyView<'a, 'd> { pub struct PostReplyView<'a, 'd> {
@@ -6,6 +6,7 @@ use enostr::Pubkey;
use nostrdb::{ProfileRecord, Transaction}; use nostrdb::{ProfileRecord, Transaction};
use notedeck::{tr, Localization}; use notedeck::{tr, Localization};
use notedeck_ui::profile::follow_button; use notedeck_ui::profile::follow_button;
use robius_open::Uri;
use tracing::error; use tracing::error;
use crate::{ use crate::{
@@ -13,12 +14,11 @@ use crate::{
ui::timeline::{tabs_ui, TimelineTabView}, ui::timeline::{tabs_ui, TimelineTabView},
}; };
use notedeck::{ use notedeck::{
name::get_display_name, profile::get_profile_url, IsFollowing, NoteAction, NoteContext, name::get_display_name, profile::get_profile_url, IsFollowing, JobsCache, NoteAction,
NotedeckTextStyle, NoteContext, NotedeckTextStyle,
}; };
use notedeck_ui::{ use notedeck_ui::{
app_images, app_images,
jobs::JobsCache,
profile::{about_section_widget, banner, display_name_widget}, profile::{about_section_widget, banner, display_name_widget},
NoteOptions, ProfilePic, NoteOptions, ProfilePic,
}; };
@@ -286,8 +286,8 @@ fn handle_link(ui: &mut egui::Ui, website_url: &str) {
.interact(Sense::click()) .interact(Sense::click())
.clicked() .clicked()
{ {
if let Err(e) = open::that(website_url) { if let Err(e) = Uri::new(website_url).open() {
error!("Failed to open URL {} because: {}", website_url, e); error!("Failed to open URL {} because: {:?}", website_url, e);
}; };
} }
} }
+7 -7
View File
@@ -5,11 +5,11 @@ use state::TypingType;
use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView}; use crate::{timeline::TimelineTab, ui::timeline::TimelineTabView};
use egui_winit::clipboard::Clipboard; use egui_winit::clipboard::Clipboard;
use nostrdb::{Filter, Ndb, Transaction}; use nostrdb::{Filter, Ndb, Transaction};
use notedeck::{tr, tr_plural, Localization, NoteAction, NoteContext, NoteRef}; use notedeck::{tr, tr_plural, JobsCache, Localization, NoteAction, NoteContext, NoteRef};
use notedeck_ui::{ use notedeck_ui::{
context_menu::{input_context, PasteBehavior}, context_menu::{input_context, PasteBehavior},
icons::search_icon, icons::search_icon,
jobs::JobsCache,
padding, NoteOptions, padding, NoteOptions,
}; };
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -19,7 +19,7 @@ mod state;
pub use state::{FocusState, SearchQueryState, SearchState}; pub use state::{FocusState, SearchQueryState, SearchState};
use super::search_results::{SearchResultsResponse, SearchResultsView}; use super::mentions_picker::{MentionPickerResponse, MentionPickerView};
pub struct SearchView<'a, 'd> { pub struct SearchView<'a, 'd> {
query: &'a mut SearchQueryState, query: &'a mut SearchQueryState,
@@ -76,7 +76,7 @@ impl<'a, 'd> SearchView<'a, 'd> {
break 's; break 's;
}; };
let search_res = SearchResultsView::new( let search_res = MentionPickerView::new(
self.note_context.img_cache, self.note_context.img_cache,
self.note_context.ndb, self.note_context.ndb,
self.txn, self.txn,
@@ -85,7 +85,7 @@ impl<'a, 'd> SearchView<'a, 'd> {
.show_in_rect(ui.available_rect_before_wrap(), ui); .show_in_rect(ui.available_rect_before_wrap(), ui);
search_action = match search_res { search_action = match search_res {
SearchResultsResponse::SelectResult(Some(index)) => { MentionPickerResponse::SelectResult(Some(index)) => {
let Some(pk_bytes) = results.get(index) else { let Some(pk_bytes) = results.get(index) else {
break 's; break 's;
}; };
@@ -103,8 +103,8 @@ impl<'a, 'd> SearchView<'a, 'd> {
new_search_text: format!("@{username}"), new_search_text: format!("@{username}"),
}) })
} }
SearchResultsResponse::DeleteMention => Some(SearchAction::CloseMention), MentionPickerResponse::DeleteMention => Some(SearchAction::CloseMention),
SearchResultsResponse::SelectResult(None) => break 's, MentionPickerResponse::SelectResult(None) => break 's,
}; };
} }
SearchState::PerformSearch(search_type) => { SearchState::PerformSearch(search_type) => {
+407 -234
View File
@@ -1,22 +1,115 @@
use egui::{vec2, Button, Color32, ComboBox, Frame, Margin, RichText, ThemePreference}; use egui::{
use notedeck::{tr, Images, LanguageIdentifier, Localization, NotedeckTextStyle, ThemeHandler}; vec2, Button, Color32, ComboBox, FontId, Frame, Margin, RichText, ScrollArea, ThemePreference,
use notedeck_ui::NoteOptions; };
use enostr::NoteId;
use nostrdb::Transaction;
use notedeck::{
tr,
ui::{is_narrow, richtext_small},
Images, JobsCache, LanguageIdentifier, Localization, NoteContext, NotedeckTextStyle, Settings,
SettingsHandler, DEFAULT_NOTE_BODY_FONT_SIZE,
};
use notedeck_ui::{NoteOptions, NoteView};
use strum::Display; use strum::Display;
use crate::{nav::RouterAction, Damus, Route}; use crate::{nav::RouterAction, Damus, Route};
const PREVIEW_NOTE_ID: &str = "note1edjc8ggj07hwv77g2405uh6j2jkk5aud22gktxrvc2wnre4vdwgqzlv2gw";
const THEME_LIGHT: &str = "Light";
const THEME_DARK: &str = "Dark";
const MIN_ZOOM: f32 = 0.5;
const MAX_ZOOM: f32 = 3.0;
const ZOOM_STEP: f32 = 0.1;
const RESET_ZOOM: f32 = 1.0;
#[derive(Clone, Copy, PartialEq, Eq, Display)] #[derive(Clone, Copy, PartialEq, Eq, Display)]
pub enum ShowNoteClientOptions { pub enum ShowSourceClientOption {
Hide, Hide,
Top, Top,
Bottom, Bottom,
} }
impl From<ShowSourceClientOption> for String {
fn from(show_option: ShowSourceClientOption) -> Self {
match show_option {
ShowSourceClientOption::Hide => "hide".to_string(),
ShowSourceClientOption::Top => "top".to_string(),
ShowSourceClientOption::Bottom => "bottom".to_string(),
}
}
}
impl From<NoteOptions> for ShowSourceClientOption {
fn from(note_options: NoteOptions) -> Self {
if note_options.contains(NoteOptions::ShowNoteClientTop) {
ShowSourceClientOption::Top
} else if note_options.contains(NoteOptions::ShowNoteClientBottom) {
ShowSourceClientOption::Bottom
} else {
ShowSourceClientOption::Hide
}
}
}
impl From<String> for ShowSourceClientOption {
fn from(s: String) -> Self {
match s.to_lowercase().as_str() {
"hide" => Self::Hide,
"top" => Self::Top,
"bottom" => Self::Bottom,
_ => Self::Hide, // default fallback
}
}
}
impl ShowSourceClientOption {
pub fn set_note_options(self, note_options: &mut NoteOptions) {
match self {
Self::Hide => {
note_options.set(NoteOptions::ShowNoteClientTop, false);
note_options.set(NoteOptions::ShowNoteClientBottom, false);
}
Self::Bottom => {
note_options.set(NoteOptions::ShowNoteClientTop, false);
note_options.set(NoteOptions::ShowNoteClientBottom, true);
}
Self::Top => {
note_options.set(NoteOptions::ShowNoteClientTop, true);
note_options.set(NoteOptions::ShowNoteClientBottom, false);
}
}
}
fn label(&self, i18n: &mut Localization) -> String {
match self {
Self::Hide => tr!(
i18n,
"Hide",
"Option in settings section to hide the source client label in note display"
),
Self::Top => tr!(
i18n,
"Top",
"Option in settings section to show the source client label at the top of the note"
),
Self::Bottom => tr!(
i18n,
"Bottom",
"Option in settings section to show the source client label at the bottom of the note"
),
}
}
}
pub enum SettingsAction { pub enum SettingsAction {
SetZoom(f32), SetZoomFactor(f32),
SetTheme(ThemePreference), SetTheme(ThemePreference),
SetShowNoteClient(ShowNoteClientOptions), SetShowSourceClient(ShowSourceClientOption),
SetLocale(LanguageIdentifier), SetLocale(LanguageIdentifier),
SetRepliestNewestFirst(bool),
SetNoteBodyFontSize(f32),
OpenRelays, OpenRelays,
OpenCacheFolder, OpenCacheFolder,
ClearCacheFolder, ClearCacheFolder,
@@ -26,7 +119,7 @@ impl SettingsAction {
pub fn process_settings_action<'a>( pub fn process_settings_action<'a>(
self, self,
app: &mut Damus, app: &mut Damus,
theme_handler: &'a mut ThemeHandler, settings: &'a mut SettingsHandler,
i18n: &'a mut Localization, i18n: &'a mut Localization,
img_cache: &mut Images, img_cache: &mut Images,
ctx: &egui::Context, ctx: &egui::Context,
@@ -34,152 +127,195 @@ impl SettingsAction {
let mut route_action: Option<RouterAction> = None; let mut route_action: Option<RouterAction> = None;
match self { match self {
SettingsAction::OpenRelays => { Self::OpenRelays => {
route_action = Some(RouterAction::route_to(Route::Relays)); route_action = Some(RouterAction::route_to(Route::Relays));
} }
SettingsAction::SetZoom(zoom_level) => { Self::SetZoomFactor(zoom_factor) => {
ctx.set_zoom_factor(zoom_level); ctx.set_zoom_factor(zoom_factor);
settings.set_zoom_factor(zoom_factor);
} }
SettingsAction::SetShowNoteClient(newvalue) => match newvalue { Self::SetShowSourceClient(option) => {
ShowNoteClientOptions::Hide => { option.set_note_options(&mut app.note_options);
app.note_options.set(NoteOptions::ShowNoteClientTop, false);
app.note_options settings.set_show_source_client(option);
.set(NoteOptions::ShowNoteClientBottom, false);
} }
ShowNoteClientOptions::Bottom => { Self::SetTheme(theme) => {
app.note_options.set(NoteOptions::ShowNoteClientTop, false); ctx.set_theme(theme);
app.note_options settings.set_theme(theme);
.set(NoteOptions::ShowNoteClientBottom, true);
} }
ShowNoteClientOptions::Top => { Self::SetLocale(language) => {
app.note_options.set(NoteOptions::ShowNoteClientTop, true); if i18n.set_locale(language.clone()).is_ok() {
app.note_options settings.set_locale(language.to_string());
.set(NoteOptions::ShowNoteClientBottom, false);
} }
},
SettingsAction::SetTheme(theme) => {
ctx.options_mut(|o| {
o.theme_preference = theme;
});
theme_handler.save(theme);
} }
SettingsAction::SetLocale(language) => { Self::SetRepliestNewestFirst(value) => {
_ = i18n.set_locale(language); app.note_options.set(NoteOptions::RepliesNewestFirst, value);
settings.set_show_replies_newest_first(value);
} }
SettingsAction::OpenCacheFolder => { Self::OpenCacheFolder => {
use opener; use opener;
let _ = opener::open(img_cache.base_path.clone()); let _ = opener::open(img_cache.base_path.clone());
} }
SettingsAction::ClearCacheFolder => { Self::ClearCacheFolder => {
let _ = img_cache.clear_folder_contents(); let _ = img_cache.clear_folder_contents();
} }
Self::SetNoteBodyFontSize(size) => {
let mut style = (*ctx.style()).clone();
style.text_styles.insert(
NotedeckTextStyle::NoteBody.text_style(),
FontId::proportional(size),
);
ctx.set_style(style);
settings.set_note_body_font_size(size);
}
} }
route_action route_action
} }
} }
pub struct SettingsView<'a> { pub struct SettingsView<'a> {
theme: &'a mut String, settings: &'a mut Settings,
selected_language: &'a mut String, note_context: &'a mut NoteContext<'a>,
show_note_client: &'a mut ShowNoteClientOptions, note_options: &'a mut NoteOptions,
i18n: &'a mut Localization, jobs: &'a mut JobsCache,
img_cache: &'a mut Images, }
fn settings_group<S>(ui: &mut egui::Ui, title: S, contents: impl FnOnce(&mut egui::Ui))
where
S: Into<String>,
{
Frame::group(ui.style())
.fill(ui.style().visuals.widgets.open.bg_fill)
.inner_margin(10.0)
.show(ui, |ui| {
ui.label(RichText::new(title).text_style(NotedeckTextStyle::Body.text_style()));
ui.separator();
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(10.0, 10.0);
contents(ui)
});
});
} }
impl<'a> SettingsView<'a> { impl<'a> SettingsView<'a> {
pub fn new( pub fn new(
img_cache: &'a mut Images, settings: &'a mut Settings,
selected_language: &'a mut String, note_context: &'a mut NoteContext<'a>,
theme: &'a mut String, note_options: &'a mut NoteOptions,
show_note_client: &'a mut ShowNoteClientOptions, jobs: &'a mut JobsCache,
i18n: &'a mut Localization,
) -> Self { ) -> Self {
Self { Self {
show_note_client, settings,
theme, note_context,
img_cache, note_options,
selected_language, jobs,
i18n,
} }
} }
/// Get the localized name for a language identifier /// Get the localized name for a language identifier
fn get_selected_language_name(&mut self) -> String { fn get_selected_language_name(&mut self) -> String {
if let Ok(lang_id) = self.selected_language.parse::<LanguageIdentifier>() { if let Ok(lang_id) = self.settings.locale.parse::<LanguageIdentifier>() {
self.i18n self.note_context
.i18n
.get_locale_native_name(&lang_id) .get_locale_native_name(&lang_id)
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.unwrap_or_else(|| lang_id.to_string()) .unwrap_or_else(|| lang_id.to_string())
} else { } else {
self.selected_language.clone() self.settings.locale.clone()
} }
} }
/// Get the localized label for ShowNoteClientOptions pub fn appearance_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
fn get_show_note_client_label(&mut self, option: ShowNoteClientOptions) -> String {
match option {
ShowNoteClientOptions::Hide => tr!(
self.i18n,
"Hide",
"Option in settings section to hide the source client label in note display"
),
ShowNoteClientOptions::Top => tr!(
self.i18n,
"Top",
"Option in settings section to show the source client label at the top of the note"
),
ShowNoteClientOptions::Bottom => tr!(
self.i18n,
"Bottom",
"Option in settings section to show the source client label at the bottom of the note"
),
}.to_string()
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
let id = ui.id();
let mut action = None; let mut action = None;
let title = tr!(
Frame::default() self.note_context.i18n,
.inner_margin(Margin::symmetric(10, 10))
.show(ui, |ui| {
Frame::group(ui.style())
.fill(ui.style().visuals.widgets.open.bg_fill)
.inner_margin(10.0)
.show(ui, |ui| {
ui.vertical(|ui| {
ui.label(
RichText::new(tr!(
self.i18n,
"Appearance", "Appearance",
"Label for appearance settings section" "Label for appearance settings section",
))
.text_style(NotedeckTextStyle::Body.text_style()),
); );
settings_group(ui, title, |ui| {
ui.horizontal(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Font size:",
"Label for font size, Appearance settings section",
)));
if ui
.add(
egui::Slider::new(&mut self.settings.note_body_font_size, 8.0..=32.0)
.text(""),
)
.changed()
{
action = Some(SettingsAction::SetNoteBodyFontSize(
self.settings.note_body_font_size,
));
};
if ui
.button(richtext_small(tr!(
self.note_context.i18n,
"Reset",
"Label for reset note body font size, Appearance settings section",
)))
.clicked()
{
action = Some(SettingsAction::SetNoteBodyFontSize(
DEFAULT_NOTE_BODY_FONT_SIZE,
));
}
});
let txn = Transaction::new(self.note_context.ndb).unwrap();
if let Some(note_id) = NoteId::from_bech(PREVIEW_NOTE_ID) {
if let Ok(preview_note) =
self.note_context.ndb.get_note_by_id(&txn, note_id.bytes())
{
notedeck_ui::padding(8.0, ui, |ui| {
if is_narrow(ui.ctx()) {
ui.set_max_width(ui.available_width());
}
NoteView::new(
self.note_context,
&preview_note,
*self.note_options,
self.jobs,
)
.actionbar(false)
.options_button(false)
.show(ui);
});
ui.separator(); ui.separator();
ui.spacing_mut().item_spacing = vec2(10.0, 10.0); }
}
let current_zoom = ui.ctx().zoom_factor(); let current_zoom = ui.ctx().zoom_factor();
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(richtext_small(tr!(
RichText::new(tr!( self.note_context.i18n,
self.i18n,
"Zoom Level:", "Zoom Level:",
"Label for zoom level, Appearance settings section" "Label for zoom level, Appearance settings section",
)) )));
.text_style(NotedeckTextStyle::Small.text_style()),
); let min_reached = current_zoom <= MIN_ZOOM;
let max_reached = current_zoom >= MAX_ZOOM;
if ui if ui
.button( .add_enabled(
RichText::new("-") !min_reached,
.text_style(NotedeckTextStyle::Small.text_style()), Button::new(
RichText::new("-").text_style(NotedeckTextStyle::Small.text_style()),
),
) )
.clicked() .clicked()
{ {
let new_zoom = (current_zoom - 0.1).max(0.1); let new_zoom = (current_zoom - ZOOM_STEP).max(MIN_ZOOM);
action = Some(SettingsAction::SetZoom(new_zoom)); action = Some(SettingsAction::SetZoomFactor(new_zoom));
}; };
ui.label( ui.label(
@@ -188,96 +324,89 @@ impl<'a> SettingsView<'a> {
); );
if ui if ui
.button( .add_enabled(
RichText::new("+") !max_reached,
.text_style(NotedeckTextStyle::Small.text_style()), Button::new(
RichText::new("+").text_style(NotedeckTextStyle::Small.text_style()),
),
) )
.clicked() .clicked()
{ {
let new_zoom = (current_zoom + 0.1).min(10.0); let new_zoom = (current_zoom + ZOOM_STEP).min(MAX_ZOOM);
action = Some(SettingsAction::SetZoom(new_zoom)); action = Some(SettingsAction::SetZoomFactor(new_zoom));
}; };
if ui if ui
.button( .button(richtext_small(tr!(
RichText::new(tr!( self.note_context.i18n,
self.i18n,
"Reset", "Reset",
"Label for reset zoom level, Appearance settings section" "Label for reset zoom level, Appearance settings section",
)) )))
.text_style(NotedeckTextStyle::Small.text_style()),
)
.clicked() .clicked()
{ {
action = Some(SettingsAction::SetZoom(1.0)); action = Some(SettingsAction::SetZoomFactor(RESET_ZOOM));
} }
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(richtext_small(tr!(
RichText::new(tr!( self.note_context.i18n,
self.i18n,
"Language:", "Language:",
"Label for language, Appearance settings section" "Label for language, Appearance settings section",
)) )));
.text_style(NotedeckTextStyle::Small.text_style()),
); //
ComboBox::from_label("") ComboBox::from_label("")
.selected_text(self.get_selected_language_name()) .selected_text(self.get_selected_language_name())
.show_ui(ui, |ui| { .show_ui(ui, |ui| {
for lang in self.i18n.get_available_locales() { for lang in self.note_context.i18n.get_available_locales() {
let name = self.i18n let name = self
.note_context
.i18n
.get_locale_native_name(lang) .get_locale_native_name(lang)
.map(|s| s.to_owned()) .map(|s| s.to_owned())
.unwrap_or_else(|| lang.to_string()); .unwrap_or_else(|| lang.to_string());
if ui if ui
.selectable_value( .selectable_value(&mut self.settings.locale, lang.to_string(), name)
self.selected_language,
lang.to_string(),
&name,
)
.clicked() .clicked()
{ {
action = Some(SettingsAction::SetLocale(lang.to_owned())) action = Some(SettingsAction::SetLocale(lang.to_owned()))
} }
} }
}) });
}); });
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label( ui.label(richtext_small(tr!(
RichText::new(tr!( self.note_context.i18n,
self.i18n,
"Theme:", "Theme:",
"Label for theme, Appearance settings section" "Label for theme, Appearance settings section",
)) )));
.text_style(NotedeckTextStyle::Small.text_style()),
);
if ui if ui
.selectable_value( .selectable_value(
self.theme, &mut self.settings.theme,
"Light".into(), ThemePreference::Light,
RichText::new(tr!( richtext_small(tr!(
self.i18n, self.note_context.i18n,
"Light", THEME_LIGHT,
"Label for Theme Light, Appearance settings section" "Label for Theme Light, Appearance settings section",
)) )),
.text_style(NotedeckTextStyle::Small.text_style()),
) )
.clicked() .clicked()
{ {
action = Some(SettingsAction::SetTheme(ThemePreference::Light)); action = Some(SettingsAction::SetTheme(ThemePreference::Light));
} }
if ui if ui
.selectable_value( .selectable_value(
self.theme, &mut self.settings.theme,
"Dark".into(), ThemePreference::Dark,
RichText::new(tr!( richtext_small(tr!(
self.i18n, self.note_context.i18n,
"Dark", THEME_DARK,
"Label for Theme Dark, Appearance settings section" "Label for Theme Dark, Appearance settings section",
)) )),
.text_style(NotedeckTextStyle::Small.text_style()),
) )
.clicked() .clicked()
{ {
@@ -285,49 +414,42 @@ impl<'a> SettingsView<'a> {
} }
}); });
}); });
});
ui.add_space(5.0); action
}
Frame::group(ui.style()) pub fn storage_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
.fill(ui.style().visuals.widgets.open.bg_fill) let id = ui.id();
.inner_margin(10.0) let mut action: Option<SettingsAction> = None;
.show(ui, |ui| { let title = tr!(
ui.label( self.note_context.i18n,
RichText::new(tr!(
self.i18n,
"Storage", "Storage",
"Label for storage settings section" "Label for storage settings section"
))
.text_style(NotedeckTextStyle::Body.text_style()),
); );
ui.separator(); settings_group(ui, title, |ui| {
ui.vertical(|ui| {
ui.spacing_mut().item_spacing = vec2(10.0, 10.0);
ui.horizontal_wrapped(|ui| { ui.horizontal_wrapped(|ui| {
let static_imgs_size = self let static_imgs_size = self
.note_context
.img_cache .img_cache
.static_imgs .static_imgs
.cache_size .cache_size
.lock() .lock()
.unwrap(); .unwrap();
let gifs_size = self.img_cache.gifs.cache_size.lock().unwrap(); let gifs_size = self.note_context.img_cache.gifs.cache_size.lock().unwrap();
ui.label( ui.label(
RichText::new(format!("{} {}", RichText::new(format!(
"{} {}",
tr!( tr!(
self.i18n, self.note_context.i18n,
"Image cache size:", "Image cache size:",
"Label for Image cache size, Storage settings section" "Label for Image cache size, Storage settings section"
), ),
format_size( format_size(
[static_imgs_size, gifs_size] [static_imgs_size, gifs_size]
.iter() .iter()
.fold(0_u64, |acc, cur| acc .fold(0_u64, |acc, cur| acc + cur.unwrap_or_default())
+ cur.unwrap_or_default())
) )
)) ))
.text_style(NotedeckTextStyle::Small.text_style()), .text_style(NotedeckTextStyle::Small.text_style()),
@@ -335,19 +457,24 @@ impl<'a> SettingsView<'a> {
ui.end_row(); ui.end_row();
if !notedeck::ui::is_compiled_as_mobile() && if !notedeck::ui::is_compiled_as_mobile()
ui.button(RichText::new(tr!(self.i18n, "View folder:", "Label for view folder button, Storage settings section")) && ui
.text_style(NotedeckTextStyle::Small.text_style())).clicked() { .button(richtext_small(tr!(
self.note_context.i18n,
"View folder",
"Label for view folder button, Storage settings section",
)))
.clicked()
{
action = Some(SettingsAction::OpenCacheFolder); action = Some(SettingsAction::OpenCacheFolder);
} }
let clearcache_resp = ui.button( let clearcache_resp = ui.button(
RichText::new(tr!( richtext_small(tr!(
self.i18n, self.note_context.i18n,
"Clear cache", "Clear cache",
"Label for clear cache button, Storage settings section" "Label for clear cache button, Storage settings section",
)) ))
.text_style(NotedeckTextStyle::Small.text_style())
.color(Color32::LIGHT_RED), .color(Color32::LIGHT_RED),
); );
@@ -360,7 +487,7 @@ impl<'a> SettingsView<'a> {
let mut confirm_pressed = false; let mut confirm_pressed = false;
clearcache_resp.show_tooltip_ui(|ui| { clearcache_resp.show_tooltip_ui(|ui| {
let confirm_resp = ui.button(tr!( let confirm_resp = ui.button(tr!(
self.i18n, self.note_context.i18n,
"Confirm", "Confirm",
"Label for confirm clear cache, Storage settings section" "Label for confirm clear cache, Storage settings section"
)); ));
@@ -368,97 +495,143 @@ impl<'a> SettingsView<'a> {
confirm_pressed = true; confirm_pressed = true;
} }
if confirm_resp.clicked() || ui.button(tr!( if confirm_resp.clicked()
self.i18n, || ui
.button(tr!(
self.note_context.i18n,
"Cancel", "Cancel",
"Label for cancel clear cache, Storage settings section" "Label for cancel clear cache, Storage settings section"
)).clicked() { ))
.clicked()
{
ui.data_mut(|d| d.insert_temp(id_clearcache, false)); ui.data_mut(|d| d.insert_temp(id_clearcache, false));
} }
}); });
if confirm_pressed { if confirm_pressed {
action = Some(SettingsAction::ClearCacheFolder); action = Some(SettingsAction::ClearCacheFolder);
} else if !confirm_pressed } else if !confirm_pressed && clearcache_resp.clicked_elsewhere() {
&& clearcache_resp.clicked_elsewhere()
{
ui.data_mut(|d| d.insert_temp(id_clearcache, false)); ui.data_mut(|d| d.insert_temp(id_clearcache, false));
} }
}; };
}); });
}); });
});
ui.add_space(5.0); action
}
Frame::group(ui.style()) fn other_options_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
.fill(ui.style().visuals.widgets.open.bg_fill) let mut action = None;
.inner_margin(10.0)
.show(ui, |ui| { let title = tr!(
ui.label( self.note_context.i18n,
RichText::new(tr!(
self.i18n,
"Others", "Others",
"Label for others settings section" "Label for others settings section"
))
.text_style(NotedeckTextStyle::Body.text_style()),
); );
ui.separator(); settings_group(ui, title, |ui| {
ui.vertical(|ui| { ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(10.0, 10.0); ui.label(richtext_small(tr!(
self.note_context.i18n,
ui.horizontal_wrapped(|ui| { "Sort replies newest first",
ui.label( "Label for Sort replies newest first, others settings section",
RichText::new( )));
tr!(
self.i18n,
"Show source client",
"Label for Show source client, others settings section"
))
.text_style(NotedeckTextStyle::Small.text_style()),
);
for option in [
ShowNoteClientOptions::Hide,
ShowNoteClientOptions::Top,
ShowNoteClientOptions::Bottom,
] {
let label = self.get_show_note_client_label(option);
if ui if ui
.selectable_value( .toggle_value(
self.show_note_client, &mut self.settings.show_replies_newest_first,
option, RichText::new(tr!(self.note_context.i18n, "ON", "ON"))
RichText::new(label)
.text_style(NotedeckTextStyle::Small.text_style()), .text_style(NotedeckTextStyle::Small.text_style()),
) )
.changed() .changed()
{ {
action = Some(SettingsAction::SetShowNoteClient(option)); action = Some(SettingsAction::SetRepliestNewestFirst(
} self.settings.show_replies_newest_first,
));
} }
}); });
ui.horizontal_wrapped(|ui| {
ui.label(richtext_small(tr!(
self.note_context.i18n,
"Source client",
"Label for Source client, others settings section",
)));
for option in [
ShowSourceClientOption::Hide,
ShowSourceClientOption::Top,
ShowSourceClientOption::Bottom,
] {
let mut current: ShowSourceClientOption =
self.settings.show_source_client.clone().into();
if ui
.selectable_value(
&mut current,
option,
RichText::new(option.label(self.note_context.i18n))
.text_style(NotedeckTextStyle::Small.text_style()),
)
.changed()
{
action = Some(SettingsAction::SetShowSourceClient(option));
}
}
}); });
}); });
ui.add_space(10.0); action
}
fn manage_relays_section(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
let mut action = None;
if ui if ui
.add_sized( .add_sized(
[ui.available_width(), 30.0], [ui.available_width(), 30.0],
Button::new( Button::new(richtext_small(tr!(
RichText::new(tr!( self.note_context.i18n,
self.i18n,
"Configure relays", "Configure relays",
"Label for configure relays, settings section" "Label for configure relays, settings section",
)) ))),
.text_style(NotedeckTextStyle::Small.text_style()),
),
) )
.clicked() .clicked()
{ {
action = Some(SettingsAction::OpenRelays); action = Some(SettingsAction::OpenRelays);
} }
action
}
pub fn ui(&mut self, ui: &mut egui::Ui) -> Option<SettingsAction> {
let mut action: Option<SettingsAction> = None;
Frame::default()
.inner_margin(Margin::symmetric(10, 10))
.show(ui, |ui| {
ScrollArea::vertical().show(ui, |ui| {
if let Some(new_action) = self.appearance_section(ui) {
action = Some(new_action);
}
ui.add_space(5.0);
if let Some(new_action) = self.storage_section(ui) {
action = Some(new_action);
}
ui.add_space(5.0);
if let Some(new_action) = self.other_options_section(ui) {
action = Some(new_action);
}
ui.add_space(10.0);
if let Some(new_action) = self.manage_relays_section(ui) {
action = Some(new_action);
}
});
}); });
action action
+10 -4
View File
@@ -1,10 +1,10 @@
use crate::support::{Support, SUPPORT_EMAIL};
use egui::{vec2, Button, Label, Layout, RichText}; use egui::{vec2, Button, Label, Layout, RichText};
use notedeck::{tr, Localization, NamedFontFamily, NotedeckTextStyle}; use notedeck::{tr, Localization, NamedFontFamily, NotedeckTextStyle};
use notedeck_ui::{colors::PINK, padding}; use notedeck_ui::{colors::PINK, padding};
use robius_open::Uri;
use tracing::error; use tracing::error;
use crate::support::Support;
pub struct SupportView<'a> { pub struct SupportView<'a> {
support: &'a mut Support, support: &'a mut Support,
i18n: &'a mut Localization, i18n: &'a mut Localization,
@@ -44,15 +44,21 @@ impl<'a> SupportView<'a> {
"Open your default email client to get help from the Damus team", "Open your default email client to get help from the Damus team",
"Instruction to open email client" "Instruction to open email client"
)); ));
ui.horizontal_wrapped(|ui| {
ui.label(tr!(self.i18n, "Support email:", "Support email address",));
ui.label(RichText::new(SUPPORT_EMAIL).color(PINK))
});
let size = vec2(120.0, 40.0); let size = vec2(120.0, 40.0);
ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| { ui.allocate_ui_with_layout(size, Layout::top_down(egui::Align::Center), |ui| {
let font_size = let font_size =
notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body); notedeck::fonts::get_font_size(ui.ctx(), &NotedeckTextStyle::Body);
let button_resp = ui.add(open_email_button(self.i18n, font_size, size)); let button_resp = ui.add(open_email_button(self.i18n, font_size, size));
if button_resp.clicked() { if button_resp.clicked() {
if let Err(e) = open::that(self.support.get_mailto_url()) { if let Err(e) = Uri::new(self.support.get_mailto_url()).open() {
error!( error!(
"Failed to open URL {} because: {}", "Failed to open URL {} because: {:?}",
self.support.get_mailto_url(), self.support.get_mailto_url(),
e e
); );
+15 -3
View File
@@ -2,8 +2,8 @@ use egui::InnerResponse;
use egui_virtual_list::VirtualList; use egui_virtual_list::VirtualList;
use nostrdb::{Note, Transaction}; use nostrdb::{Note, Transaction};
use notedeck::note::root_note_id_from_selected_id; use notedeck::note::root_note_id_from_selected_id;
use notedeck::JobsCache;
use notedeck::{NoteAction, NoteContext}; use notedeck::{NoteAction, NoteContext};
use notedeck_ui::jobs::JobsCache;
use notedeck_ui::note::NoteResponse; use notedeck_ui::note::NoteResponse;
use notedeck_ui::{NoteOptions, NoteView}; use notedeck_ui::{NoteOptions, NoteView};
@@ -115,7 +115,10 @@ impl<'a, 'd> ThreadView<'a, 'd> {
.unwrap() .unwrap()
.list; .list;
let notes = note_builder.into_notes(&mut self.threads.seen_flags); let notes = note_builder.into_notes(
self.note_options.contains(NoteOptions::RepliesNewestFirst),
&mut self.threads.seen_flags,
);
if !full_chain { if !full_chain {
// TODO(kernelkind): insert UI denoting we don't have the full chain yet // TODO(kernelkind): insert UI denoting we don't have the full chain yet
@@ -223,7 +226,11 @@ impl<'a> ThreadNoteBuilder<'a> {
self.replies.push(note); self.replies.push(note);
} }
pub fn into_notes(mut self, seen_flags: &mut NoteSeenFlags) -> ThreadNotes<'a> { pub fn into_notes(
mut self,
replies_newer_first: bool,
seen_flags: &mut NoteSeenFlags,
) -> ThreadNotes<'a> {
let mut notes = Vec::new(); let mut notes = Vec::new();
let selected_is_root = self.chain.is_empty(); let selected_is_root = self.chain.is_empty();
@@ -246,6 +253,11 @@ impl<'a> ThreadNoteBuilder<'a> {
unread_and_have_replies: false, unread_and_have_replies: false,
}); });
if replies_newer_first {
self.replies
.sort_by_key(|b| std::cmp::Reverse(b.created_at()));
}
for reply in self.replies { for reply in self.replies {
notes.push(ThreadNote { notes.push(ThreadNote {
unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false), unread_and_have_replies: *seen_flags.get(reply.id()).unwrap_or(&false),
+1 -1
View File
@@ -3,7 +3,7 @@ use egui::{vec2, Direction, Layout, Pos2, Stroke};
use egui_tabs::TabColor; use egui_tabs::TabColor;
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::ui::is_narrow; use notedeck::ui::is_narrow;
use notedeck_ui::jobs::JobsCache; use notedeck::JobsCache;
use std::f32::consts::PI; use std::f32::consts::PI;
use tracing::{error, warn}; use tracing::{error, warn};
@@ -6,8 +6,12 @@ use crate::deck_state::DeckState;
use crate::login_manager::AcquireKeyState; use crate::login_manager::AcquireKeyState;
use crate::ui::search::SearchQueryState; use crate::ui::search::SearchQueryState;
use enostr::ProfileState; use enostr::ProfileState;
use notedeck_ui::media::MediaViewerState;
/// Various state for views /// Various state for views
///
/// TODO(jb55): we likely want to encapsulate these better,
/// or at least document where they are used
#[derive(Default)] #[derive(Default)]
pub struct ViewState { pub struct ViewState {
pub login: AcquireKeyState, pub login: AcquireKeyState,
@@ -16,6 +20,11 @@ pub struct ViewState {
pub id_string_map: HashMap<egui::Id, String>, pub id_string_map: HashMap<egui::Id, String>,
pub searches: HashMap<egui::Id, SearchQueryState>, pub searches: HashMap<egui::Id, SearchQueryState>,
pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>, pub pubkey_to_profile_state: HashMap<Pubkey, ProfileState>,
/// Keeps track of what urls we are actively viewing in the
/// fullscreen media viewier, as well as any other state we want to
/// keep track of
pub media_viewer: MediaViewerState,
} }
impl ViewState { impl ViewState {
+1 -2
View File
@@ -8,8 +8,7 @@ use egui_wgpu::RenderState;
use enostr::KeypairUnowned; use enostr::KeypairUnowned;
use futures::StreamExt; use futures::StreamExt;
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::{AppAction, AppContext}; use notedeck::{AppAction, AppContext, JobsCache};
use notedeck_ui::jobs::JobsCache;
use std::collections::HashMap; use std::collections::HashMap;
use std::string::ToString; use std::string::ToString;
use std::sync::mpsc::{self, Receiver}; use std::sync::mpsc::{self, Receiver};
+4 -2
View File
@@ -4,8 +4,10 @@ use crate::{
}; };
use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::{tr, Accounts, AppContext, Images, Localization, NoteAction, NoteContext}; use notedeck::{
use notedeck_ui::{app_images, icons::search_icon, jobs::JobsCache, NoteOptions, ProfilePic}; tr, Accounts, AppContext, Images, JobsCache, Localization, NoteAction, NoteContext,
};
use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic};
/// DaveUi holds all of the data it needs to render itself /// DaveUi holds all of the data it needs to render itself
pub struct DaveUi<'a> { pub struct DaveUi<'a> {
+9
View File
@@ -0,0 +1,9 @@
[package]
name = "notedeck_notebook"
edition = "2024"
version.workspace = true
[dependencies]
jsoncanvas = { git = "https://github.com/jb55/jsoncanvas", rev = "ae60f96e4d022cf037e086b793cacc3225bc14e5" }
notedeck = { workspace = true }
egui = { workspace = true }
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
/*
fn debug_slider(
ui: &mut egui::Ui,
id: egui::Id,
point: Pos2,
initial: f32,
range: std::ops::RangeInclusive<f32>,
) -> f32 {
let mut val = ui.data_mut(|d| *d.get_temp_mut_or::<f32>(id, initial));
let nudge = vec2(10.0, 10.0);
let slider = Rect::from_min_max(point - nudge, point + nudge);
let label = Rect::from_min_max(point + nudge * 2.0, point - nudge * 2.0);
let old_val = val;
ui.put(slider, egui::Slider::new(&mut val, range));
ui.put(label, egui::Label::new(format!("{val}")));
if val != old_val {
ui.data_mut(|d| d.insert_temp(id, val))
}
val
}
*/
+60
View File
@@ -0,0 +1,60 @@
use crate::ui::{edge_ui, node_ui};
use egui::{Pos2, Rect};
use jsoncanvas::JsonCanvas;
use notedeck::{AppAction, AppContext};
mod ui;
pub struct Notebook {
canvas: JsonCanvas,
scene_rect: Rect,
loaded: bool,
}
impl Notebook {
pub fn new() -> Self {
Notebook::default()
}
}
impl Default for Notebook {
fn default() -> Self {
Notebook {
canvas: demo_canvas(),
scene_rect: Rect::from_min_max(Pos2::ZERO, Pos2::ZERO),
loaded: false,
}
}
}
impl notedeck::App for Notebook {
fn update(&mut self, _ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
//let app_action: Option<AppAction> = None;
if !self.loaded {
self.scene_rect = ui.available_rect_before_wrap();
self.loaded = true;
}
egui::Scene::new().show(ui, &mut self.scene_rect, |ui| {
// render nodes
for (_node_id, node) in self.canvas.get_nodes().iter() {
let _resp = node_ui(ui, node);
}
// render edges
for (_edge_id, edge) in self.canvas.get_edges().iter() {
let _resp = edge_ui(ui, self.canvas.get_nodes(), edge);
}
});
None
}
}
fn demo_canvas() -> JsonCanvas {
let demo_json: String = include_str!("../demo.canvas").to_string();
let canvas: JsonCanvas = demo_json.parse().unwrap_or_else(|_| JsonCanvas::default());
canvas
}
+184
View File
@@ -0,0 +1,184 @@
use egui::{Align, Label, Pos2, Rect, Shape, Stroke, TextWrapMode, epaint::CubicBezierShape, vec2};
use jsoncanvas::{
FileNode, GroupNode, LinkNode, Node, NodeId, TextNode,
edge::{Edge, Side},
node::GenericNode,
};
use std::collections::HashMap;
use std::ops::Neg;
fn node_rect(node: &GenericNode) -> Rect {
let x = node.x as f32;
let y = node.y as f32;
let width = node.width as f32;
let height = node.height as f32;
let min = Pos2::new(x, y);
let max = Pos2::new(x + width, y + height);
Rect::from_min_max(min, max)
}
fn side_point(side: &Side, node: &GenericNode) -> Pos2 {
let rect = node_rect(node);
match side {
Side::Top => rect.center_top(),
Side::Left => rect.left_center(),
Side::Right => rect.right_center(),
Side::Bottom => rect.center_bottom(),
}
}
/// a unit vector pointing outward from the given side
fn side_tangent(side: &Side) -> egui::Vec2 {
match side {
Side::Top => vec2(0.0, -1.0),
Side::Bottom => vec2(0.0, 1.0),
Side::Left => vec2(-1.0, 0.0),
Side::Right => vec2(1.0, 0.0),
}
}
pub fn edge_ui(
ui: &mut egui::Ui,
nodes: &HashMap<NodeId, Node>,
edge: &Edge,
) -> Option<egui::Response> {
let from_node = nodes.get(edge.from_node())?;
let to_node = nodes.get(edge.to_node())?;
let to_side = edge.to_side()?;
let from_side = edge.from_side()?;
// anchor from-side
let p0 = side_point(from_side, from_node.node());
// anchor b
let to_anchor = side_point(to_side, to_node.node());
// to-point is slightly offset to accomidate arrow
let p3 = to_anchor + side_tangent(to_side) * 2.0;
// bend debug
//let bend = debug_slider(ui, ui.id().with("bend"), p3, 0.25, 0.0..=1.0);
let bend = 0.28;
// How far to pull the tangents.
// ¼ of the distance between anchors feels very “Obsidian”.
let d = (p3 - p0).length() * bend;
// c1 = anchor A + (outward tangent) * d
let c1 = p0 + side_tangent(from_side) * d;
// c2 = anchor B + (inward tangent) * d
let c2 = p3 - side_tangent(to_side).neg() * d;
let color = ui.visuals().noninteractive().bg_stroke.color;
let stroke = egui::Stroke::new(4.0, color);
let bezier = CubicBezierShape::from_points_stroke([p0, c1, c2, p3], false, color, stroke);
ui.painter().add(Shape::CubicBezier(bezier));
arrow_ui(ui, to_side, to_anchor, color);
None
}
/// Paint a tiny triangular “arrow”.
///
/// * `ui` the egui `Ui` youre painting in
/// * `side` which edge of the box were attaching to
/// * `point` the exact spot on that edge the arrows tip should touch
/// * `fill` colour to fill the arrow with (usually your popups background)
pub fn arrow_ui(ui: &mut egui::Ui, side: &Side, point: Pos2, fill: egui::Color32) {
let len: f32 = 12.0; // distance from tip to base
let width: f32 = 16.0; // length of the base
let stroke: f32 = 1.0; // length of the base
let verts = match side {
Side::Top => [
point, // tip
Pos2::new(point.x - width * 0.5, point.y - len), // baseleft (above)
Pos2::new(point.x + width * 0.5, point.y - len), // baseright (above)
],
Side::Bottom => [
point,
Pos2::new(point.x + width * 0.5, point.y + len), // below
Pos2::new(point.x - width * 0.5, point.y + len),
],
Side::Left => [
point,
Pos2::new(point.x - len, point.y + width * 0.5), // left
Pos2::new(point.x - len, point.y - width * 0.5),
],
Side::Right => [
point,
Pos2::new(point.x + len, point.y - width * 0.5), // right
Pos2::new(point.x + len, point.y + width * 0.5),
],
};
ui.painter().add(egui::Shape::convex_polygon(
verts.to_vec(),
fill,
Stroke::new(stroke, fill), // add a stroke here if you want an outline
));
}
pub fn node_ui(ui: &mut egui::Ui, node: &Node) -> egui::Response {
match node {
Node::Text(text_node) => text_node_ui(ui, text_node),
Node::File(file_node) => file_node_ui(ui, file_node),
Node::Link(link_node) => link_node_ui(ui, link_node),
Node::Group(group_node) => group_node_ui(ui, group_node),
}
}
fn text_node_ui(ui: &mut egui::Ui, node: &TextNode) -> egui::Response {
node_box_ui(ui, node.node(), |ui| {
egui::ScrollArea::vertical()
.show(ui, |ui| {
ui.with_layout(egui::Layout::left_to_right(Align::Min), |ui| {
ui.add(Label::new(node.text()).wrap_mode(TextWrapMode::Wrap))
})
})
.inner
.response
})
}
fn file_node_ui(ui: &mut egui::Ui, node: &FileNode) -> egui::Response {
node_box_ui(ui, node.node(), |ui| ui.label("file node"))
}
fn link_node_ui(ui: &mut egui::Ui, node: &LinkNode) -> egui::Response {
node_box_ui(ui, node.node(), |ui| ui.label("link node"))
}
fn group_node_ui(ui: &mut egui::Ui, node: &GroupNode) -> egui::Response {
node_box_ui(ui, node.node(), |ui| ui.label("group node"))
}
fn node_box_ui(
ui: &mut egui::Ui,
node: &GenericNode,
contents: impl FnOnce(&mut egui::Ui) -> egui::Response,
) -> egui::Response {
let pos = node_rect(node);
ui.put(pos, |ui: &mut egui::Ui| {
egui::Frame::default()
.fill(ui.visuals().noninteractive().weak_bg_fill)
.inner_margin(egui::Margin::same(16))
.corner_radius(egui::CornerRadius::same(10))
.stroke(egui::Stroke::new(
2.0,
ui.visuals().noninteractive().bg_stroke.color,
))
.show(ui, |ui| {
let rect = ui.available_rect_before_wrap();
ui.allocate_at_least(ui.available_size(), egui::Sense::click());
ui.put(rect, contents);
})
.response
})
}
-2
View File
@@ -21,5 +21,3 @@ image = { workspace = true }
bitflags = { workspace = true } bitflags = { workspace = true }
enostr = { workspace = true } enostr = { workspace = true }
hashbrown = { workspace = true } hashbrown = { workspace = true }
blurhash = "0.2.3"
-509
View File
@@ -1,510 +1 @@
use egui::{pos2, Color32, ColorImage, Context, Rect, Sense, SizeHint};
use image::codecs::gif::GifDecoder;
use image::imageops::FilterType;
use image::{AnimationDecoder, DynamicImage, FlatSamples, Frame};
use notedeck::{
Animation, GifStateMap, ImageFrame, Images, LoadableTextureState, MediaCache, MediaCacheType,
TextureFrame, TextureState, TexturedImage,
};
use poll_promise::Promise;
use std::collections::VecDeque;
use std::io::Cursor;
use std::path::PathBuf;
use std::path::{self, Path};
use std::sync::mpsc;
use std::sync::mpsc::SyncSender;
use std::thread;
use std::time::Duration;
use tokio::fs;
// NOTE(jb55): chatgpt wrote this because I was too dumb to
pub fn aspect_fill(
ui: &mut egui::Ui,
sense: Sense,
texture_id: egui::TextureId,
aspect_ratio: f32,
) -> egui::Response {
let frame = ui.available_rect_before_wrap(); // Get the available frame space in the current layout
let frame_ratio = frame.width() / frame.height();
let (width, height) = if frame_ratio > aspect_ratio {
// Frame is wider than the content
(frame.width(), frame.width() / aspect_ratio)
} else {
// Frame is taller than the content
(frame.height() * aspect_ratio, frame.height())
};
let content_rect = Rect::from_min_size(
frame.min
+ egui::vec2(
(frame.width() - width) / 2.0,
(frame.height() - height) / 2.0,
),
egui::vec2(width, height),
);
// Set the clipping rectangle to the frame
//let clip_rect = ui.clip_rect(); // Preserve the original clipping rectangle
//ui.set_clip_rect(frame);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
let (response, painter) = ui.allocate_painter(ui.available_size(), sense);
// Draw the texture within the calculated rect, potentially clipping it
painter.rect_filled(content_rect, 0.0, ui.ctx().style().visuals.window_fill());
painter.image(texture_id, content_rect, uv, Color32::WHITE);
// Restore the original clipping rectangle
//ui.set_clip_rect(clip_rect);
response
}
#[profiling::function]
pub fn round_image(image: &mut ColorImage) {
// The radius to the edge of of the avatar circle
let edge_radius = image.size[0] as f32 / 2.0;
let edge_radius_squared = edge_radius * edge_radius;
for (pixnum, pixel) in image.pixels.iter_mut().enumerate() {
// y coordinate
let uy = pixnum / image.size[0];
let y = uy as f32;
let y_offset = edge_radius - y;
// x coordinate
let ux = pixnum % image.size[0];
let x = ux as f32;
let x_offset = edge_radius - x;
// The radius to this pixel (may be inside or outside the circle)
let pixel_radius_squared: f32 = x_offset * x_offset + y_offset * y_offset;
// If inside of the avatar circle
if pixel_radius_squared <= edge_radius_squared {
// squareroot to find how many pixels we are from the edge
let pixel_radius: f32 = pixel_radius_squared.sqrt();
let distance = edge_radius - pixel_radius;
// If we are within 1 pixel of the edge, we should fade, to
// antialias the edge of the circle. 1 pixel from the edge should
// be 100% of the original color, and right on the edge should be
// 0% of the original color.
if distance <= 1.0 {
*pixel = Color32::from_rgba_premultiplied(
(pixel.r() as f32 * distance) as u8,
(pixel.g() as f32 * distance) as u8,
(pixel.b() as f32 * distance) as u8,
(pixel.a() as f32 * distance) as u8,
);
}
} else {
// Outside of the avatar circle
*pixel = Color32::TRANSPARENT;
}
}
}
/// If the image's longest dimension is greater than max_edge, downscale
fn resize_image_if_too_big(
image: image::DynamicImage,
max_edge: u32,
filter: FilterType,
) -> image::DynamicImage {
// if we have no size hint, resize to something reasonable
let w = image.width();
let h = image.height();
let long = w.max(h);
if long > max_edge {
let scale = max_edge as f32 / long as f32;
let new_w = (w as f32 * scale).round() as u32;
let new_h = (h as f32 * scale).round() as u32;
image.resize(new_w, new_h, filter)
} else {
image
}
}
///
/// Process an image, resizing so we don't blow up video memory or even crash
///
/// For profile pictures, make them round and small to fit the size hint
/// For everything else, either:
///
/// - resize to the size hint
/// - keep the size if the longest dimension is less than MAX_IMG_LENGTH
/// - resize if any larger, using [`resize_image_if_too_big`]
///
#[profiling::function]
fn process_image(imgtyp: ImageType, mut image: image::DynamicImage) -> ColorImage {
const MAX_IMG_LENGTH: u32 = 512;
const FILTER_TYPE: FilterType = FilterType::CatmullRom;
match imgtyp {
ImageType::Content(size_hint) => {
let image = match size_hint {
None => resize_image_if_too_big(image, MAX_IMG_LENGTH, FILTER_TYPE),
Some((w, h)) => image.resize(w, h, FILTER_TYPE),
};
let image_buffer = image.into_rgba8();
ColorImage::from_rgba_unmultiplied(
[
image_buffer.width() as usize,
image_buffer.height() as usize,
],
image_buffer.as_flat_samples().as_slice(),
)
}
ImageType::Profile(size) => {
// Crop square
let smaller = image.width().min(image.height());
if image.width() > smaller {
let excess = image.width() - smaller;
image = image.crop_imm(excess / 2, 0, image.width() - excess, image.height());
} else if image.height() > smaller {
let excess = image.height() - smaller;
image = image.crop_imm(0, excess / 2, image.width(), image.height() - excess);
}
let image = image.resize(size, size, FilterType::CatmullRom); // DynamicImage
let image_buffer = image.into_rgba8(); // RgbaImage (ImageBuffer)
let mut color_image = ColorImage::from_rgba_unmultiplied(
[
image_buffer.width() as usize,
image_buffer.height() as usize,
],
image_buffer.as_flat_samples().as_slice(),
);
round_image(&mut color_image);
color_image
}
}
}
#[profiling::function]
fn parse_img_response(
response: ehttp::Response,
imgtyp: ImageType,
) -> Result<ColorImage, notedeck::Error> {
let content_type = response.content_type().unwrap_or_default();
let size_hint = match imgtyp {
ImageType::Profile(size) => SizeHint::Size(size, size),
ImageType::Content(Some((w, h))) => SizeHint::Size(w, h),
ImageType::Content(None) => SizeHint::default(),
};
if content_type.starts_with("image/svg") {
profiling::scope!("load_svg");
let mut color_image =
egui_extras::image::load_svg_bytes_with_size(&response.bytes, Some(size_hint))?;
round_image(&mut color_image);
Ok(color_image)
} else if content_type.starts_with("image/") {
profiling::scope!("load_from_memory");
let dyn_image = image::load_from_memory(&response.bytes)?;
Ok(process_image(imgtyp, dyn_image))
} else {
Err(format!("Expected image, found content-type {content_type:?}").into())
}
}
fn fetch_img_from_disk(
ctx: &egui::Context,
url: &str,
path: &path::Path,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, notedeck::Error>>> {
let ctx = ctx.clone();
let url = url.to_owned();
let path = path.to_owned();
Promise::spawn_async(async move {
Some(async_fetch_img_from_disk(ctx, url, &path, cache_type).await)
})
}
async fn async_fetch_img_from_disk(
ctx: egui::Context,
url: String,
path: &path::Path,
cache_type: MediaCacheType,
) -> Result<TexturedImage, notedeck::Error> {
match cache_type {
MediaCacheType::Image => {
let data = fs::read(path).await?;
let image_buffer = image::load_from_memory(&data).map_err(notedeck::Error::Image)?;
let img = buffer_to_color_image(
image_buffer.as_flat_samples_u8(),
image_buffer.width(),
image_buffer.height(),
);
Ok(TexturedImage::Static(ctx.load_texture(
&url,
img,
Default::default(),
)))
}
MediaCacheType::Gif => {
let gif_bytes = fs::read(path).await?; // Read entire file into a Vec<u8>
generate_gif(ctx, url, path, gif_bytes, false, |i| {
buffer_to_color_image(i.as_flat_samples_u8(), i.width(), i.height())
})
}
}
}
fn generate_gif(
ctx: egui::Context,
url: String,
path: &path::Path,
data: Vec<u8>,
write_to_disk: bool,
process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + Copy + 'static,
) -> Result<TexturedImage, notedeck::Error> {
let decoder = {
let reader = Cursor::new(data.as_slice());
GifDecoder::new(reader)?
};
let (tex_input, tex_output) = mpsc::sync_channel(4);
let (maybe_encoder_input, maybe_encoder_output) = if write_to_disk {
let (inp, out) = mpsc::sync_channel(4);
(Some(inp), Some(out))
} else {
(None, None)
};
let mut frames: VecDeque<Frame> = decoder
.into_frames()
.collect::<std::result::Result<VecDeque<_>, image::ImageError>>()
.map_err(|e| notedeck::Error::Generic(e.to_string()))?;
let first_frame = frames.pop_front().map(|frame| {
generate_animation_frame(
&ctx,
&url,
0,
frame,
maybe_encoder_input.as_ref(),
process_to_egui,
)
});
let cur_url = url.clone();
thread::spawn(move || {
for (index, frame) in frames.into_iter().enumerate() {
let texture_frame = generate_animation_frame(
&ctx,
&cur_url,
index,
frame,
maybe_encoder_input.as_ref(),
process_to_egui,
);
if tex_input.send(texture_frame).is_err() {
tracing::debug!("AnimationTextureFrame mpsc stopped abruptly");
break;
}
}
});
if let Some(encoder_output) = maybe_encoder_output {
let path = path.to_owned();
thread::spawn(move || {
let mut imgs = Vec::new();
while let Ok(img) = encoder_output.recv() {
imgs.push(img);
}
if let Err(e) = MediaCache::write_gif(&path, &url, imgs) {
tracing::error!("Could not write gif to disk: {e}");
}
});
}
first_frame.map_or_else(
|| {
Err(notedeck::Error::Generic(
"first frame not found for gif".to_owned(),
))
},
|first_frame| {
Ok(TexturedImage::Animated(Animation {
other_frames: Default::default(),
receiver: Some(tex_output),
first_frame,
}))
},
)
}
fn generate_animation_frame(
ctx: &egui::Context,
url: &str,
index: usize,
frame: image::Frame,
maybe_encoder_input: Option<&SyncSender<ImageFrame>>,
process_to_egui: impl Fn(DynamicImage) -> ColorImage + Send + 'static,
) -> TextureFrame {
let delay = Duration::from(frame.delay());
let img = DynamicImage::ImageRgba8(frame.into_buffer());
let color_img = process_to_egui(img);
if let Some(sender) = maybe_encoder_input {
if let Err(e) = sender.send(ImageFrame {
delay,
image: color_img.clone(),
}) {
tracing::error!("ImageFrame mpsc unexpectedly closed: {e}");
}
}
TextureFrame {
delay,
texture: ctx.load_texture(format!("{url}{index}"), color_img, Default::default()),
}
}
fn buffer_to_color_image(
samples: Option<FlatSamples<&[u8]>>,
width: u32,
height: u32,
) -> ColorImage {
// TODO(jb55): remove unwrap here
let flat_samples = samples.unwrap();
ColorImage::from_rgba_unmultiplied([width as usize, height as usize], flat_samples.as_slice())
}
pub fn fetch_binary_from_disk(path: PathBuf) -> Result<Vec<u8>, notedeck::Error> {
std::fs::read(path).map_err(|e| notedeck::Error::Generic(e.to_string()))
}
/// Controls type-specific handling
#[derive(Debug, Clone, Copy)]
pub enum ImageType {
/// Profile Image (size)
Profile(u32),
/// Content Image with optional size hint
Content(Option<(u32, u32)>),
}
pub fn fetch_img(
img_cache_path: &Path,
ctx: &egui::Context,
url: &str,
imgtyp: ImageType,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, notedeck::Error>>> {
let key = MediaCache::key(url);
let path = img_cache_path.join(key);
if path.exists() {
fetch_img_from_disk(ctx, url, &path, cache_type)
} else {
fetch_img_from_net(img_cache_path, ctx, url, imgtyp, cache_type)
}
// TODO: fetch image from local cache
}
fn fetch_img_from_net(
cache_path: &path::Path,
ctx: &egui::Context,
url: &str,
imgtyp: ImageType,
cache_type: MediaCacheType,
) -> Promise<Option<Result<TexturedImage, notedeck::Error>>> {
let (sender, promise) = Promise::new();
let request = ehttp::Request::get(url);
let ctx = ctx.clone();
let cloned_url = url.to_owned();
let cache_path = cache_path.to_owned();
ehttp::fetch(request, move |response| {
let handle = response.map_err(notedeck::Error::Generic).and_then(|resp| {
match cache_type {
MediaCacheType::Image => {
let img = parse_img_response(resp, imgtyp);
img.map(|img| {
let texture_handle =
ctx.load_texture(&cloned_url, img.clone(), Default::default());
// write to disk
std::thread::spawn(move || {
MediaCache::write(&cache_path, &cloned_url, img)
});
TexturedImage::Static(texture_handle)
})
}
MediaCacheType::Gif => {
let gif_bytes = resp.bytes;
generate_gif(
ctx.clone(),
cloned_url,
&cache_path,
gif_bytes,
true,
move |img| process_image(imgtyp, img),
)
}
}
});
sender.send(Some(handle)); // send the results back to the UI thread.
ctx.request_repaint();
});
promise
}
pub fn get_render_state<'a>(
ctx: &Context,
images: &'a mut Images,
cache_type: MediaCacheType,
url: &str,
img_type: ImageType,
) -> RenderState<'a> {
let cache = match cache_type {
MediaCacheType::Image => &mut images.static_imgs,
MediaCacheType::Gif => &mut images.gifs,
};
let cur_state = cache.textures_cache.handle_and_get_or_insert(url, || {
crate::images::fetch_img(&cache.cache_dir, ctx, url, img_type, cache_type)
});
RenderState {
texture_state: cur_state,
gifs: &mut images.gif_states,
}
}
pub struct LoadableRenderState<'a> {
pub texture_state: LoadableTextureState<'a>,
pub gifs: &'a mut GifStateMap,
}
pub struct RenderState<'a> {
pub texture_state: TextureState<'a>,
pub gifs: &'a mut GifStateMap,
}
pub fn fetch_no_pfp_promise(
ctx: &Context,
cache: &MediaCache,
) -> Promise<Option<Result<TexturedImage, notedeck::Error>>> {
crate::images::fetch_img(
&cache.cache_dir,
ctx,
notedeck::profile::no_pfp_url(),
ImageType::Profile(128),
MediaCacheType::Image,
)
}
+1 -3
View File
@@ -1,13 +1,11 @@
pub mod anim; pub mod anim;
pub mod app_images; pub mod app_images;
pub mod blur;
pub mod colors; pub mod colors;
pub mod constants; pub mod constants;
pub mod context_menu; pub mod context_menu;
pub mod gif;
pub mod icons; pub mod icons;
pub mod images; pub mod images;
pub mod jobs; pub mod media;
pub mod mention; pub mod mention;
pub mod note; pub mod note;
pub mod profile; pub mod profile;
+3
View File
@@ -0,0 +1,3 @@
mod viewer;
pub use viewer::{MediaViewer, MediaViewerFlags, MediaViewerState};
+232
View File
@@ -0,0 +1,232 @@
/// Spiral layout for media galleries
use egui::{pos2, vec2, Color32, Rect, Sense, TextureId, Vec2};
#[derive(Clone, Copy, Debug)]
pub struct ImageItem {
pub texture: TextureId,
pub ar: f32, // width / height (must be > 0)
}
#[derive(Clone, Debug)]
struct Placed {
texture: TextureId,
rect: Rect,
}
#[derive(Clone, Copy, Debug)]
pub struct LayoutParams {
pub gutter: f32,
pub h_min: f32,
pub h_max: f32,
pub w_min: f32,
pub w_max: f32,
pub seed_center: bool,
}
pub fn layout_spiral(images: &[ImageItem], params: LayoutParams) -> (Vec<Placed>, Vec2) {
if images.is_empty() {
return (Vec::new(), vec2(0.0, 0.0));
}
let eps = f32::EPSILON;
let g = params.gutter.max(0.0);
let h_min = params.h_min.max(1.0);
let h_max = params.h_max.max(h_min);
let w_min = params.w_min.max(1.0);
let w_max = params.w_max.max(w_min);
let mut placed = Vec::with_capacity(images.len());
// Build around origin; normalize at the end.
let mut x_min = 0.0f32;
let mut x_max = 0.0f32;
let mut y_min = 0.0f32;
let mut y_max = 0.0f32;
// dir: 0 right-col, 1 top-row, 2 left-col, 3 bottom-row
let mut dir = 0usize;
let mut i = 0usize;
// Optional seed: center a single image
if params.seed_center && i < images.len() {
let ar = images[i].ar.max(eps);
let h = ((h_min + h_max) * 0.5).clamp(h_min, h_max);
let w = ar * h;
let rect = Rect::from_center_size(pos2(0.0, 0.0), vec2(w, h));
placed.push(Placed { texture: images[i].texture, rect });
x_min = rect.min.x;
x_max = rect.max.x;
y_min = rect.min.y;
y_max = rect.max.y;
i += 1;
dir = 1; // start by adding a row above
} else {
// ensure non-empty bbox for the first strip
x_min = 0.0; x_max = 1.0; y_min = 0.0; y_max = 1.0;
}
// --- helpers -------------------------------------------------------------
// Choose how many items fit and the strip size S (W for column, H for row).
fn choose_k<F: Fn(&ImageItem) -> f32>(
images: &[ImageItem],
L: f32,
g: f32,
s_min: f32,
s_max: f32,
weight: F,
) -> (usize, f32) {
// prefix sums of weights (sum over first k items)
let mut pref = Vec::with_capacity(images.len() + 1);
pref.push(0.0);
for im in images {
pref.push(pref.last().copied().unwrap_or(0.0) + weight(im));
}
let k_max = images.len().max(1);
let mut chosen_k = 1usize;
let mut chosen_s = f32::NAN;
for k in 1..=k_max {
let L_eff = (L - g * (k as f32 - 1.0)).max(1.0);
let sum_w = pref[k].max(f32::EPSILON);
let s = (L_eff / sum_w).max(1.0);
if s > s_max && k < k_max {
continue; // too big; add one more to thin the strip
}
if s < s_min {
// prefer one fewer if possible
if k > 1 {
let k2 = k - 1;
let L_eff2 = (L - g * (k2 as f32 - 1.0)).max(1.0);
let sum_w2 = pref[k2].max(f32::EPSILON);
chosen_k = k2;
chosen_s = (L_eff2 / sum_w2).max(1.0);
} else {
chosen_k = 1;
chosen_s = s_min;
}
return (chosen_k, chosen_s);
}
return (k, s); // within bounds
}
// Fell through: use k_max and clamp
let L_eff = (L - g * (k_max as f32 - 1.0)).max(1.0);
let sum_w = pref[k_max].max(f32::EPSILON);
let s = (L_eff / sum_w).clamp(s_min, s_max);
(k_max, s)
}
// Place a column (top→bottom). Returns the new right/left edge.
fn place_column(
placed: &mut Vec<Placed>,
strip: &[ImageItem],
W: f32,
x: f32,
y_top: f32,
g: f32,
) -> f32 {
let mut y = y_top;
for (idx, im) in strip.iter().enumerate() {
let h = (W / im.ar.max(f32::EPSILON)).max(1.0);
let rect = Rect::from_min_size(pos2(x, y), vec2(W, h));
placed.push(Placed { texture: im.texture, rect });
y += h;
if idx + 1 != strip.len() { y += g; }
}
x + W
}
// Place a row (left→right). Returns the new top/bottom edge.
fn place_row(
placed: &mut Vec<Placed>,
strip: &[ImageItem],
H: f32,
x_left: f32,
y: f32,
g: f32,
) -> f32 {
let mut x = x_left;
for (idx, im) in strip.iter().enumerate() {
let w = (im.ar.max(f32::EPSILON) * H).max(1.0);
let rect = Rect::from_min_size(pos2(x, y), vec2(w, H));
placed.push(Placed { texture: im.texture, rect });
x += w;
if idx + 1 != strip.len() { x += g; }
}
y + H
}
// --- main loop -----------------------------------------------------------
while i < images.len() {
let remaining = &images[i..];
if dir % 2 == 0 {
// COLUMN (dir 0: right, 2: left)
let L = (y_max - y_min).max(1.0);
let (k, W) = choose_k(
remaining,
L, g, w_min, w_max,
|im| 1.0 / im.ar.max(f32::EPSILON),
);
let x = if dir == 0 { x_max + g } else { x_min - g - W };
let new_edge = place_column(&mut placed, &remaining[..k], W, x, y_min, g);
if dir == 0 { x_max = new_edge; } else { x_min = x; }
i += k;
} else {
// ROW (dir 1: top, 3: bottom)
let L = (x_max - x_min).max(1.0);
let (k, H) = choose_k(
remaining,
L, g, h_min, h_max,
|im| im.ar.max(f32::EPSILON),
);
let y = if dir == 1 { y_max + g } else { y_min - g - H };
let new_edge = place_row(&mut placed, &remaining[..k], H, x_min, y, g);
if dir == 1 { y_max = new_edge; } else { y_min = y; }
i += k;
}
dir = (dir + 1) % 4;
}
// Normalize so bbox top-left is (0,0)
let shift = vec2(-x_min, -y_min);
for p in &mut placed {
p.rect = p.rect.translate(shift);
}
let total_size = vec2(x_max - x_min, y_max - y_min);
(placed, total_size)
}
pub fn spiral_gallery(ui: &mut egui::Ui, images: &[ImageItem], params: LayoutParams) {
use egui::{ScrollArea, Stroke};
let (placed, size) = layout_spiral(images, params);
ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
let (rect, _resp) = ui.allocate_exact_size(size, Sense::hover());
let painter = ui.painter_at(rect);
painter.rect_stroke(
Rect::from_min_size(rect.min, size),
0.0,
Stroke::new(1.0, Color32::DARK_GRAY),
);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
for p in &placed {
let r = Rect::from_min_max(rect.min + p.rect.min.to_vec2(),
rect.min + p.rect.max.to_vec2());
painter.image(p.texture, r, uv, Color32::WHITE);
}
});
}
+306
View File
@@ -0,0 +1,306 @@
use bitflags::bitflags;
use egui::{emath::TSTransform, pos2, Color32, Rangef, Rect};
use notedeck::media::{MediaInfo, ViewMediaInfo};
use notedeck::{ImageType, Images};
bitflags! {
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct MediaViewerFlags: u64 {
/// Open the media viewer fullscreen
const Fullscreen = 1 << 0;
/// Enable a transition animation
const Transition = 1 << 1;
/// Are we open or closed?
const Open = 1 << 2;
}
}
/// State used in the MediaViewer ui widget.
pub struct MediaViewerState {
/// When
pub media_info: ViewMediaInfo,
pub scene_rect: Option<Rect>,
pub flags: MediaViewerFlags,
pub anim_id: egui::Id,
}
impl Default for MediaViewerState {
fn default() -> Self {
Self {
anim_id: egui::Id::new("notedeck-fullscreen-media-viewer"),
media_info: Default::default(),
scene_rect: None,
flags: MediaViewerFlags::Transition | MediaViewerFlags::Fullscreen,
}
}
}
impl MediaViewerState {
pub fn new(anim_id: egui::Id) -> Self {
Self {
anim_id,
..Default::default()
}
}
/// How much is our media viewer open
pub fn open_amount(&self, ui: &mut egui::Ui) -> f32 {
ui.ctx().animate_bool_with_time_and_easing(
self.anim_id,
self.flags.contains(MediaViewerFlags::Open),
0.3,
egui::emath::easing::cubic_out,
)
}
/// Should we show the control even if we're closed?
/// Needed for transition animation
pub fn should_show(&self, ui: &mut egui::Ui) -> bool {
if self.flags.contains(MediaViewerFlags::Open) {
return true;
}
// we are closing
self.open_amount(ui) > 0.0
}
}
/// A panning, scrolling, optionally fullscreen, and tiling media viewer
pub struct MediaViewer<'a> {
state: &'a mut MediaViewerState,
}
impl<'a> MediaViewer<'a> {
pub fn new(state: &'a mut MediaViewerState) -> Self {
Self { state }
}
/// Is this
pub fn fullscreen(self, enable: bool) -> Self {
self.state.flags.set(MediaViewerFlags::Fullscreen, enable);
self
}
/// Enable open transition animation
pub fn transition(self, enable: bool) -> Self {
self.state.flags.set(MediaViewerFlags::Transition, enable);
self
}
pub fn ui(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response {
if self.state.flags.contains(MediaViewerFlags::Fullscreen) {
egui::Window::new("Media Viewer")
.title_bar(false)
.fixed_size(ui.ctx().screen_rect().size())
.fixed_pos(ui.ctx().screen_rect().min)
.frame(egui::Frame::NONE)
.show(ui.ctx(), |ui| self.ui_content(images, ui))
.unwrap() // SAFETY: we are always open
.inner
.unwrap()
} else {
self.ui_content(images, ui)
}
}
fn ui_content(&mut self, images: &mut Images, ui: &mut egui::Ui) -> egui::Response {
let avail_rect = ui.available_rect_before_wrap();
let scene_rect = if let Some(scene_rect) = self.state.scene_rect {
scene_rect
} else {
self.state.scene_rect = Some(avail_rect);
avail_rect
};
let zoom_range: egui::Rangef = (0.0..=10.0).into();
let is_open = self.state.flags.contains(MediaViewerFlags::Open);
let can_transition = self.state.flags.contains(MediaViewerFlags::Transition);
let open_amount = self.state.open_amount(ui);
let transitioning = if !can_transition {
false
} else if is_open {
open_amount < 1.0
} else {
open_amount > 0.0
};
let mut trans_rect = if transitioning {
let clicked_img = &self.state.media_info.clicked_media();
let src_pos = &clicked_img.original_position;
let in_scene_pos = Self::first_image_rect(ui, clicked_img, images);
transition_scene_rect(
&avail_rect,
&zoom_range,
&in_scene_pos,
src_pos,
open_amount,
)
} else {
scene_rect
};
// Draw background
ui.painter().rect_filled(
avail_rect,
0.0,
egui::Color32::from_black_alpha((200.0 * open_amount) as u8),
);
let scene = egui::Scene::new().zoom_range(zoom_range);
// We are opening, so lock controls
/* TODO(jb55): 0.32
if transitioning {
scene = scene.sense(egui::Sense::hover());
}
*/
let resp = scene.show(ui, &mut trans_rect, |ui| {
Self::render_image_tiles(&self.state.media_info.medias, images, ui, open_amount);
});
self.state.scene_rect = Some(trans_rect);
resp.response
}
/// The rect of the first image to be placed.
/// This is mainly used for the transition animation
///
/// TODO(jb55): replace this with a "placed" variant once
/// we have image layouts
fn first_image_rect(ui: &mut egui::Ui, media: &MediaInfo, images: &mut Images) -> Rect {
// fetch image texture
let Some(texture) = images.latest_texture(ui, &media.url, ImageType::Content(None)) else {
tracing::error!("could not get latest texture in first_image_rect");
return Rect::ZERO;
};
// the area the next image will be put in.
let mut img_rect = ui.available_rect_before_wrap();
let size = texture.size_vec2();
img_rect.set_height(size.y);
img_rect.set_width(size.x);
img_rect
}
///
/// Tile a scene with images.
///
/// TODO(jb55): Let's improve image tiling over time, spiraling outward. We
/// should have a way to click "next" and have the scene smoothly transition and
/// focus on the next image
fn render_image_tiles(
infos: &[MediaInfo],
images: &mut Images,
ui: &mut egui::Ui,
open_amount: f32,
) {
for info in infos {
let url = &info.url;
// fetch image texture
let Some(texture) = images.latest_texture(ui, url, ImageType::Content(None)) else {
continue;
};
// the area the next image will be put in.
let mut img_rect = ui.available_rect_before_wrap();
/*
if !ui.is_rect_visible(img_rect) {
// just stop rendering images if we're going out of the scene
// basic culling when we have lots of images
break;
}
*/
{
let size = texture.size_vec2();
img_rect.set_height(size.y);
img_rect.set_width(size.x);
let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0));
// image actions
//let response = ui.interact(render_rect, carousel_id.with("img"), Sense::click());
/*
if response.clicked() {
} else if background_response.clicked() {
}
*/
// Paint image
ui.painter().image(
texture.id(),
img_rect,
uv,
Color32::from_white_alpha((open_amount * 255.0) as u8),
);
ui.advance_cursor_after_rect(img_rect);
}
}
}
}
/// Helper: lerp a TSTransform (uniform scale + translation)
fn lerp_ts(a: TSTransform, b: TSTransform, t: f32) -> TSTransform {
let s = egui::lerp(a.scaling..=b.scaling, t);
let p = a.translation + (b.translation - a.translation) * t;
TSTransform {
scaling: s,
translation: p,
}
}
/// Calculate the open/close amount and transition rect
pub fn transition_scene_rect(
outer_rect: &Rect,
zoom_range: &Rangef,
image_rect_in_scene: &Rect, // e.g. Rect::from_min_size(Pos2::ZERO, image_size)
timeline_global_rect: &Rect, // saved from timeline Response.rect
open_amt: f32, // stable ID per media item
) -> Rect {
// Compute the two endpoints:
let from = fit_to_rect_in_scene(timeline_global_rect, image_rect_in_scene, zoom_range);
let to = fit_to_rect_in_scene(outer_rect, image_rect_in_scene, zoom_range);
// Interpolate transform and convert to scene_rect expected by Scene::show:
let lerped = lerp_ts(from, to, open_amt);
lerped.inverse() * (*outer_rect)
}
/// Creates a transformation that fits a given scene rectangle into the available screen size.
///
/// The resulting visual scene bounds can be larger, due to letterboxing.
///
/// Returns the transformation from `scene` to `global` coordinates.
fn fit_to_rect_in_scene(
rect_in_global: &Rect,
rect_in_scene: &Rect,
zoom_range: &Rangef,
) -> TSTransform {
// Compute the scale factor to fit the bounding rectangle into the available screen size:
let scale = rect_in_global.size() / rect_in_scene.size();
// Use the smaller of the two scales to ensure the whole rectangle fits on the screen:
let scale = scale.min_elem();
// Clamp scale to what is allowed
let scale = zoom_range.clamp(scale);
// Compute the translation to center the bounding rect in the screen:
let center_in_global = rect_in_global.center().to_vec2();
let center_scene = rect_in_scene.center().to_vec2();
// Set the transformation to scale and then translate to center.
TSTransform::from_translation(center_in_global - scale * center_scene)
* TSTransform::from_scaling(scale)
}
+4 -2
View File
@@ -2,7 +2,7 @@ use crate::ProfilePreview;
use egui::Sense; use egui::Sense;
use enostr::Pubkey; use enostr::Pubkey;
use nostrdb::{Ndb, Transaction}; use nostrdb::{Ndb, Transaction};
use notedeck::{name::get_display_name, Images, NoteAction}; use notedeck::{name::get_display_name, Images, NoteAction, NotedeckTextStyle};
pub struct Mention<'a> { pub struct Mention<'a> {
ndb: &'a Ndb, ndb: &'a Ndb,
@@ -75,7 +75,9 @@ fn mention_ui(
get_display_name(profile.as_ref()).username_or_displayname() get_display_name(profile.as_ref()).username_or_displayname()
); );
let mut text = egui::RichText::new(name).color(link_color); let mut text = egui::RichText::new(name)
.color(link_color)
.text_style(NotedeckTextStyle::NoteBody.text_style());
if let Some(size) = size { if let Some(size) = size {
text = text.size(size); text = text.size(size);
} }
+48 -26
View File
@@ -1,19 +1,15 @@
use std::cell::OnceCell;
use crate::{ use crate::{
blur::imeta_blurhashes,
jobs::JobsCache,
note::{NoteAction, NoteOptions, NoteResponse, NoteView}, note::{NoteAction, NoteOptions, NoteResponse, NoteView},
secondary_label, secondary_label,
}; };
use notedeck::{JobsCache, RenderableMedia};
use egui::{Color32, Hyperlink, RichText}; use egui::{Color32, Hyperlink, Label, RichText};
use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction}; use nostrdb::{BlockType, Mention, Note, NoteKey, Transaction};
use tracing::warn; use tracing::warn;
use notedeck::{IsFollowing, NoteCache, NoteContext}; use super::media::image_carousel;
use notedeck::{update_imeta_blurhashes, IsFollowing, NoteCache, NoteContext, NotedeckTextStyle};
use super::media::{find_renderable_media, image_carousel, RenderableMedia};
pub struct NoteContents<'a, 'd> { pub struct NoteContents<'a, 'd> {
note_context: &'a mut NoteContext<'d>, note_context: &'a mut NoteContext<'d>,
@@ -127,11 +123,11 @@ pub fn render_note_preview(
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
#[profiling::function] #[profiling::function]
pub fn render_note_contents( pub fn render_note_contents<'a>(
ui: &mut egui::Ui, ui: &mut egui::Ui,
note_context: &mut NoteContext, note_context: &mut NoteContext,
txn: &Transaction, txn: &Transaction,
note: &Note, note: &'a Note,
options: NoteOptions, options: NoteOptions,
jobs: &mut JobsCache, jobs: &mut JobsCache,
) -> NoteResponse { ) -> NoteResponse {
@@ -152,7 +148,6 @@ pub fn render_note_contents(
} }
let mut supported_medias: Vec<RenderableMedia> = vec![]; let mut supported_medias: Vec<RenderableMedia> = vec![];
let blurhashes = OnceCell::new();
let response = ui.horizontal_wrapped(|ui| { let response = ui.horizontal_wrapped(|ui| {
let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) { let blocks = if let Ok(blocks) = note_context.ndb.get_blocks_by_key(txn, note_key) {
@@ -163,9 +158,7 @@ pub fn render_note_contents(
return; return;
}; };
ui.spacing_mut().item_spacing.x = 0.0; 'block_loop: for block in blocks.iter(note) {
for block in blocks.iter(note) {
match block.blocktype() { match block.blocktype() {
BlockType::MentionBech32 => match block.as_mention().unwrap() { BlockType::MentionBech32 => match block.as_mention().unwrap() {
Mention::Profile(profile) => { Mention::Profile(profile) => {
@@ -205,13 +198,24 @@ pub fn render_note_contents(
} }
_ => { _ => {
ui.colored_label(link_color, format!("@{}", &block.as_str()[..16])); ui.colored_label(
link_color,
RichText::new(format!("@{}", &block.as_str()[..16]))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
);
} }
}, },
BlockType::Hashtag => { BlockType::Hashtag => {
if block.as_str().trim().is_empty() {
continue 'block_loop;
}
let resp = ui let resp = ui
.colored_label(link_color, format!("#{}", block.as_str())) .colored_label(
link_color,
RichText::new(format!("#{}", block.as_str()))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand); .on_hover_cursor(egui::CursorIcon::PointingHand);
if resp.clicked() { if resp.clicked() {
@@ -223,21 +227,26 @@ pub fn render_note_contents(
let mut found_supported = || -> bool { let mut found_supported = || -> bool {
let url = block.as_str(); let url = block.as_str();
let blurs = blurhashes.get_or_init(|| imeta_blurhashes(note)); if !note_context.img_cache.metadata.contains_key(url) {
update_imeta_blurhashes(note, &mut note_context.img_cache.metadata);
}
let Some(media_type) = let Some(media) = note_context.img_cache.get_renderable_media(url) else {
find_renderable_media(&mut note_context.img_cache.urls, blurs, url)
else {
return false; return false;
}; };
supported_medias.push(media_type); supported_medias.push(media);
true true
}; };
if hide_media || !found_supported() { if hide_media || !found_supported() {
if block.as_str().trim().is_empty() {
continue 'block_loop;
}
ui.add(Hyperlink::from_label_and_url( ui.add(Hyperlink::from_label_and_url(
RichText::new(block.as_str()).color(link_color), RichText::new(block.as_str())
.color(link_color)
.text_style(NotedeckTextStyle::NoteBody.text_style()),
block.as_str(), block.as_str(),
)); ));
} }
@@ -263,17 +272,28 @@ pub fn render_note_contents(
current_len += block_str.len(); current_len += block_str.len();
block_str block_str
}; };
if block_str.trim().is_empty() {
continue 'block_loop;
}
if options.contains(NoteOptions::ScrambleText) { if options.contains(NoteOptions::ScrambleText) {
ui.add( ui.add(
egui::Label::new(rot13(block_str)) Label::new(
RichText::new(rot13(block_str))
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.wrap() .wrap()
.selectable(selectable), .selectable(selectable),
); );
} else { } else {
ui.add(egui::Label::new(block_str).wrap().selectable(selectable)); ui.add(
Label::new(
RichText::new(block_str)
.text_style(NotedeckTextStyle::NoteBody.text_style()),
)
.wrap()
.selectable(selectable),
);
} }
// don't render any more blocks // don't render any more blocks
if truncate { if truncate {
break; break;
@@ -311,6 +331,7 @@ pub fn render_note_contents(
.key .key
.pubkey .pubkey
.bytes(); .bytes();
let trusted_media = is_self let trusted_media = is_self
|| note_context || note_context
.accounts .accounts
@@ -327,6 +348,7 @@ pub fn render_note_contents(
carousel_id, carousel_id,
trusted_media, trusted_media,
note_context.i18n, note_context.i18n,
options,
); );
ui.add_space(2.0); ui.add_space(2.0);
} }
File diff suppressed because it is too large Load Diff
+23 -10
View File
@@ -4,7 +4,6 @@ pub mod media;
pub mod options; pub mod options;
pub mod reply_description; pub mod reply_description;
use crate::jobs::JobsCache;
use crate::{app_images, secondary_label}; use crate::{app_images, secondary_label};
use crate::{ use crate::{
profile::name::one_line_display_name_widget, widgets::x_button, ProfilePic, ProfilePreview, profile::name::one_line_display_name_widget, widgets::x_button, ProfilePic, ProfilePreview,
@@ -14,13 +13,14 @@ use crate::{
pub use contents::{render_note_contents, render_note_preview, NoteContents}; pub use contents::{render_note_contents, render_note_preview, NoteContents};
pub use context::NoteContextButton; pub use context::NoteContextButton;
use notedeck::get_current_wallet; use notedeck::get_current_wallet;
use notedeck::note::MediaAction;
use notedeck::note::ZapTargetAmount; use notedeck::note::ZapTargetAmount;
use notedeck::ui::is_narrow; use notedeck::ui::is_narrow;
use notedeck::Accounts; use notedeck::Accounts;
use notedeck::GlobalWallet; use notedeck::GlobalWallet;
use notedeck::Images; use notedeck::Images;
use notedeck::JobsCache;
use notedeck::Localization; use notedeck::Localization;
use notedeck::MediaAction;
pub use options::NoteOptions; pub use options::NoteOptions;
pub use reply_description::reply_desc; pub use reply_description::reply_desc;
@@ -344,7 +344,12 @@ impl<'a, 'd> NoteView<'a, 'd> {
1.0, 1.0,
ui.visuals().noninteractive().bg_stroke.color, ui.visuals().noninteractive().bg_stroke.color,
)) ))
.show(ui, |ui| self.show_impl(ui)) .show(ui, |ui| {
if is_narrow(ui.ctx()) {
ui.set_width(ui.available_width());
}
self.show_impl(ui)
})
.inner .inner
} else { } else {
self.show_impl(ui) self.show_impl(ui)
@@ -454,7 +459,14 @@ impl<'a, 'd> NoteView<'a, 'd> {
note_action = contents.action.or(note_action); note_action = contents.action.or(note_action);
if self.options().contains(NoteOptions::ActionBar) { if self.options().contains(NoteOptions::ActionBar) {
note_action = render_note_actionbar( note_action = ui
.horizontal_wrapped(|ui| {
// NOTE(jb55): without this we get a weird artifact where
// there subsequent lines start sinking leftward off the screen.
// question: WTF? question 2: WHY?
ui.allocate_space(egui::vec2(0.0, 0.0));
render_note_actionbar(
ui, ui,
get_zapper( get_zapper(
self.note_context.accounts, self.note_context.accounts,
@@ -466,6 +478,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
note_key, note_key,
self.note_context.i18n, self.note_context.i18n,
) )
})
.inner .inner
.or(note_action); .or(note_action);
} }
@@ -531,7 +544,9 @@ impl<'a, 'd> NoteView<'a, 'd> {
note_action = contents.action.or(note_action); note_action = contents.action.or(note_action);
if self.options().contains(NoteOptions::ActionBar) { if self.options().contains(NoteOptions::ActionBar) {
note_action = render_note_actionbar( note_action = ui
.horizontal_wrapped(|ui| {
render_note_actionbar(
ui, ui,
get_zapper( get_zapper(
self.note_context.accounts, self.note_context.accounts,
@@ -543,6 +558,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
note_key, note_key,
self.note_context.i18n, self.note_context.i18n,
) )
})
.inner .inner
.or(note_action); .or(note_action);
} }
@@ -781,8 +797,7 @@ fn render_note_actionbar(
note_pubkey: &[u8; 32], note_pubkey: &[u8; 32],
note_key: NoteKey, note_key: NoteKey,
i18n: &mut Localization, i18n: &mut Localization,
) -> egui::InnerResponse<Option<NoteAction>> { ) -> Option<NoteAction> {
ui.horizontal(|ui| {
ui.set_min_height(26.0); ui.set_min_height(26.0);
ui.spacing_mut().item_spacing.x = 24.0; ui.spacing_mut().item_spacing.x = 24.0;
@@ -825,8 +840,7 @@ fn render_note_actionbar(
match zap_state { match zap_state {
Ok(any_zap_state) => ui.add(zap_button(i18n, any_zap_state, note_id)), Ok(any_zap_state) => ui.add(zap_button(i18n, any_zap_state, note_id)),
Err(err) => { Err(err) => {
let (rect, _) = let (rect, _) = ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
ui.allocate_at_least(egui::vec2(10.0, 10.0), egui::Sense::click());
ui.add(x_button(rect)).on_hover_text(err.to_string()) ui.add(x_button(rect)).on_hover_text(err.to_string())
} }
} }
@@ -845,7 +859,6 @@ fn render_note_actionbar(
target, target,
specified_msats: None, specified_msats: None,
}))) })))
})
} }
#[profiling::function] #[profiling::function]
+2
View File
@@ -25,6 +25,8 @@ bitflags! {
/// Show note's client in the note header /// Show note's client in the note header
const ShowNoteClientTop = 1 << 12; const ShowNoteClientTop = 1 << 12;
const ShowNoteClientBottom = 1 << 13; const ShowNoteClientBottom = 1 << 13;
const RepliesNewestFirst = 1 << 14;
} }
} }
@@ -2,8 +2,8 @@ use egui::{Label, RichText, Sense};
use nostrdb::{NoteReply, Transaction}; use nostrdb::{NoteReply, Transaction};
use super::NoteOptions; use super::NoteOptions;
use crate::{jobs::JobsCache, note::NoteView, Mention}; use crate::{note::NoteView, Mention};
use notedeck::{tr, NoteAction, NoteContext}; use notedeck::{tr, JobsCache, NoteAction, NoteContext};
// Rich text segment types for internationalized rendering // Rich text segment types for internationalized rendering
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
+1 -1
View File
@@ -113,7 +113,7 @@ pub fn banner(ui: &mut egui::Ui, banner_url: Option<&str>, height: f32) -> egui:
banner_url banner_url
.and_then(|url| banner_texture(ui, url)) .and_then(|url| banner_texture(ui, url))
.map(|texture| { .map(|texture| {
crate::images::aspect_fill( notedeck::media::images::aspect_fill(
ui, ui,
egui::Sense::hover(), egui::Sense::hover(),
texture.id, texture.id,
+6 -8
View File
@@ -1,8 +1,9 @@
use crate::gif::{handle_repaint, retrieve_latest_texture};
use crate::images::{fetch_no_pfp_promise, get_render_state, ImageType};
use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle}; use egui::{vec2, InnerResponse, Sense, Stroke, TextureHandle};
use notedeck::note::MediaAction; use notedeck::get_render_state;
use notedeck::media::gif::ensure_latest_texture;
use notedeck::media::images::{fetch_no_pfp_promise, ImageType};
use notedeck::MediaAction;
use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images}; use notedeck::{show_one_error_message, supported_mime_hosted_at_url, Images};
pub struct ProfilePic<'cache, 'url> { pub struct ProfilePic<'cache, 'url> {
@@ -140,12 +141,9 @@ fn render_pfp(
) )
} }
notedeck::TextureState::Loaded(textured_image) => { notedeck::TextureState::Loaded(textured_image) => {
let texture_handle = handle_repaint( let texture_handle = ensure_latest_texture(ui, url, cur_state.gifs, textured_image);
ui,
retrieve_latest_texture(url, cur_state.gifs, textured_image),
);
egui::InnerResponse::new(None, pfp_image(ui, texture_handle, ui_size, border, sense)) egui::InnerResponse::new(None, pfp_image(ui, &texture_handle, ui_size, border, sense))
} }
} }
} }