diff --git a/MANUAL.md b/MANUAL.md index 21dc280..7d1f207 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -139,8 +139,8 @@ You can switch between **vertical stack (`TTT`, `bstack`)**, **horizontal tile ( - `Mod4+1`..`4`: switch to workspace tag `1`..`4` - `Mod4+Shift+q`: close focused window -#### 3.1.2 Preloaded DWM plugins (`XDrawer`, `Dialer`, `Call Log`, `Contacts`) -All four core phone overlays are compiled as shared-object plugins (`/usr/lib/dwm/plugins/*.so`), preloaded at `dwm` startup (`dwm-plugin --preload`), and awakened over `/tmp/dwm_plugin_.fifo` in `< 10 ms`: +#### 3.1.2 Preloaded DWM plugins (`XDrawer`, `Dialer`, `Call Log`, `Contacts`, `SMS`, `Files`, `Camera`) +All seven core phone overlays are compiled as shared-object plugins (`/usr/lib/dwm/plugins/*.so`), preloaded at `dwm` startup (`dwm-plugin --preload`), and awakened over `/tmp/dwm_plugin_.fifo` in `< 10 ms`: 1. **`XDrawer` (`xdrawer.so` / `xdrawer`) — Global Phone Controls**: - **Open / Close**: Press **both softkeys simultaneously** (`KEY_MENU` + `KEY_BACK`) from anywhere, or select `XDrawer` from the Sxmo menu. @@ -161,6 +161,22 @@ All four core phone overlays are compiled as shared-object plugins (`/usr/lib/dw - **Contact Details View**: Provides one-click actions for **Call Contact**, **Send SMS**, **Read Messages**, **Open in Dialer**, **Edit Contact**, and **Delete Contact**. - **Built-in Contact Editor**: Supports adding new contacts or editing existing contacts directly in the `240x320` UI. In the **Name** field, keypad keys `0`–`9` provide multi-tap character entry (`#` cycles case mode `[Abc]` -> `[ABC]` -> `[abc]` -> `[123]`, `*` inserts space). In the **Number** field, hold `0` or press `Right` to insert `+`, or select **Use Recent Number** to pick from recent unknown callers in `modemlog.tsv`. Press **Left softkey (`Save`)** or select **Save Contact** to write directly to `~/.config/sxmo/contacts.tsv`. +5. **`SMS Manager / Editor` (`sms.so` / `xsms` / `sxmo_modemtext.sh`) — Conversations & Composer**: + - **Open**: Select **Texts** from the Sxmo system menu (`Left softkey`), or choose **Send SMS** / **Read Messages** from Contacts. + - **Conversations List**: Shows all SMS threads and drafts from `~/.local/share/sxmo/modem//sms.txt` with contact name resolution, message previews, and timestamps. Press `0`–`9` for live T9/number search, `*` for a new message, **Center `OK` (`Return`)** to open the conversation, **Call (green)** to call the sender, or **Left softkey (`Options`)** for **New Message**, **Open Thread**, **Reply / Compose**, **Call Number**, **Save/View Contact**, and **Delete Thread**. + - **Thread View**: Displays incoming (`Recv`, green left accent) and outgoing (`Sent`, blue/gold right accent) message bubbles; `Up`/`Down` scrolls; **Center `OK` (`Return`)** opens the reply composer; **Call (green)** calls the contact. + - **Multi-Tap SMS Composer**: Supports picking a recipient from Contacts/Recents or entering a number directly (hold `0` for `+`), multi-tap text entry on keys `0`–`9` and `*` (`#` cycles `[Abc]` -> `[ABC]` -> `[abc]` -> `[123]`), live character and SMS segment counter (`0/160`), automatic draft persistence (`draft.txt`), and asynchronous sending via `sxmo_modemsendsms.sh` (`Call` key, `Send SMS` button, or `Left softkey`). + +6. **`File Manager` (`files.so` / `xfiles` / `sxmo_files.sh`) — Graphical Directory Browser & Viewer**: + - **Open**: Select **Files** from the Sxmo system menu (`Left softkey`) or press `Mod4+d`. + - **Browser Controls**: `Up`/`Down` navigates files and directories; `Return`/`Right` enters a directory or opens the selected file; `Left`/`BackSpace` ascends to the parent directory (`..`); `0`–`9` filters items in real time; `*` toggles hidden dotfiles (`.*`); `#` cycles sort order (**Name** -> **Date** -> **Size**). + - **Built-in Viewers & File Actions (`Left softkey`)**: Text files and scripts open in a fast built-in scrollable viewer (with `Left softkey` shortcut to edit in `st -e nano`); JPEG and PNG images open in a built-in `libjpeg`/`libpng` previewer (`Left`/`Right` steps through images in the folder, `Left softkey` opens full screen in `feh`). The **Actions** menu supports **Open**, **Edit in Nano**, **New Folder**, **New File**, **Rename**, **Copy**, **Cut / Move**, **Paste Here**, **Properties**, **Toggle Hidden (`*`)**, **Cycle Sort (`#`)**, **Open Terminal Here**, and **Delete**. + +7. **`Camera` (`camera.so` / `xcamera`) — Live Viewfinder & Photo Gallery**: + - **Open**: Select **Camera** from the Sxmo system menu (`Left softkey`) or run `xcamera` / `bananarchy-photo --menu`. + - **Live Viewfinder**: Streams `800x600` `RGB24` frames directly from `/dev/video0` (`msm8905-cam` / `GC2395`) into a `240x180` 4:3 viewfinder with a center reticle. Press **Center `OK` (`Return`)**, **`5`**, or **Call (green)** to snap a JPEG photo to `~/Pictures/photo-YYYYMMDD-HHMMSS.jpg`; `*` or `1` toggles **Auto-Flash**; `0` or `2` toggles continuous **Torch** (`white:torch`); `Up`/`Down` adjusts **Digital Zoom** (`1.0x` / `2.0x` / `4.0x`); `#` or `3` toggles the **3-second Self-Timer**; **Left softkey (`Gallery`)** opens the built-in photo gallery. + - **Built-in Photo Gallery**: Browse captured photos with `Left`/`Right` or `Up`/`Down`, press `Return` to open full-screen in `feh`, or press **Left softkey (`Delete`)** to delete a photo. + ### 3.2 Multi-mode `fcitx5-t9` keypad reference - **Short-press `#`** *(when no character is in preedit)*: cycles through the 6 input modes (`abc` -> `Abc` -> `ABC` -> `Sym` -> `Ctl` -> `123` -> `abc`), displaying a `1500ms` preedit status banner (`[Mode: abc]`, `[Mode: Abc]`, `[Mode: ABC]`, `[Mode: Sym]`, `[Mode: Ctl]`, `[Mode: 123]`). @@ -254,15 +270,17 @@ All four core phone overlays are compiled as shared-object plugins (`/usr/lib/dw ## 5. Camera, audio, flashlight & cellular modem reference -### 5.1 Taking photos with the rear camera (`bananarchy-photo` & `/dev/video0`) +### 5.1 Taking photos with the rear camera (`xcamera`, `bananarchy-photo` & `/dev/video0`) The `msm8905-cam` kernel module drives the rear 2MP GalaxyCore `GC2395` sensor through MSM8909 `CAMSS` (`CSIPHY0 -> CSID0 -> ISPIF -> VFE0 -> VBIF`) and registers `/dev/video0` (`800x600` 24-bit `RGB3`). -- **From the Sxmo keypad UI**: - Open the **System menu** (`Left softkey`) or **Apps menu** (`Left softkey` -> `Apps`) and select **Camera** (`bananarchy-photo --menu`). From the Camera menu you can: - - **Take photo**: capture a photo to `~/Pictures/photo-YYYYMMDD-HHMMSS.jpg` and immediately open it in `imv`. - - **Take photo (flash)**: turn on the rear flashlight (`white:torch`), capture a photo, restore the flashlight state, and open the photo in `imv`. - - **View photos**: browse all saved photos in `~/Pictures` using `imv` (press `Right softkey` inside `imv` to navigate, zoom, rotate, or quit). +- **From the Sxmo keypad UI (`xcamera` / `camera.so`)**: + Open the **System menu** (`Left softkey`) and select **Camera** (`xcamera`). The native DWM camera plugin streams a live `240x180` viewfinder from `/dev/video0` with: + - **Shutter (`Return` / `5` / `Call`)**: encodes an `800x600` JPEG directly to `~/Pictures/photo-YYYYMMDD-HHMMSS.jpg`. + - **Auto-Flash (`*` / `1`) & Continuous Torch (`0` / `2`)**: controls `white:torch` during or before capture. + - **Digital Zoom (`Up` / `Down`)**: cycles `1.0x`, `2.0x`, and `4.0x` center-crop zoom. + - **Self-Timer (`#` / `3`)**: toggles a 3-second countdown before capture. + - **Built-in Gallery (`Left softkey`)**: browse and delete saved photos in `~/Pictures` or press `Return` to open full-screen in `feh`. - **From the CLI (`bananarchy-photo`)**: ```bash # Capture to ~/Pictures/photo-YYYYMMDD-HHMMSS.jpg and print path diff --git a/README.md b/README.md index a4ff3ac..7d865af 100644 --- a/README.md +++ b/README.md @@ -10,11 +10,14 @@ Bananarchy targets an ultra-lightweight **Sxmo X11 (`sxmo-de-dwm`)** environment - **Cross-compiled Xlibre (`X11Libre/xserver` 25.2.0) display server**: `modesetting` KMS + `ShadowFB` Damage partial-rectangle SPI transfers, software cursor fallback, and rootless `elogind` integration. - **Patched `sxmo-dwm` with native C/C++ plugin architecture**: `240x320` vertical `bstack` layout, 24px Pango status bar with an overflow-protected right-aligned clock, transparent-backgrounded `"Menu"` and `"Names"` desktop softkey labels, direct C `SW_LID` slider and locked-key wake handling in `< 2 ms`, and a dynamic `.so` plugin API (`dwm_plugin.h`) with preloaded resident daemons (`--preload`) and non-blocking FIFO IPC (`< 10 ms` wakeup latency). -- **Preloaded `240x320` keypad-driven DWM plugins (`xdrawer`, `dialer`, `calllog`, `contacts`)**: +- **Preloaded `240x320` keypad-driven DWM plugins (`xdrawer`, `dialer`, `calllog`, `contacts`, `sms`, `files`, `camera`)**: - **`XDrawer` (`xdrawer.so` / `xdrawer`)**: global phone controls overlay invoked from anywhere by pressing **both softkeys simultaneously**, providing instant sliders and toggles for speaker volume, earpiece volume, flashlight, sound mute, microphone mute, screen brightness, and keypad backlight. - **`Dialer` (`dialer.so` / `xdialer`)**: opens automatically from the idle desktop when pressing any **`0`–`9`, `*`, or `#` key** (inserting that character immediately), with live T9/number contact filtering, hold-`0` for `+`, and single-press **Call** dialing. - **`Call Log` (`calllog.so` / `xcalllog`)**: opens automatically from the idle desktop when pressing the **Call (green)** button, with `All` / `Missed` / `In` / `Out` tabs, contact resolution, call durations, and instant redial. - **`Contacts` (`contacts.so` / `xcontacts`)**: full-featured phonebook application opened from the idle desktop via the **Right softkey (`Names`)** or from the Sxmo menu, integrated with `~/.config/sxmo/contacts.tsv` (live T9/number search, call/SMS/dialer actions, built-in contact editor with keypad multi-tap name entry and recent-caller number picker, and contact deletion). + - **`SMS Manager` (`sms.so` / `xsms`)**: conversation thread manager, bubble message history reader, and multi-tap keypad SMS composer/draft editor integrated with `$SXMO_LOGDIR//sms.txt`, `draft.txt`, `modemlog.tsv`, and `sxmo_modemsendsms.sh`. + - **`File Manager` (`files.so` / `xfiles`)**: graphical directory browser with live T9/number filtering, dotfiles toggle (`*`), sort mode cycle (`#`), file operations (new folder/file, rename, copy, cut/move, paste, delete, properties, edit in `nano`, open terminal here), built-in text viewer, and built-in `libjpeg`/`libpng` image previewer. + - **`Camera` (`camera.so` / `xcamera`)**: live V4L2 `/dev/video0` (`800x600` `RGB24` -> `240x180` 4:3) viewfinder with auto-flash (`*`/`1`), continuous torch (`0`/`2`), digital zoom (`1x`/`2x`/`4x` via `Up`/`Down`), 3-second self-timer (`#`/`3`), direct `libjpeg` photo capture to `~/Pictures/photo-YYYYMMDD-HHMMSS.jpg`, and built-in photo gallery. - **Patched `sxmo-dmenu`**: `240x320` full-screen 10-line vertical menu (`240x296` flush below the 24px top bar) with a `#383838` search bar, `#005577` prompt badge, and `XIM` `fcitx5-t9` text input in `< 15 ms`. - **Patched `sxmo-st` terminal (`pixelsize=18`)**: **HarfBuzz (`libharfbuzz`) OpenType ligature shaping** (`liga`, `calt`, `clig`, `dlig`) and `XIM` support. - **Typography**: non-Nerd **`Fira Code` (`font-fira-code`) with ligatures** as the primary system and terminal font (with `DejaVu SansM Nerd Font` used strictly as an icon fallback). @@ -90,7 +93,7 @@ bananarchy/ │ └── 0001-bananarchy-multimode-t9-and-fcitx5-5.1.19.patch ├── src/ │ ├── sxmo-dwm/ # Patched sxmo-dwm (240x320 bstack, Pango bar, desktop softkey bar & plugin API) -│ ├── dwm-plugins/ # DWM plugins (dialer.so, calllog.so, contacts.so), shared phone/TSV lib & runner +│ ├── dwm-plugins/ # DWM plugins (dialer, calllog, contacts, sms, files, camera), shared phone/media lib & runner │ ├── xdrawer/ # XDrawer global phone controls DWM plugin (xdrawer.so / xdrawer) │ ├── sxmo-dmenu/ # Patched sxmo-dmenu (240x320 full-screen 10-line menu, #383838 search bar, XIM fcitx5-t9) │ ├── sxmo-st/ # Patched sxmo-st terminal with HarfBuzz Fira Code ligature shaping & XIM diff --git a/overlay/rootfs/usr/bin/bananarchy-photo b/overlay/rootfs/usr/bin/bananarchy-photo index c254747..2c4f431 100755 --- a/overlay/rootfs/usr/bin/bananarchy-photo +++ b/overlay/rootfs/usr/bin/bananarchy-photo @@ -189,6 +189,9 @@ run_menu() { } if [ "$MENU_MODE" -eq 1 ]; then + if command -v xcamera >/dev/null 2>&1; then + exec xcamera + fi run_menu else take_photo "$USE_FLASH" "$OUT_FILE" diff --git a/overlay/rootfs/usr/bin/dwm b/overlay/rootfs/usr/bin/dwm index 2921d1e..9ff0292 100755 Binary files a/overlay/rootfs/usr/bin/dwm and b/overlay/rootfs/usr/bin/dwm differ diff --git a/overlay/rootfs/usr/bin/dwm-plugin b/overlay/rootfs/usr/bin/dwm-plugin index 6522237..a315043 100755 Binary files a/overlay/rootfs/usr/bin/dwm-plugin and b/overlay/rootfs/usr/bin/dwm-plugin differ diff --git a/overlay/rootfs/usr/bin/sxmo_appmenu.sh b/overlay/rootfs/usr/bin/sxmo_appmenu.sh index 7f425e8..3948fa6 100755 --- a/overlay/rootfs/usr/bin/sxmo_appmenu.sh +++ b/overlay/rootfs/usr/bin/sxmo_appmenu.sh @@ -153,12 +153,12 @@ fast_sys_choices() { CHOICES="$icon_trm Terminal^0^st -e ${SHELL:-/bin/sh} $icon_grd Apps^0^sxmo_appmenu.sh applications $icon_grd Scripts^0^sxmo_appmenu.sh scripts -$icon_dir Files^0^sxmo_files.sh +$icon_dir Files^0^xfiles $icon_phn Dialer^0^xdialer $icon_phl Call Log^0^xcalllog -$icon_msg Texts^0^sxmo_modemtext.sh +$icon_msg Texts^0^xsms $icon_usr Contacts^0^xcontacts -${_bt_line}$icon_cam Camera^0^bananarchy-photo --menu +${_bt_line}$icon_cam Camera^0^xcamera ${_flash_line}$icon_net Networks^0^sxmo_networks.sh $icon_mus Audio^0^sxmo_audio.sh $icon_phn Modem^0^sxmo_appmenu.sh modem diff --git a/overlay/rootfs/usr/bin/sxmo_files.sh b/overlay/rootfs/usr/bin/sxmo_files.sh new file mode 100755 index 0000000..6f4a3f7 --- /dev/null +++ b/overlay/rootfs/usr/bin/sxmo_files.sh @@ -0,0 +1,127 @@ +#!/bin/sh +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2022 Sxmo Contributors +# title="$icon_dir Files" + +case " $* " in + *" --select-only "*) + ;; + *) + if command -v xfiles >/dev/null 2>&1; then + if [ $# -gt 0 ] && [ -d "$1" ]; then + exec xfiles "$1" + fi + exec xfiles + fi + ;; +esac + +. sxmo_common.sh + +set -e + +usage() { + printf "%s [--date-sort] [--reverse-sort] [--select-only] [-h help]\n" \ + "$(basename "$0")" + exit 1 +} + +DIR="$HOME" +SORT= +REVERSE= +SELECTONLY=0 + +while [ -n "${1:-}" ]; do + case "$1" in + --select-only) + SELECTONLY=1 + ;; + --date-sort) + SORT="--sort=t" + ;; + --reverse-sort) + REVERSE="-r" + ;; + -h) + usage + ;; + *) + DIR="$1" + ;; + esac + shift +done + +cd "$DIR" + +sort_loop() { + CHOICES="$([ -z "$SORT" ] && printf "date" || printf "name")\n$([ -z "$REVERSE" ] && printf "desc" || printf "asc")\n" + + PICKED="$( + printf %b "$CHOICES" | + sxmo_dmenu.sh -p "Sort" -i + )" + + case "$PICKED" in + "date") + SORT="--sort=t" + ;; + "name") + SORT= + ;; + "desc") + REVERSE="-r" + ;; + "asc") + REVERSE= + ;; + esac +} + +while true; do + # shellcheck disable=SC2086 + FILES="$(ls -1p $SORT $REVERSE)" + CHOICES="$(printf %b 'Reload\nOpen in terminal\nClose Menu\nSort By\n../\n*\n'"$FILES")" + DIR="$(basename "$(pwd)")" + TRUNCATED="$(printf %.7s "$DIR")" + if [ "$DIR" != "$TRUNCATED" ]; then + DIR="$TRUNCATED..." + fi + + PICKED="$( + printf %b "$CHOICES" | + sxmo_dmenu.sh -p "$DIR" -i + )" + + case "$PICKED" in + "Sort By") + sort_loop + ;; + "Open in terminal") + exec sxmo_terminal.sh + ;; + "Close Menu") + exit 0 + ;; + "Reload") + continue + ;; + \*) + if [ -n "$SELECTONLY" ]; then + printf %s "Can't do this" + else + sxmo_open.sh -a ./* + fi + ;; + *) + [ -d "$PICKED" ] && cd "$PICKED" && continue + if [ -f "$PICKED" ]; then + if [ "$SELECTONLY" -eq 1 ]; then + printf "%s" "$(pwd)/$PICKED" && exit + else + sxmo_open.sh -a "$PICKED" + fi + fi + ;; + esac +done diff --git a/overlay/rootfs/usr/bin/sxmo_modemtext.sh b/overlay/rootfs/usr/bin/sxmo_modemtext.sh new file mode 100755 index 0000000..4b62168 --- /dev/null +++ b/overlay/rootfs/usr/bin/sxmo_modemtext.sh @@ -0,0 +1,5 @@ +#!/bin/sh +if command -v xsms >/dev/null 2>&1; then + exec xsms "$@" +fi +exec /usr/bin/dwm-plugin sms "$@" diff --git a/overlay/rootfs/usr/bin/xcamera b/overlay/rootfs/usr/bin/xcamera new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/overlay/rootfs/usr/bin/xcamera @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/overlay/rootfs/usr/bin/xfiles b/overlay/rootfs/usr/bin/xfiles new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/overlay/rootfs/usr/bin/xfiles @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/overlay/rootfs/usr/bin/xsms b/overlay/rootfs/usr/bin/xsms new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/overlay/rootfs/usr/bin/xsms @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/overlay/rootfs/usr/lib/dwm/plugins/calllog.so b/overlay/rootfs/usr/lib/dwm/plugins/calllog.so index 7090c67..b23e9c2 100755 Binary files a/overlay/rootfs/usr/lib/dwm/plugins/calllog.so and b/overlay/rootfs/usr/lib/dwm/plugins/calllog.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/camera.so b/overlay/rootfs/usr/lib/dwm/plugins/camera.so new file mode 100755 index 0000000..d7a25d1 Binary files /dev/null and b/overlay/rootfs/usr/lib/dwm/plugins/camera.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/contacts.so b/overlay/rootfs/usr/lib/dwm/plugins/contacts.so index 959505d..c18f7d8 100755 Binary files a/overlay/rootfs/usr/lib/dwm/plugins/contacts.so and b/overlay/rootfs/usr/lib/dwm/plugins/contacts.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/dialer.so b/overlay/rootfs/usr/lib/dwm/plugins/dialer.so index 5eddbb9..1351e51 100755 Binary files a/overlay/rootfs/usr/lib/dwm/plugins/dialer.so and b/overlay/rootfs/usr/lib/dwm/plugins/dialer.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/files.so b/overlay/rootfs/usr/lib/dwm/plugins/files.so new file mode 100755 index 0000000..4cb49c6 Binary files /dev/null and b/overlay/rootfs/usr/lib/dwm/plugins/files.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/sms.so b/overlay/rootfs/usr/lib/dwm/plugins/sms.so new file mode 100755 index 0000000..05ef3fb Binary files /dev/null and b/overlay/rootfs/usr/lib/dwm/plugins/sms.so differ diff --git a/overlay/rootfs/usr/lib/dwm/plugins/xdrawer.so b/overlay/rootfs/usr/lib/dwm/plugins/xdrawer.so index af91d3d..28a775c 100755 Binary files a/overlay/rootfs/usr/lib/dwm/plugins/xdrawer.so and b/overlay/rootfs/usr/lib/dwm/plugins/xdrawer.so differ diff --git a/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_contextmenu.sh b/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_contextmenu.sh index 74ad90c..fd83e21 100755 --- a/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_contextmenu.sh +++ b/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_contextmenu.sh @@ -766,16 +766,16 @@ case "$WMCLASS" in $icon_trm Terminal ^ 0 ^ st -e ${SHELL:-/bin/sh} $icon_grd Apps ^ 0 ^ sxmo_appmenu.sh applications $icon_grd Scripts ^ 0 ^ sxmo_appmenu.sh scripts - $icon_dir Files ^ 0 ^ sxmo_files.sh + $icon_dir Files ^ 0 ^ xfiles $icon_phn Dialer ^ 0 ^ xdialer $icon_phl Call Log ^ 0 ^ xcalllog - $icon_msg Texts ^ 0 ^ sxmo_modemtext.sh + $icon_msg Texts ^ 0 ^ xsms $icon_usr Contacts ^ 0 ^ xcontacts $( bt_is_unblocked && printf %b "$icon_bth Bluetooth ^ 1 ^ sxmo_bluetoothmenu.sh" ) - $icon_cam Camera ^ 0 ^ bananarchy-photo --menu + $icon_cam Camera ^ 0 ^ xcamera $( brightness="" if [ -r /sys/class/leds/white:torch/brightness ]; then diff --git a/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_tailtextlog.sh b/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_tailtextlog.sh index 3ec3ba0..52cc120 100755 --- a/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_tailtextlog.sh +++ b/overlay/rootfs/usr/share/sxmo/default_hooks/sxmo_hook_tailtextlog.sh @@ -3,55 +3,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2022 Sxmo Contributors -# This hook displays the sms log for a numbers passed as $1 - -LOGDIRNUM="$1" - -# Wrap this in a terminal if not already done -if [ -z "$TERMNAME" ]; then - TERMNAME="$LOGDIRNUM SMS" - - # If it's already open, switch to it. - if [ "$SXMO_WM" = "sway" ] && [ -z "$SSH_CLIENT" ]; then - regesc_termname="$(echo "$TERMNAME" | sed 's|+|\\+|g')" - if swaymsg -q "[title=\"^$regesc_termname\$\"]" focus; then - exit 0 - fi - fi - - export TERMNAME - exec sxmo_terminal.sh "$0" "$@" +if command -v xsms >/dev/null 2>&1; then + exec xsms tailtextlog "$@" fi - -# include common definitions -# shellcheck source=scripts/core/sxmo_common.sh -. sxmo_common.sh - -mkcontactssedcmd() { - pnc find "$LOGDIRNUM" | while read -r NUMBER; do - CONTACT="$(sxmo_contacts.sh --name-or-number "$NUMBER")" - if [ ! "$CONTACT" = "$NUMBER" ]; then - printf %s "s/$NUMBER/$CONTACT/;" - fi - done -} - -# Replace certain dates with human readable versions -TODAY="$(date +%F)" -YESTERDAY="$(date -d "- 1 day" +%F)" -TWO_DAYS="$(date -d "- 2 day" +%F)" -DATESEDCMD="s/ at ${TODAY}T/ today at /; s/ at ${YESTERDAY}T/ yesterday at /; s/ at ${TWO_DAYS}T/ two days ago at /; s/[-+][0-9][0-9][0-9][0-9]://; s/\(-[0-9][0-9]\)T\([0-9][0-9]\)/\1 \2/;" - -# TODO -#ALIGNSEDCMD="s/^Sent//;s/^Received//;" - -# Colorize Sent and Received lines -RECEIVED_COLOR="2" -SENT_COLOR="3" -COLORSEDCMD="s#^\(Sent.*\)#$(tput setaf "$SENT_COLOR")\1$(tput op)#;s#^\(Received.*\)#$(tput setaf "$RECEIVED_COLOR")\1$(tput op)#;" - -# Replace phone numbers in the filename with contacts from contact book -CONTACTSSEDCMD="$(mkcontactssedcmd)" - -tail -n9999 -f "$SXMO_LOGDIR/$LOGDIRNUM/sms.txt" |\ - sed -e "$CONTACTSSEDCMD" -e "$DATESEDCMD" -e "$COLORSEDCMD" +exec /usr/bin/dwm-plugin sms tailtextlog "$@" diff --git a/prebuilt/sxmo-x11/usr/bin/dwm b/prebuilt/sxmo-x11/usr/bin/dwm index 2921d1e..9ff0292 100755 Binary files a/prebuilt/sxmo-x11/usr/bin/dwm and b/prebuilt/sxmo-x11/usr/bin/dwm differ diff --git a/prebuilt/sxmo-x11/usr/bin/dwm-plugin b/prebuilt/sxmo-x11/usr/bin/dwm-plugin index 6522237..a315043 100755 Binary files a/prebuilt/sxmo-x11/usr/bin/dwm-plugin and b/prebuilt/sxmo-x11/usr/bin/dwm-plugin differ diff --git a/prebuilt/sxmo-x11/usr/bin/xcamera b/prebuilt/sxmo-x11/usr/bin/xcamera new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/prebuilt/sxmo-x11/usr/bin/xcamera @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/prebuilt/sxmo-x11/usr/bin/xfiles b/prebuilt/sxmo-x11/usr/bin/xfiles new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/prebuilt/sxmo-x11/usr/bin/xfiles @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/prebuilt/sxmo-x11/usr/bin/xsms b/prebuilt/sxmo-x11/usr/bin/xsms new file mode 120000 index 0000000..e492465 --- /dev/null +++ b/prebuilt/sxmo-x11/usr/bin/xsms @@ -0,0 +1 @@ +dwm-plugin \ No newline at end of file diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/calllog.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/calllog.so index 7090c67..b23e9c2 100755 Binary files a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/calllog.so and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/calllog.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/camera.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/camera.so new file mode 100755 index 0000000..d7a25d1 Binary files /dev/null and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/camera.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so index 959505d..c18f7d8 100755 Binary files a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/dialer.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/dialer.so index 5eddbb9..1351e51 100755 Binary files a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/dialer.so and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/dialer.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so new file mode 100755 index 0000000..4cb49c6 Binary files /dev/null and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/sms.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/sms.so new file mode 100755 index 0000000..05ef3fb Binary files /dev/null and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/sms.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/xdrawer.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/xdrawer.so index af91d3d..28a775c 100755 Binary files a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/xdrawer.so and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/xdrawer.so differ diff --git a/scripts/build-sxmo-x11.sh b/scripts/build-sxmo-x11.sh index ea9ce07..42c6349 100755 --- a/scripts/build-sxmo-x11.sh +++ b/scripts/build-sxmo-x11.sh @@ -55,7 +55,9 @@ EOF harfbuzz-dev \ pango-dev \ glib-dev \ - cairo-dev >/dev/null + cairo-dev \ + libjpeg-turbo-dev \ + libpng-dev >/dev/null export PKG_CONFIG_SYSROOT_DIR="$SYSROOT" export PKG_CONFIG_LIBDIR="$SYSROOT/usr/lib/pkgconfig:$SYSROOT/usr/share/pkgconfig" @@ -97,10 +99,11 @@ EOF dwm.c drw.c util.c $DWM_LIBS -o /bananarchy/prebuilt/sxmo-x11/usr/bin/dwm $STRIP --strip-unneeded /bananarchy/prebuilt/sxmo-x11/usr/bin/dwm - echo "[6/6] Compiling DWM Plugins (xdrawer.so, dialer.so, calllog.so, contacts.so) and dwm-plugin runner..." + echo "[6/6] Compiling DWM Plugins (xdrawer, dialer, calllog, contacts, sms, files, camera) and dwm-plugin runner..." mkdir -p /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins - PLUG_INCS="$(pkgconf --cflags x11 xft fontconfig freetype2 pango pangoxft alsa)" + PLUG_INCS="$(pkgconf --cflags x11 xft fontconfig freetype2 pango pangoxft alsa libjpeg libpng)" PLUG_LIBS="$(pkgconf --libs x11 xft fontconfig freetype2 pango pangoxft alsa)" + MEDIA_LIBS="$(pkgconf --libs libjpeg libpng)" $CXX $OPT_CFLAGS -fPIC -shared -std=c++17 -Wall -Wextra $PLUG_INCS \ /bananarchy/src/xdrawer/xdrawer.cpp $PLUG_LIBS \ @@ -118,11 +121,26 @@ EOF /bananarchy/src/dwm-plugins/contacts_plugin.cpp $PLUG_LIBS \ -o /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so + $CXX $OPT_CFLAGS -fPIC -shared -std=c++17 -Wall -Wextra $PLUG_INCS \ + /bananarchy/src/dwm-plugins/sms_plugin.cpp $PLUG_LIBS \ + -o /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/sms.so + + $CXX $OPT_CFLAGS -fPIC -shared -std=c++17 -Wall -Wextra $PLUG_INCS \ + /bananarchy/src/dwm-plugins/files_plugin.cpp $PLUG_LIBS $MEDIA_LIBS \ + -o /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so + + $CXX $OPT_CFLAGS -fPIC -shared -std=c++17 -Wall -Wextra $PLUG_INCS \ + /bananarchy/src/dwm-plugins/camera_plugin.cpp $PLUG_LIBS $MEDIA_LIBS -lpthread \ + -o /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/camera.so + $STRIP --strip-unneeded \ /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/xdrawer.so \ /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/dialer.so \ /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/calllog.so \ - /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so + /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/contacts.so \ + /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/sms.so \ + /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so \ + /bananarchy/prebuilt/sxmo-x11/usr/lib/dwm/plugins/camera.so $CC $OPT_CFLAGS -std=c99 -D_DEFAULT_SOURCE -Wall -Wextra \ /bananarchy/src/dwm-plugins/dwm_plugin_runner.c -ldl \ @@ -134,6 +152,9 @@ EOF ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xdialer ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xcalllog ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xcontacts + ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xsms + ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xfiles + ln -sf dwm-plugin /bananarchy/prebuilt/sxmo-x11/usr/bin/xcamera mkdir -p /bananarchy/overlay/rootfs/usr/bin /bananarchy/overlay/rootfs/usr/lib/dwm/plugins cp -a /bananarchy/prebuilt/sxmo-x11/usr/bin/. /bananarchy/overlay/rootfs/usr/bin/ diff --git a/src/dwm-plugins/calllog_plugin.cpp b/src/dwm-plugins/calllog_plugin.cpp index 20768c7..efbb443 100644 --- a/src/dwm-plugins/calllog_plugin.cpp +++ b/src/dwm-plugins/calllog_plugin.cpp @@ -347,15 +347,7 @@ int calllog_run_main(int argc, char** argv) { XSync(dpy, False); auto showWindow = [&]() { - if (isPidFileAlive(getRuntimeFile("xdialer.pid"))) { - sendPluginCommand("dialer", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xcontacts.pid"))) { - sendPluginCommand("contacts", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xdrawer.pid"))) { - sendPluginCommand("xdrawer", "CLOSE"); - } + closeOtherPlugins("calllog"); if (!visible) { openStartMs = nowMs(); startupIgnoreUntilMs = openStartMs + 90; @@ -457,11 +449,6 @@ int calllog_run_main(int argc, char** argv) { if (ev.xexpose.count == 0) { render(); } - } else if (ev.type == VisibilityNotify) { - if (ev.xvisibility.state != VisibilityUnobscured) { - XRaiseWindow(dpy, win); - XFlush(dpy); - } } else if (ev.type == KeyRelease) { KeySym sym = XLookupKeysym(&ev.xkey, 0); if (sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || diff --git a/src/dwm-plugins/camera_plugin.cpp b/src/dwm-plugins/camera_plugin.cpp new file mode 100644 index 0000000..b06f766 --- /dev/null +++ b/src/dwm-plugins/camera_plugin.cpp @@ -0,0 +1,1046 @@ +#include "phone_data.h" +#include "media_utils.h" +#include "../sxmo-dwm/dwm_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace bananarchy; + +volatile sig_atomic_t gRunning = 1; + +void handleSignal(int) { + gRunning = 0; +} + +enum class ViewMode { + Viewfinder = 0, + Gallery = 1, + ConfirmDelete = 2, +}; + +constexpr int kCamW = 800; +constexpr int kCamH = 600; +constexpr size_t kCamFrameBytes = kCamW * kCamH * 3; +constexpr int kVfW = 240; +constexpr int kVfH = 180; + +const char* getTorchBrightnessPath() { + if (::access("/sys/class/leds/white:torch/brightness", R_OK) == 0) { + return "/sys/class/leds/white:torch/brightness"; + } + if (::access("/sys/class/leds/white:flash/brightness", R_OK) == 0) { + return "/sys/class/leds/white:flash/brightness"; + } + return nullptr; +} + +bool getTorchState() { + const char* p = getTorchBrightnessPath(); + if (!p) return false; + return readIntFile(p, 0) > 0; +} + +void setTorchState(bool on) { + const char* p = getTorchBrightnessPath(); + if (!p) return; + writeIntFile(p, on ? 1 : 0); +} + +std::string getPicturesDir() { + const char* xdg = std::getenv("XDG_PICTURES_DIR"); + if (xdg && xdg[0]) { + ::mkdir(xdg, 0755); + return xdg; + } + const char* home = std::getenv("HOME"); + std::string dir = std::string(home && home[0] ? home : "/home/user") + "/Pictures"; + ::mkdir(dir.c_str(), 0755); + return dir; +} + +struct PhotoEntry { + std::string name; + std::string fullPath; + off_t size = 0; + time_t mtime = 0; +}; + +std::vector listSavedPhotos() { + std::vector out; + std::string dir = getPicturesDir(); + DIR* d = ::opendir(dir.c_str()); + if (!d) return out; + struct dirent* ent; + while ((ent = ::readdir(d)) != nullptr) { + std::string name = ent->d_name; + if (name.empty() || name[0] == '.') continue; + std::string low = toLowerStr(name); + bool isImg = (low.size() > 4 && + (low.substr(low.size() - 4) == ".jpg" || + low.substr(low.size() - 4) == ".png")) || + (low.size() > 5 && low.substr(low.size() - 5) == ".jpeg"); + if (!isImg) continue; + PhotoEntry pe; + pe.name = name; + pe.fullPath = dir + "/" + name; + struct stat st {}; + if (::stat(pe.fullPath.c_str(), &st) == 0 && S_ISREG(st.st_mode)) { + pe.size = st.st_size; + pe.mtime = st.st_mtim.tv_sec; + out.push_back(pe); + } + } + ::closedir(d); + std::sort(out.begin(), out.end(), + [](const PhotoEntry& a, const PhotoEntry& b) { + if (a.mtime != b.mtime) return a.mtime > b.mtime; + return a.name > b.name; + }); + return out; +} + +void saveZoomedRgb24AsJpeg(const std::string& path, + const uint8_t* src800x600, + int zoomFactor) { + if (zoomFactor <= 1) { + saveRgb24AsJpeg(path, src800x600, kCamW, kCamH, 92); + return; + } + zoomFactor = std::clamp(zoomFactor, 1, 4); + int cropW = kCamW / zoomFactor; + int cropH = kCamH / zoomFactor; + int startX = (kCamW - cropW) / 2; + int startY = (kCamH - cropH) / 2; + + std::vector zoomed(kCamFrameBytes); + for (int dy = 0; dy < kCamH; ++dy) { + int sy = startY + (dy * cropH) / kCamH; + const uint8_t* srow = src800x600 + static_cast(sy) * kCamW * 3; + uint8_t* drow = zoomed.data() + static_cast(dy) * kCamW * 3; + for (int dx = 0; dx < kCamW; ++dx) { + int sx = startX + (dx * cropW) / kCamW; + const uint8_t* px = srow + sx * 3; + drow[dx * 3 + 0] = px[0]; + drow[dx * 3 + 1] = px[1]; + drow[dx * 3 + 2] = px[2]; + } + } + saveRgb24AsJpeg(path, zoomed.data(), kCamW, kCamH, 92); +} + +int runCameraApp(int argc, char** argv) { + gRunning = 1; + std::setlocale(LC_ALL, ""); + XSetLocaleModifiers(""); + + bool isPreload = false; + std::string initArg; + for (int i = 1; i < argc; ++i) { + if (!argv[i]) continue; + if (std::strcmp(argv[i], "--preload") == 0) { + isPreload = true; + } else if (std::strcmp(argv[i], "gallery") == 0) { + initArg = "gallery"; + } + } + + std::string daemonPidPath = getPluginDaemonPidPath("camera"); + std::string activePidPath = getRuntimeFile("xcamera.pid"); + + int existingDaemon = readIntFile(daemonPidPath, -1); + if (existingDaemon > 0 && existingDaemon != static_cast(::getpid()) && + ::kill(existingDaemon, 0) == 0) { + if (!isPreload) { + sendPluginCommand("camera", + initArg.empty() ? "OPEN" : ("OPEN " + initArg)); + } + return 0; + } + + writeIntFile(daemonPidPath, static_cast(::getpid())); + int fifoFd = setupPluginDaemonFifo("camera"); + + int vfPipe[2] = {-1, -1}; + if (::pipe(vfPipe) == 0) { + ::fcntl(vfPipe[0], F_SETFL, O_NONBLOCK | O_CLOEXEC); + ::fcntl(vfPipe[1], F_SETFL, O_NONBLOCK | O_CLOEXEC); + } + + struct sigaction sa {}; + sa.sa_handler = handleSignal; + sigemptyset(&sa.sa_mask); + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + Display* dpy = XOpenDisplay(nullptr); + if (!dpy) { + if (fifoFd >= 0) ::close(fifoFd); + if (vfPipe[0] >= 0) ::close(vfPipe[0]); + if (vfPipe[1] >= 0) ::close(vfPipe[1]); + ::unlink(daemonPidPath.c_str()); + return 1; + } + + int screen = DefaultScreen(dpy); + Window root = RootWindow(dpy, screen); + Visual* visual = DefaultVisual(dpy, screen); + Colormap cmap = DefaultColormap(dpy, screen); + + XSetWindowAttributes swa {}; + swa.override_redirect = True; + swa.background_pixel = 0x181818; + swa.border_pixel = 0x005577; + swa.event_mask = ExposureMask | KeyPressMask | KeyReleaseMask | + VisibilityChangeMask | StructureNotifyMask; + + Window win = XCreateWindow( + dpy, root, kWinX, kWinY, kWinWidth, kWinHeight, 0, + DefaultDepth(dpy, screen), CopyFromParent, visual, + CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &swa); + + XClassHint ch {}; + char resName[] = "xcamera"; + char resClass[] = "XCamera"; + ch.res_name = resName; + ch.res_class = resClass; + XSetClassHint(dpy, win, &ch); + XStoreName(dpy, win, "Camera"); + + Pixmap pix = XCreatePixmap(dpy, win, kWinWidth, kWinHeight, + DefaultDepth(dpy, screen)); + GC gc = XCreateGC(dpy, pix, 0, nullptr); + XftDraw* xftDraw = XftDrawCreate(dpy, pix, visual, cmap); + + PangoFontMap* fontMap = pango_xft_get_font_map(dpy, screen); + PangoContext* pangoCtx = pango_font_map_create_context(fontMap); + + PangoLayout* layoutMain = pango_layout_new(pangoCtx); + PangoFontDescription* descMain = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 15px"); + pango_layout_set_font_description(layoutMain, descMain); + + PangoLayout* layoutSmall = pango_layout_new(pangoCtx); + PangoFontDescription* descSmall = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 12px"); + pango_layout_set_font_description(layoutSmall, descSmall); + + PangoLayout* layoutIcon = pango_layout_new(pangoCtx); + PangoFontDescription* descIcon = + pango_font_description_from_string("DejaVuSansM Nerd Font, Fira Code 16px"); + pango_layout_set_font_description(layoutIcon, descIcon); + + auto allocColor = [&](const char* hex) { + XftColor col {}; + XftColorAllocName(dpy, visual, cmap, hex, &col); + return col; + }; + + XftColor colWhite = allocColor("#ffffff"); + XftColor colLight = allocColor("#dddddd"); + XftColor colMuted = allocColor("#999999"); + XftColor colYellow = allocColor("#f4d03f"); + XftColor colGreen = allocColor("#2ecc71"); + XftColor colBlue = allocColor("#5dade2"); + XftColor colRed = allocColor("#e74c3c"); + + std::vector vfFront(kVfW * kVfH, 0x101010); + std::vector vfBack(kVfW * kVfH, 0x101010); + XImage* xImgVf = XCreateImage( + dpy, visual, DefaultDepth(dpy, screen), ZPixmap, 0, + reinterpret_cast(vfFront.data()), kVfW, kVfH, 32, kVfW * 4); + + std::mutex frameMutex; + std::atomic vfThreadRunning {false}; + std::atomic vfShouldStop {false}; + std::atomic zoomFactor {1}; + std::atomic flashAuto {false}; + std::atomic snapRequested {false}; + std::thread vfThread; + + bool cameraReady = false; + std::string cameraStatus = "Initializing camera..."; + std::string statusBanner; + int64_t statusBannerUntilMs = 0; + int64_t shutterFlashUntilMs = 0; + + bool timerEnabled = false; + bool countdownActive = false; + int64_t countdownTargetMs = 0; + + bool torchOn = false; + bool torchRestoredOnClose = false; + + ViewMode mode = ViewMode::Viewfinder; + std::vector photos; + int selectedPhoto = 0; + int confirmSelected = 0; + int galleryImgW = 0; + int galleryImgH = 0; + + bool visible = false; + int64_t openStartMs = 0; + int64_t startupIgnoreUntilMs = 0; + bool initialSoftkeysReleased = true; + int softkeyPending = 0; + int64_t softkeyDeadlineMs = 0; + + auto stopViewfinderThread = [&]() { + vfShouldStop = true; + if (vfThread.joinable()) { + vfThread.join(); + } + vfThreadRunning = false; + }; + + auto startViewfinderThread = [&]() { + stopViewfinderThread(); + vfShouldStop = false; + vfThreadRunning = true; + { + std::lock_guard lk(frameMutex); + cameraReady = false; + cameraStatus = "Starting /dev/video0..."; + } + + int writePipe = vfPipe[1]; + vfThread = std::thread([&, writePipe]() { + int fd = ::open("/dev/video0", O_RDWR | O_CLOEXEC); + if (fd < 0) { + std::lock_guard lk(frameMutex); + cameraReady = false; + cameraStatus = "/dev/video0 not available"; + if (writePipe >= 0) { + char c = 'E'; + (void)::write(writePipe, &c, 1); + } + vfThreadRunning = false; + return; + } + + struct v4l2_format fmt {}; + fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + fmt.fmt.pix.width = kCamW; + fmt.fmt.pix.height = kCamH; + fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB24; + fmt.fmt.pix.field = V4L2_FIELD_NONE; + (void)::ioctl(fd, VIDIOC_S_FMT, &fmt); + + bool useMmap = false; + void* mmapPtr = MAP_FAILED; + size_t mmapLen = 0; + + struct v4l2_requestbuffers req {}; + req.count = 1; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + if (::ioctl(fd, VIDIOC_REQBUFS, &req) == 0 && req.count >= 1) { + struct v4l2_buffer buf {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = 0; + if (::ioctl(fd, VIDIOC_QUERYBUF, &buf) == 0) { + mmapLen = buf.length; + mmapPtr = ::mmap(nullptr, mmapLen, PROT_READ | PROT_WRITE, MAP_SHARED, + fd, buf.m.offset); + if (mmapPtr != MAP_FAILED) { + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (::ioctl(fd, VIDIOC_STREAMON, &type) == 0) { + useMmap = true; + } + } + } + } + + std::vector localRgb; + if (!useMmap) { + localRgb.resize(kCamFrameBytes); + } + + while (!vfShouldStop && gRunning) { + bool doSnap = snapRequested.exchange(false); + bool usedTempFlash = false; + if (doSnap && flashAuto.load() && !getTorchState()) { + setTorchState(true); + usedTempFlash = true; + struct timespec ts {0, 120 * 1000 * 1000}; + nanosleep(&ts, nullptr); + } + + const uint8_t* framePtr = nullptr; + if (useMmap) { + struct v4l2_buffer buf {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = 0; + if (::ioctl(fd, VIDIOC_QBUF, &buf) == 0 && + ::ioctl(fd, VIDIOC_DQBUF, &buf) == 0) { + framePtr = static_cast(mmapPtr); + } + } else { + ::lseek(fd, 0, SEEK_SET); + ssize_t r = ::read(fd, localRgb.data(), kCamFrameBytes); + if (r == static_cast(kCamFrameBytes)) { + framePtr = localRgb.data(); + } + } + + if (usedTempFlash) { + setTorchState(false); + } + + if (framePtr) { + int zf = zoomFactor.load(); + downscaleViewfinder800x600To240x180(framePtr, vfBack.data(), zf); + + std::string savedName; + if (doSnap) { + time_t nowSec = std::time(nullptr); + struct tm tmNow {}; + localtime_r(&nowSec, &tmNow); + char nameBuf[64]; + std::strftime(nameBuf, sizeof(nameBuf), "photo-%Y%m%d-%H%M%S.jpg", + &tmNow); + savedName = nameBuf; + std::string fullPath = getPicturesDir() + "/" + savedName; + saveZoomedRgb24AsJpeg(fullPath, framePtr, zf); + } + + { + std::lock_guard lk(frameMutex); + vfFront.swap(vfBack); + cameraReady = true; + cameraStatus.clear(); + if (!savedName.empty()) { + statusBanner = "Saved " + savedName; + statusBannerUntilMs = nowMs() + 2200; + shutterFlashUntilMs = nowMs() + 140; + } + } + if (writePipe >= 0) { + char c = 'F'; + (void)::write(writePipe, &c, 1); + } + } else { + struct timespec ts {0, 30 * 1000 * 1000}; + nanosleep(&ts, nullptr); + } + } + + if (useMmap) { + enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + (void)::ioctl(fd, VIDIOC_STREAMOFF, &type); + } + if (mmapPtr != MAP_FAILED) { + ::munmap(mmapPtr, mmapLen); + } + ::close(fd); + vfThreadRunning = false; + }); + }; + + auto loadGalleryPhoto = [&]() { + if (photos.empty()) { + std::fill(vfFront.begin(), vfFront.end(), 0x101010); + galleryImgW = 0; + galleryImgH = 0; + return; + } + selectedPhoto = std::clamp(selectedPhoto, 0, + static_cast(photos.size()) - 1); + std::vector rgb; + if (loadImageRgb24(photos[selectedPhoto].fullPath, &rgb, &galleryImgW, + &galleryImgH)) { + scaleRgb24ToBgrxCanvas(rgb.data(), galleryImgW, galleryImgH, + vfFront.data(), kVfW, kVfH, 0x101010); + } else { + std::fill(vfFront.begin(), vfFront.end(), 0x101010); + galleryImgW = 0; + galleryImgH = 0; + } + }; + + auto enterGalleryMode = [&]() { + stopViewfinderThread(); + photos = listSavedPhotos(); + selectedPhoto = 0; + loadGalleryPhoto(); + mode = ViewMode::Gallery; + }; + + auto enterViewfinderMode = [&]() { + mode = ViewMode::Viewfinder; + photos = listSavedPhotos(); + if (visible) { + startViewfinderThread(); + } + }; + + auto triggerShutter = [&]() { + if (timerEnabled && !countdownActive) { + countdownActive = true; + countdownTargetMs = nowMs() + 3000; + return; + } + countdownActive = false; + snapRequested = true; + }; + + auto drawPango = [&](PangoLayout* layout, XftColor* color, int x, int y, int h, + const std::string& text, int maxW = -1) { + pango_layout_set_text(layout, text.c_str(), static_cast(text.size())); + if (maxW > 0) { + pango_layout_set_width(layout, maxW * PANGO_SCALE); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_END); + } else { + pango_layout_set_width(layout, -1); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE); + } + int tw = 0, th = 0; + pango_layout_get_pixel_size(layout, &tw, &th); + int dy = y + std::max(0, (h - th) / 2); + pango_xft_render_layout(xftDraw, color, layout, x * PANGO_SCALE, + dy * PANGO_SCALE); + return tw; + }; + + auto drawSoftkeyBar = [&](const std::string& leftLabel, + const std::string& centerLabel, + const std::string& rightLabel) { + const int barY = kWinHeight - 26; + XSetForeground(dpy, gc, 0x141414); + XFillRectangle(dpy, pix, gc, 0, barY, kWinWidth, 26); + XSetForeground(dpy, gc, 0x2d2d2d); + XDrawLine(dpy, pix, gc, 0, barY, kWinWidth, barY); + + drawPango(layoutMain, &colBlue, 6, barY, 26, leftLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, centerLabel.c_str(), + static_cast(centerLabel.size())); + int cw = 0, chh = 0; + pango_layout_get_pixel_size(layoutMain, &cw, &chh); + drawPango(layoutMain, &colWhite, (kWinWidth - cw) / 2, barY, 26, + centerLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, rightLabel.c_str(), + static_cast(rightLabel.size())); + int rw = 0, rhh = 0; + pango_layout_get_pixel_size(layoutMain, &rw, &rhh); + drawPango(layoutMain, &colLight, kWinWidth - rw - 6, barY, 26, rightLabel); + }; + + auto drawUI = [&]() { + if (!visible) return; + int64_t tNow = nowMs(); + + XSetForeground(dpy, gc, 0x181818); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kWinHeight); + + if (mode == ViewMode::Viewfinder) { + // Top status bar (28px) + XSetForeground(dpy, gc, 0x141414); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 28); + drawPango(layoutIcon, &colGreen, 6, 0, 28, kIconCamera); + drawPango(layoutMain, &colWhite, 28, 0, 28, "800x600", 85); + + int zf = zoomFactor.load(); + std::string zStr = std::to_string(zf) + ".0x"; + drawPango(layoutMain, zf > 1 ? &colYellow : &colBlue, 114, 0, 28, zStr); + + bool fl = flashAuto.load(); + std::string flBadge = torchOn ? "TORCH" : (fl ? "FLASH" : "NO-FL"); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, flBadge.c_str(), + static_cast(flBadge.size())); + int fw = 0, fhh = 0; + pango_layout_get_pixel_size(layoutSmall, &fw, &fhh); + drawPango(layoutSmall, (torchOn || fl) ? &colYellow : &colMuted, + kWinWidth - fw - 6, 0, 28, flBadge); + + // Viewfinder canvas (240x180 at y = 28..208) + const int vfY = 28; + bool isReady = false; + std::string errMsg; + { + std::lock_guard lk(frameMutex); + isReady = cameraReady; + errMsg = cameraStatus; + XPutImage(dpy, pix, gc, xImgVf, 0, 0, 0, vfY, kVfW, kVfH); + } + + if (!isReady && !errMsg.empty()) { + drawPango(layoutSmall, &colLight, 16, vfY + 78, 24, errMsg, 208); + } else { + // Subtle framing reticle in center of viewfinder + XSetForeground(dpy, gc, 0x0088bb); + int cx = kWinWidth / 2; + int cy = vfY + kVfH / 2; + XDrawLine(dpy, pix, gc, cx - 10, cy, cx - 3, cy); + XDrawLine(dpy, pix, gc, cx + 3, cy, cx + 10, cy); + XDrawLine(dpy, pix, gc, cx, cy - 10, cx, cy - 3); + XDrawLine(dpy, pix, gc, cx, cy + 3, cx, cy + 10); + } + + if (tNow < shutterFlashUntilMs) { + XSetForeground(dpy, gc, 0xffffff); + XDrawRectangle(dpy, pix, gc, 0, vfY, kVfW - 1, kVfH - 1); + XDrawRectangle(dpy, pix, gc, 1, vfY + 1, kVfW - 3, kVfH - 3); + } + + if (countdownActive) { + int remSec = std::max(1, static_cast((countdownTargetMs - tNow + 999) / 1000)); + XSetForeground(dpy, gc, 0x005577); + XFillRectangle(dpy, pix, gc, 78, vfY + 66, 84, 44); + drawPango(layoutMain, &colWhite, 92, vfY + 66, 44, + "Timer: " + std::to_string(remSec)); + } + + // Camera control strip (y = 208..270, 62px) + const int ctlY = 208; + XSetForeground(dpy, gc, 0x1e1e1e); + XFillRectangle(dpy, pix, gc, 0, ctlY, kWinWidth, 62); + XSetForeground(dpy, gc, 0x333333); + XDrawLine(dpy, pix, gc, 0, ctlY, kWinWidth, ctlY); + + // Pill 1: Flash (* or 1) + XSetForeground(dpy, gc, fl ? 0x004466 : 0x282828); + XFillRectangle(dpy, pix, gc, 6, ctlY + 5, 110, 24); + drawPango(layoutSmall, fl ? &colYellow : &colLight, 10, ctlY + 5, 24, + std::string("[*] Flash:") + (fl ? "ON" : "OFF"), 102); + + // Pill 2: Torch (0 or 2) + XSetForeground(dpy, gc, torchOn ? 0x004466 : 0x282828); + XFillRectangle(dpy, pix, gc, 124, ctlY + 5, 110, 24); + drawPango(layoutSmall, torchOn ? &colYellow : &colLight, 128, ctlY + 5, + 24, std::string("[0] Torch:") + (torchOn ? "ON" : "OFF"), 102); + + // Pill 3: Zoom (Up/Dn) + XSetForeground(dpy, gc, zf > 1 ? 0x004466 : 0x282828); + XFillRectangle(dpy, pix, gc, 6, ctlY + 33, 110, 24); + drawPango(layoutSmall, &colBlue, 10, ctlY + 33, 24, + "[U/D] Zoom:" + std::to_string(zf) + "x", 102); + + // Pill 4: Timer (# or 3) + XSetForeground(dpy, gc, timerEnabled ? 0x004466 : 0x282828); + XFillRectangle(dpy, pix, gc, 124, ctlY + 33, 110, 24); + drawPango(layoutSmall, timerEnabled ? &colYellow : &colLight, 128, + ctlY + 33, 24, + std::string("[#] Timer:") + (timerEnabled ? "3s" : "OFF"), 102); + + drawSoftkeyBar("Gallery", "SNAP", "Close"); + + } else if (mode == ViewMode::Gallery) { + XSetForeground(dpy, gc, 0x141414); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 28); + drawPango(layoutIcon, &colBlue, 6, 0, 28, kIconImage); + drawPango(layoutMain, &colWhite, 28, 0, 28, "Gallery", 110); + + std::string idxStr = + photos.empty() ? "0/0" + : (std::to_string(selectedPhoto + 1) + "/" + + std::to_string(photos.size())); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, idxStr.c_str(), + static_cast(idxStr.size())); + int iw = 0, ihh = 0; + pango_layout_get_pixel_size(layoutSmall, &iw, &ihh); + drawPango(layoutSmall, &colMuted, kWinWidth - iw - 6, 0, 28, idxStr); + + const int vfY = 28; + if (photos.empty()) { + drawPango(layoutMain, &colMuted, 24, vfY + 75, 26, "No saved photos", + 192); + } else { + XPutImage(dpy, pix, gc, xImgVf, 0, 0, 0, vfY, kVfW, kVfH); + const auto& pe = photos[selectedPhoto]; + drawPango(layoutMain, &colWhite, 6, 212, 26, pe.name, 228); + std::string meta = std::to_string(galleryImgW) + "x" + + std::to_string(galleryImgH) + " " + + formatFileSize(pe.size) + " (L/R: Prev/Next)"; + drawPango(layoutSmall, &colMuted, 6, 240, 22, meta, 228); + } + + drawSoftkeyBar(photos.empty() ? "Camera" : "Delete", + photos.empty() ? "Camera" : "Full", + "Camera"); + + } else if (mode == ViewMode::ConfirmDelete && !photos.empty()) { + const auto& pe = photos[selectedPhoto]; + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + drawPango(layoutIcon, &colRed, 8, 0, 36, kIconTrash); + drawPango(layoutMain, &colWhite, 30, 0, 36, "Delete Photo?", 200); + + drawPango(layoutMain, &colWhite, 14, 70, 26, pe.name, 212); + drawPango(layoutSmall, &colMuted, 14, 100, 22, + "Remove this photo from ~/Pictures?", 212); + + for (int i = 0; i < 2; ++i) { + int ry = 156 + i * 40; + bool isSel = (confirmSelected == i); + XSetForeground(dpy, gc, + isSel ? (i == 1 ? 0x922b21 : 0x005577) : 0x2a2a2a); + XFillRectangle(dpy, pix, gc, 16, ry, kWinWidth - 32, 34); + drawPango(layoutMain, &colWhite, 28, ry, 34, + i == 0 ? "No, Keep Photo" : "Yes, Delete Photo", 184); + } + drawSoftkeyBar("Select", "Confirm", "Cancel"); + } + + { + std::lock_guard lk(frameMutex); + if (!statusBanner.empty() && tNow < statusBannerUntilMs) { + XSetForeground(dpy, gc, 0x102535); + XFillRectangle(dpy, pix, gc, 8, 174, kWinWidth - 16, 28); + XSetForeground(dpy, gc, 0x2ecc71); + XDrawRectangle(dpy, pix, gc, 8, 174, kWinWidth - 17, 27); + drawPango(layoutSmall, &colWhite, 14, 174, 28, statusBanner, + kWinWidth - 28); + } + } + + XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0); + XFlush(dpy); + }; + + auto showWindow = [&](const std::string& argStr) { + closeOtherPlugins("camera"); + countdownActive = false; + torchOn = getTorchState(); + torchRestoredOnClose = !torchOn; + + writeIntFile(activePidPath, static_cast(::getpid())); + if (!visible) { + visible = true; + XMapRaised(dpy, win); + for (int i = 0; i < 20; ++i) { + if (XGrabKeyboard(dpy, win, True, GrabModeAsync, GrabModeAsync, + CurrentTime) == GrabSuccess) { + break; + } + usleep(2000); + } + } else { + XRaiseWindow(dpy, win); + } + XSetInputFocus(dpy, win, RevertToPointerRoot, CurrentTime); + openStartMs = nowMs(); + startupIgnoreUntilMs = openStartMs + 180; + initialSoftkeysReleased = (querySoftkeysDown(dpy) == 0); + softkeyPending = 0; + + if (argStr == "gallery") { + enterGalleryMode(); + } else { + enterViewfinderMode(); + } + drawUI(); + }; + + auto hideWindow = [&]() { + if (!visible) return; + countdownActive = false; + stopViewfinderThread(); + if (torchOn && torchRestoredOnClose) { + setTorchState(false); + torchOn = false; + } + writeSoftkeySuppress(nowMs() + 450); + XUngrabKeyboard(dpy, CurrentTime); + XUnmapWindow(dpy, win); + XFlush(dpy); + visible = false; + ::unlink(activePidPath.c_str()); + }; + + auto handleSoftkeyAction = [&](bool isLeft) { + if (mode == ViewMode::Viewfinder) { + if (isLeft) { + enterGalleryMode(); + } else { + hideWindow(); + return; + } + } else if (mode == ViewMode::Gallery) { + if (isLeft) { + if (photos.empty()) { + enterViewfinderMode(); + } else { + confirmSelected = 0; + mode = ViewMode::ConfirmDelete; + } + } else { + enterViewfinderMode(); + } + } else if (mode == ViewMode::ConfirmDelete) { + if (isLeft) { + if (confirmSelected == 1 && !photos.empty()) { + ::unlink(photos[selectedPhoto].fullPath.c_str()); + photos = listSavedPhotos(); + loadGalleryPhoto(); + } + mode = ViewMode::Gallery; + } else { + mode = ViewMode::Gallery; + } + } + drawUI(); + }; + + if (!isPreload) { + showWindow(initArg); + } + + int xfd = ConnectionNumber(dpy); + while (gRunning) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "NOP") { + continue; + } else if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + if (!visible) continue; + + if (ev.type == Expose && ev.xexpose.count == 0) { + drawUI(); + } else if (ev.type == KeyPress) { + int64_t tNow = nowMs(); + unsigned int kc = ev.xkey.keycode; + KeySym sym = XLookupKeysym(&ev.xkey, 0); + + bool isLeftSk = + (kc == 135 || sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || + sym == _EVDEVK(0x246) || sym == XK_F1); + bool isRightSk = + (kc == 166 || sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 || + sym == _EVDEVK(0x247) || sym == XK_F2); + + if (!initialSoftkeysReleased) { + if (querySoftkeysDown(dpy) == 0) { + initialSoftkeysReleased = true; + } else if (isLeftSk || isRightSk) { + continue; + } + } + if (tNow < startupIgnoreUntilMs && (isLeftSk || isRightSk)) { + continue; + } + + if (isLeftSk || isRightSk) { + int bit = isLeftSk ? 1 : 2; + softkeyPending |= bit; + if (softkeyPending == 3 || querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + continue; + } + softkeyDeadlineMs = tNow + 35; + continue; + } + + if (sym == XK_Escape || kc == 124 || sym == XF86XK_PowerOff) { + hideWindow(); + continue; + } + + if (sym == XK_BackSpace || sym == XK_Delete) { + handleSoftkeyAction(false); + continue; + } + + bool isCallKey = + (kc == 177 || sym == XF86XK_PickupPhone || sym == XF86XK_Phone || + sym == _EVDEVK(0x1bd) || sym == _EVDEVK(0x0a9)); + + if (mode == ViewMode::Viewfinder) { + if (sym == XK_Return || sym == XK_KP_Enter || sym == XK_space || + isCallKey) { + triggerShutter(); + drawUI(); + } else if (sym == XK_Up || sym == XK_KP_Up) { + int curZ = zoomFactor.load(); + zoomFactor = (curZ == 1) ? 2 : (curZ == 2 ? 4 : 4); + drawUI(); + } else if (sym == XK_Down || sym == XK_KP_Down) { + int curZ = zoomFactor.load(); + zoomFactor = (curZ == 4) ? 2 : 1; + drawUI(); + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (d == '5') { + triggerShutter(); + drawUI(); + } else if (d == '1' || d == '*') { + flashAuto = !flashAuto.load(); + drawUI(); + } else if (d == '2' || d == '0') { + torchOn = !torchOn; + setTorchState(torchOn); + drawUI(); + } else if (d == '3' || d == '#') { + timerEnabled = !timerEnabled; + countdownActive = false; + drawUI(); + } + } + } else if (mode == ViewMode::Gallery) { + if (sym == XK_Left || sym == XK_KP_Left || sym == XK_Up || + sym == XK_KP_Up) { + if (!photos.empty()) { + selectedPhoto = + (selectedPhoto - 1 + static_cast(photos.size())) % + static_cast(photos.size()); + loadGalleryPhoto(); + drawUI(); + } + } else if (sym == XK_Right || sym == XK_KP_Right || sym == XK_Down || + sym == XK_KP_Down) { + if (!photos.empty()) { + selectedPhoto = + (selectedPhoto + 1) % static_cast(photos.size()); + loadGalleryPhoto(); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + if (photos.empty()) { + enterViewfinderMode(); + drawUI(); + } else { + runAsyncArgv("feh", photos[selectedPhoto].fullPath.c_str()); + } + } + } else if (mode == ViewMode::ConfirmDelete) { + if (sym == XK_Up || sym == XK_Down || sym == XK_KP_Up || + sym == XK_KP_Down) { + confirmSelected = 1 - confirmSelected; + drawUI(); + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } + } + } + + int64_t tNow = nowMs(); + if (visible && softkeyPending != 0 && tNow >= softkeyDeadlineMs) { + if (querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + } else { + bool isLeft = (softkeyPending == 1); + softkeyPending = 0; + handleSoftkeyAction(isLeft); + } + } + + if (visible && countdownActive && tNow >= countdownTargetMs) { + countdownActive = false; + snapRequested = true; + drawUI(); + } + + int timeoutMs = -1; + if (visible) { + if (softkeyPending != 0) { + timeoutMs = std::max(1, static_cast(softkeyDeadlineMs - tNow)); + } else if (countdownActive) { + timeoutMs = 150; + } + } + + struct pollfd pfds[3]; + int nfds = 0; + pfds[nfds++] = {xfd, POLLIN, 0}; + int fifoIdx = -1; + if (fifoFd >= 0) { + fifoIdx = nfds; + pfds[nfds++] = {fifoFd, POLLIN, 0}; + } + int vfIdx = -1; + if (vfPipe[0] >= 0) { + vfIdx = nfds; + pfds[nfds++] = {vfPipe[0], POLLIN, 0}; + } + + int pr = ::poll(pfds, nfds, timeoutMs); + if (pr > 0) { + if (fifoIdx >= 0 && (pfds[fifoIdx].revents & POLLIN)) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + } + if (vfIdx >= 0 && (pfds[vfIdx].revents & POLLIN)) { + char drain[64]; + while (::read(vfPipe[0], drain, sizeof(drain)) > 0) { + } + if (visible && mode == ViewMode::Viewfinder) { + drawUI(); + } + } + } else if (pr == 0 && visible && countdownActive) { + drawUI(); + } + } + + hideWindow(); + if (xImgVf) { + xImgVf->data = nullptr; + XDestroyImage(xImgVf); + } + if (fifoFd >= 0) ::close(fifoFd); + if (vfPipe[0] >= 0) ::close(vfPipe[0]); + if (vfPipe[1] >= 0) ::close(vfPipe[1]); + ::unlink(daemonPidPath.c_str()); + return 0; +} + +int camera_plugin_init(const DwmHostApi* host) { + if (host && host->spawn_plugin) { + host->spawn_plugin("camera", "--preload"); + } + return 0; +} + +} // namespace + +extern "C" __attribute__((visibility("default"))) const DwmPlugin dwm_plugin_entry = { + DWM_PLUGIN_API_VERSION, + "camera", + "Camera app with live V4L2 viewfinder, flash/torch, zoom, and photo gallery", + camera_plugin_init, + nullptr, + nullptr, + nullptr, + nullptr, + runCameraApp +}; diff --git a/src/dwm-plugins/contacts_plugin.cpp b/src/dwm-plugins/contacts_plugin.cpp index 7635c20..d35edea 100644 --- a/src/dwm-plugins/contacts_plugin.cpp +++ b/src/dwm-plugins/contacts_plugin.cpp @@ -692,15 +692,7 @@ int runContactsApp(int argc, char** argv) { }; auto showWindow = [&](const std::string& arg) { - if (isPidFileAlive(getRuntimeFile("xdialer.pid"))) { - sendPluginCommand("dialer", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xcalllog.pid"))) { - sendPluginCommand("calllog", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xdrawer.pid"))) { - sendPluginCommand("xdrawer", "CLOSE"); - } + closeOtherPlugins("contacts"); bool openNewMode = (arg.rfind("new:", 0) == 0); std::string prefill = openNewMode ? arg.substr(4) : ""; @@ -844,13 +836,17 @@ int runContactsApp(int argc, char** argv) { break; case 1: // Send SMS writeSoftkeySuppress(nowMs() + 350); - hideWindow(true); - runAsyncCmd("sxmo_modemtext.sh sendtextmenu '" + c.number + "'"); + hideWindow(false); + if (!sendPluginCommand("sms", "OPEN send:" + c.number)) { + runAsyncArgv("xsms", "sendtextmenu", c.number.c_str()); + } break; case 2: // Read Messages writeSoftkeySuppress(nowMs() + 350); - hideWindow(true); - runAsyncCmd("sxmo_hook_tailtextlog.sh '" + c.number + "'"); + hideWindow(false); + if (!sendPluginCommand("sms", "OPEN thread:" + c.number)) { + runAsyncArgv("xsms", "tailtextlog", c.number.c_str()); + } break; case 3: // Open in Dialer writeSoftkeySuppress(nowMs() + 300); @@ -961,11 +957,6 @@ int runContactsApp(int argc, char** argv) { if (ev.xexpose.count == 0) { render(); } - } else if (ev.type == VisibilityNotify) { - if (ev.xvisibility.state != VisibilityUnobscured) { - XRaiseWindow(dpy, win); - XFlush(dpy); - } } else if (ev.type == KeyRelease) { KeySym sym = XLookupKeysym(&ev.xkey, 0); if (ev.xkey.keycode == 19 || sym == XK_0) { diff --git a/src/dwm-plugins/dialer_plugin.cpp b/src/dwm-plugins/dialer_plugin.cpp index 26f23a1..71bee84 100644 --- a/src/dwm-plugins/dialer_plugin.cpp +++ b/src/dwm-plugins/dialer_plugin.cpp @@ -386,15 +386,7 @@ int dialer_run_main(int argc, char** argv) { XSync(dpy, False); auto showWindow = [&](const std::string& arg) { - if (isPidFileAlive(getRuntimeFile("xcalllog.pid"))) { - sendPluginCommand("calllog", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xcontacts.pid"))) { - sendPluginCommand("contacts", "CLOSE"); - } - if (isPidFileAlive(getRuntimeFile("xdrawer.pid"))) { - sendPluginCommand("xdrawer", "CLOSE"); - } + closeOtherPlugins("dialer"); std::string cleanArg = normalizePhone(arg); if (!visible) { openStartMs = nowMs(); @@ -536,11 +528,6 @@ int dialer_run_main(int argc, char** argv) { if (ev.xexpose.count == 0) { render(); } - } else if (ev.type == VisibilityNotify) { - if (ev.xvisibility.state != VisibilityUnobscured) { - XRaiseWindow(dpy, win); - XFlush(dpy); - } } else if (ev.type == KeyRelease) { KeySym sym = XLookupKeysym(&ev.xkey, 0); if (ev.xkey.keycode == 19 || sym == XK_0) { diff --git a/src/dwm-plugins/dwm_plugin_runner.c b/src/dwm-plugins/dwm_plugin_runner.c index 27dd873..a055ff9 100644 --- a/src/dwm-plugins/dwm_plugin_runner.c +++ b/src/dwm-plugins/dwm_plugin_runner.c @@ -41,6 +41,14 @@ static int try_send_plugin_fifo(const char *plugin_name, int sub_argc, char **su } else if (sub_argc >= 2 && sub_argv[1] && strcmp(sub_argv[1], "newcontact") == 0) { const char *num = (sub_argc >= 3 && sub_argv[2]) ? sub_argv[2] : ""; len = snprintf(cmd, sizeof(cmd), "OPEN new:%s\n", num); + } else if (sub_argc >= 2 && sub_argv[1] && strcmp(sub_argv[1], "sendtextmenu") == 0) { + const char *num = (sub_argc >= 3 && sub_argv[2]) ? sub_argv[2] : ""; + len = snprintf(cmd, sizeof(cmd), "OPEN send:%s\n", num); + } else if (sub_argc >= 2 && sub_argv[1] && + (strcmp(sub_argv[1], "tailtextlog") == 0 || + strcmp(sub_argv[1], "conversationloop") == 0)) { + const char *num = (sub_argc >= 3 && sub_argv[2]) ? sub_argv[2] : ""; + len = snprintf(cmd, sizeof(cmd), "OPEN thread:%s\n", num); } else if (sub_argc >= 2 && sub_argv[1] && sub_argv[1][0]) { len = snprintf(cmd, sizeof(cmd), "OPEN %s\n", sub_argv[1]); } else { @@ -73,6 +81,15 @@ static const char *resolve_plugin_name_from_argv0(const char *argv0) { if (strcasecmp(base, "xcontacts") == 0 || strcasecmp(base, "contacts") == 0) { return "contacts"; } + if (strcasecmp(base, "xsms") == 0 || strcasecmp(base, "sms") == 0) { + return "sms"; + } + if (strcasecmp(base, "xfiles") == 0 || strcasecmp(base, "files") == 0) { + return "files"; + } + if (strcasecmp(base, "xcamera") == 0 || strcasecmp(base, "camera") == 0) { + return "camera"; + } return NULL; } diff --git a/src/dwm-plugins/files_plugin.cpp b/src/dwm-plugins/files_plugin.cpp new file mode 100644 index 0000000..9d29ba2 --- /dev/null +++ b/src/dwm-plugins/files_plugin.cpp @@ -0,0 +1,1429 @@ +#include "phone_data.h" +#include "media_utils.h" +#include "../sxmo-dwm/dwm_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace bananarchy; + +volatile sig_atomic_t gRunning = 1; + +void handleSignal(int) { + gRunning = 0; +} + +enum class ViewMode { + Browser = 0, + Actions = 1, + TextView = 2, + ImageView = 3, + PropsView = 4, + InputModal = 5, + ConfirmDelete = 6, +}; + +enum class SortMode { + Name = 0, + Date = 1, + Size = 2, +}; + +enum class InputAction { + NewFolder = 0, + NewFile = 1, + Rename = 2, +}; + +enum class TextCaseMode { + Lower = 0, // abc + Title = 1, // Abc + Upper = 2, // ABC + Num = 3, // 123 +}; + +const char* kMultiTapFile[10] = { + " _-0", // 0 + ".-_1", // 1 + "abc2", // 2 + "def3", // 3 + "ghi4", // 4 + "jkl5", // 5 + "mno6", // 6 + "pqrs7", // 7 + "tuv8", // 8 + "wxyz9" // 9 +}; + +struct FileItem { + std::string name; + std::string fullPath; + std::string lowerName; + std::string t9Name; + bool isParent = false; + bool isDir = false; + bool isExec = false; + off_t size = 0; + time_t mtime = 0; + mode_t mode = 0; +}; + +void popUtf8(std::string& s) { + if (s.empty()) return; + while (!s.empty() && (static_cast(s.back()) & 0xC0) == 0x80) { + s.pop_back(); + } + if (!s.empty()) s.pop_back(); +} + +std::string getExtLower(const std::string& name) { + size_t dot = name.rfind('.'); + if (dot == std::string::npos || dot + 1 >= name.size()) { + return ""; + } + return toLowerStr(name.substr(dot + 1)); +} + +bool isImageExt(const std::string& ext) { + return ext == "jpg" || ext == "jpeg" || ext == "png"; +} + +bool isMediaExt(const std::string& ext) { + return ext == "mp3" || ext == "wav" || ext == "ogg" || ext == "flac" || + ext == "m4a" || ext == "mp4" || ext == "mkv" || ext == "webm" || + ext == "avi"; +} + +bool isLikelyTextFile(const std::string& path, off_t size) { + if (size == 0) return true; + if (size > 256 * 1024) return false; + std::ifstream in(path, std::ios::binary); + if (!in.is_open()) return false; + char buf[512]; + in.read(buf, sizeof(buf)); + std::streamsize n = in.gcount(); + for (std::streamsize i = 0; i < n; ++i) { + unsigned char c = static_cast(buf[i]); + if (c == 0) { + return false; + } + } + return true; +} + +std::string shortenPath(const std::string& path) { + const char* home = std::getenv("HOME"); + std::string h = (home && home[0]) ? home : "/home/user"; + std::string out = path; + if (out == h) { + out = "~"; + } else if (out.rfind(h + "/", 0) == 0) { + out = "~" + out.substr(h.size()); + } + if (out.size() > 18) { + out = "..." + out.substr(out.size() - 15); + } + return out; +} + +std::string formatPermissions(mode_t m) { + std::string s(10, '-'); + if (S_ISDIR(m)) s[0] = 'd'; + else if (S_ISLNK(m)) s[0] = 'l'; + if (m & S_IRUSR) s[1] = 'r'; + if (m & S_IWUSR) s[2] = 'w'; + if (m & S_IXUSR) s[3] = 'x'; + if (m & S_IRGRP) s[4] = 'r'; + if (m & S_IWGRP) s[5] = 'w'; + if (m & S_IXGRP) s[6] = 'x'; + if (m & S_IROTH) s[7] = 'r'; + if (m & S_IWOTH) s[8] = 'w'; + if (m & S_IXOTH) s[9] = 'x'; + char oct[16]; + std::snprintf(oct, sizeof(oct), " (%04o)", static_cast(m & 07777)); + return s + oct; +} + +bool removeRecursive(const std::string& path) { + struct stat st {}; + if (::lstat(path.c_str(), &st) != 0) { + return false; + } + if (S_ISDIR(st.st_mode) && !S_ISLNK(st.st_mode)) { + DIR* d = ::opendir(path.c_str()); + if (d) { + struct dirent* ent; + while ((ent = ::readdir(d)) != nullptr) { + if (std::strcmp(ent->d_name, ".") == 0 || + std::strcmp(ent->d_name, "..") == 0) { + continue; + } + removeRecursive(path + "/" + ent->d_name); + } + ::closedir(d); + } + return ::rmdir(path.c_str()) == 0; + } + return ::unlink(path.c_str()) == 0; +} + +bool copyRecursive(const std::string& src, const std::string& dst) { + pid_t pid = ::fork(); + if (pid == 0) { + execlp("cp", "cp", "-a", src.c_str(), dst.c_str(), + static_cast(nullptr)); + _exit(127); + } else if (pid > 0) { + int status = 0; + ::waitpid(pid, &status, 0); + return WIFEXITED(status) && WEXITSTATUS(status) == 0; + } + return false; +} + +int runFilesApp(int argc, char** argv) { + gRunning = 1; + std::setlocale(LC_ALL, ""); + XSetLocaleModifiers(""); + + bool isPreload = false; + std::string initDir; + for (int i = 1; i < argc; ++i) { + if (!argv[i]) continue; + if (std::strcmp(argv[i], "--preload") == 0) { + isPreload = true; + } else if (argv[i][0] != '-' && initDir.empty()) { + initDir = argv[i]; + } + } + + std::string daemonPidPath = getPluginDaemonPidPath("files"); + std::string activePidPath = getRuntimeFile("xfiles.pid"); + + int existingDaemon = readIntFile(daemonPidPath, -1); + if (existingDaemon > 0 && existingDaemon != static_cast(::getpid()) && + ::kill(existingDaemon, 0) == 0) { + if (!isPreload) { + sendPluginCommand("files", initDir.empty() ? "OPEN" : ("OPEN " + initDir)); + } + return 0; + } + + writeIntFile(daemonPidPath, static_cast(::getpid())); + int fifoFd = setupPluginDaemonFifo("files"); + + struct sigaction sa {}; + sa.sa_handler = handleSignal; + sigemptyset(&sa.sa_mask); + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + Display* dpy = XOpenDisplay(nullptr); + if (!dpy) { + if (fifoFd >= 0) ::close(fifoFd); + ::unlink(daemonPidPath.c_str()); + return 1; + } + + int screen = DefaultScreen(dpy); + Window root = RootWindow(dpy, screen); + Visual* visual = DefaultVisual(dpy, screen); + Colormap cmap = DefaultColormap(dpy, screen); + + XSetWindowAttributes swa {}; + swa.override_redirect = True; + swa.background_pixel = 0x222222; + swa.border_pixel = 0x005577; + swa.event_mask = ExposureMask | KeyPressMask | KeyReleaseMask | + VisibilityChangeMask | StructureNotifyMask; + + Window win = XCreateWindow( + dpy, root, kWinX, kWinY, kWinWidth, kWinHeight, 0, + DefaultDepth(dpy, screen), CopyFromParent, visual, + CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &swa); + + XClassHint ch {}; + char resName[] = "xfiles"; + char resClass[] = "XFiles"; + ch.res_name = resName; + ch.res_class = resClass; + XSetClassHint(dpy, win, &ch); + XStoreName(dpy, win, "Files"); + + Pixmap pix = XCreatePixmap(dpy, win, kWinWidth, kWinHeight, + DefaultDepth(dpy, screen)); + GC gc = XCreateGC(dpy, pix, 0, nullptr); + XftDraw* xftDraw = XftDrawCreate(dpy, pix, visual, cmap); + + PangoFontMap* fontMap = pango_xft_get_font_map(dpy, screen); + PangoContext* pangoCtx = pango_font_map_create_context(fontMap); + + PangoLayout* layoutMain = pango_layout_new(pangoCtx); + PangoFontDescription* descMain = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 15px"); + pango_layout_set_font_description(layoutMain, descMain); + + PangoLayout* layoutSmall = pango_layout_new(pangoCtx); + PangoFontDescription* descSmall = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 12px"); + pango_layout_set_font_description(layoutSmall, descSmall); + + PangoLayout* layoutIcon = pango_layout_new(pangoCtx); + PangoFontDescription* descIcon = + pango_font_description_from_string("DejaVuSansM Nerd Font, Fira Code 16px"); + pango_layout_set_font_description(layoutIcon, descIcon); + + auto allocColor = [&](const char* hex) { + XftColor col {}; + XftColorAllocName(dpy, visual, cmap, hex, &col); + return col; + }; + + XftColor colWhite = allocColor("#ffffff"); + XftColor colLight = allocColor("#dddddd"); + XftColor colMuted = allocColor("#999999"); + XftColor colYellow = allocColor("#f4d03f"); + XftColor colGreen = allocColor("#2ecc71"); + XftColor colBlue = allocColor("#5dade2"); + XftColor colOrange = allocColor("#e67e22"); + XftColor colRed = allocColor("#e74c3c"); + + const char* homeEnv = std::getenv("HOME"); + std::string homeDir = (homeEnv && homeEnv[0]) ? homeEnv : "/home/user"; + std::string currentDir = homeDir; + + ViewMode mode = ViewMode::Browser; + SortMode sortMode = SortMode::Name; + bool showHidden = false; + std::string searchQuery; + + std::vector allItems; + std::vector filteredItems; + int selected = 0; + int actionSelected = 0; + int confirmSelected = 0; + + // Clipboard state + std::string clipPath; + std::string clipName; + bool clipIsCut = false; + + // Text viewer state + std::string viewingFilePath; + std::string viewingFileName; + std::vector textLines; + int textScrollLine = 0; + + // Image viewer state + constexpr int kImgCanvasW = 240; + constexpr int kImgCanvasH = 232; + std::vector imgCanvas(kImgCanvasW * kImgCanvasH, 0x111111); + XImage* xImgPreview = XCreateImage( + dpy, visual, DefaultDepth(dpy, screen), ZPixmap, 0, + reinterpret_cast(imgCanvas.data()), kImgCanvasW, kImgCanvasH, 32, + kImgCanvasW * 4); + int imgOrigW = 0; + int imgOrigH = 0; + bool imgValid = false; + + // Modal input state (New Folder / New File / Rename) + InputAction inputAction = InputAction::NewFolder; + std::string inputTitle; + std::string inputValue; + std::string inputTargetPath; + TextCaseMode caseMode = TextCaseMode::Lower; + int lastTapDigit = -1; + int lastTapCycle = 0; + int64_t lastTapMs = 0; + + std::string statusBanner; + int64_t statusBannerUntilMs = 0; + + struct ActionEntry { + int id; + std::string icon; + std::string label; + }; + std::vector actionsList; + + bool visible = false; + int64_t openStartMs = 0; + int64_t startupIgnoreUntilMs = 0; + bool initialSoftkeysReleased = true; + int softkeyPending = 0; + int64_t softkeyDeadlineMs = 0; + + auto updateFilter = [&]() { + filteredItems.clear(); + if (searchQuery.empty()) { + filteredItems = allItems; + } else { + std::string qLower = toLowerStr(searchQuery); + for (const auto& it : allItems) { + if (it.isParent) continue; + if (it.lowerName.find(qLower) != std::string::npos || + it.t9Name.find(searchQuery) != std::string::npos) { + filteredItems.push_back(it); + } + } + } + if (selected >= static_cast(filteredItems.size())) { + selected = std::max(0, static_cast(filteredItems.size()) - 1); + } + }; + + auto loadDirectory = [&](const std::string& targetDir, + const std::string& selectName = "") { + char resolved[4096]; + std::string dir = targetDir; + if (::realpath(targetDir.c_str(), resolved) != nullptr) { + dir = resolved; + } + DIR* d = ::opendir(dir.c_str()); + if (!d) { + statusBanner = "Cannot open directory"; + statusBannerUntilMs = nowMs() + 1800; + return; + } + currentDir = dir; + allItems.clear(); + + if (currentDir != "/") { + FileItem parent; + parent.name = ".."; + size_t slash = currentDir.rfind('/'); + parent.fullPath = (slash == 0 || slash == std::string::npos) + ? "/" + : currentDir.substr(0, slash); + parent.lowerName = ".."; + parent.isParent = true; + parent.isDir = true; + allItems.push_back(parent); + } + + std::vector dirs; + std::vector files; + struct dirent* ent; + while ((ent = ::readdir(d)) != nullptr) { + std::string name = ent->d_name; + if (name == "." || name == "..") continue; + if (!showHidden && !name.empty() && name[0] == '.') continue; + + FileItem item; + item.name = name; + item.fullPath = + (currentDir == "/") ? ("/" + name) : (currentDir + "/" + name); + item.lowerName = toLowerStr(name); + item.t9Name = toT9Digits(name); + + struct stat st {}; + if (::stat(item.fullPath.c_str(), &st) == 0) { + item.isDir = S_ISDIR(st.st_mode); + item.isExec = !item.isDir && ((st.st_mode & S_IXUSR) != 0); + item.size = st.st_size; + item.mtime = st.st_mtim.tv_sec; + item.mode = st.st_mode; + } + if (item.isDir) { + dirs.push_back(item); + } else { + files.push_back(item); + } + } + ::closedir(d); + + auto cmp = [&](const FileItem& a, const FileItem& b) { + if (sortMode == SortMode::Date && a.mtime != b.mtime) { + return a.mtime > b.mtime; + } + if (sortMode == SortMode::Size && a.size != b.size) { + return a.size > b.size; + } + return a.lowerName < b.lowerName; + }; + std::sort(dirs.begin(), dirs.end(), cmp); + std::sort(files.begin(), files.end(), cmp); + + allItems.insert(allItems.end(), dirs.begin(), dirs.end()); + allItems.insert(allItems.end(), files.begin(), files.end()); + + searchQuery.clear(); + updateFilter(); + selected = 0; + if (!selectName.empty()) { + for (size_t i = 0; i < filteredItems.size(); ++i) { + if (filteredItems[i].name == selectName) { + selected = static_cast(i); + break; + } + } + } + }; + + auto openTextViewer = [&](const FileItem& item) { + viewingFilePath = item.fullPath; + viewingFileName = item.name; + textLines.clear(); + textScrollLine = 0; + + std::ifstream in(item.fullPath); + std::string line; + int maxLines = 1500; + while (maxLines-- > 0 && std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + // Wrap lines longer than 30 chars for clean 240px display + if (line.empty()) { + textLines.push_back(""); + } else { + for (size_t pos = 0; pos < line.size(); pos += 30) { + textLines.push_back(line.substr(pos, 30)); + } + } + } + if (textLines.empty()) { + textLines.push_back("(Empty file)"); + } + mode = ViewMode::TextView; + }; + + auto openImageViewer = [&](const FileItem& item) { + viewingFilePath = item.fullPath; + viewingFileName = item.name; + std::vector rgb; + imgOrigW = 0; + imgOrigH = 0; + imgValid = loadImageRgb24(item.fullPath, &rgb, &imgOrigW, &imgOrigH); + if (imgValid) { + scaleRgb24ToBgrxCanvas(rgb.data(), imgOrigW, imgOrigH, imgCanvas.data(), + kImgCanvasW, kImgCanvasH, 0x111111); + } else { + std::fill(imgCanvas.begin(), imgCanvas.end(), 0x111111); + } + mode = ViewMode::ImageView; + }; + + auto buildActionsList = [&]() { + actionsList.clear(); + bool hasSel = !filteredItems.empty(); + bool isParent = hasSel && filteredItems[selected].isParent; + + if (hasSel) { + actionsList.push_back({1, kIconEye, "Open / View"}); + } + if (!clipPath.empty()) { + actionsList.push_back( + {7, kIconCheck, + std::string(clipIsCut ? "Move Here: " : "Paste Here: ") + clipName}); + } + actionsList.push_back({3, kIconPlus, "New Folder"}); + actionsList.push_back({4, kIconFileText, "New Empty File"}); + if (hasSel && !isParent) { + if (!filteredItems[selected].isDir) { + actionsList.push_back({2, kIconEdit, "Edit in nano"}); + } + actionsList.push_back({5, kIconEdit, "Rename"}); + actionsList.push_back({6, kIconCopy, "Copy"}); + actionsList.push_back({8, kIconOutCall, "Cut / Move"}); + actionsList.push_back({9, kIconInfo, "Properties / Info"}); + actionsList.push_back({13, kIconTrash, "Delete"}); + } + actionsList.push_back( + {10, kIconEye, + showHidden ? "Hide Dotfiles (.*)" : "Show Dotfiles (.*)"}); + std::string sortLbl = "Sort: "; + if (sortMode == SortMode::Name) sortLbl += "Date (#)"; + else if (sortMode == SortMode::Date) sortLbl += "Size (#)"; + else sortLbl += "Name (#)"; + actionsList.push_back({11, kIconSort, sortLbl}); + actionsList.push_back({12, kIconTerm, "Open Terminal Here"}); + actionSelected = 0; + }; + + auto openSelectedItem = [&]() { + if (filteredItems.empty()) return; + const FileItem item = filteredItems[selected]; + if (item.isParent) { + std::string prevBase = currentDir; + size_t sl = prevBase.rfind('/'); + std::string prevName = + (sl != std::string::npos) ? prevBase.substr(sl + 1) : ""; + loadDirectory(item.fullPath, prevName); + return; + } + if (item.isDir) { + loadDirectory(item.fullPath); + return; + } + std::string ext = getExtLower(item.name); + if (isImageExt(ext)) { + openImageViewer(item); + } else if (isMediaExt(ext)) { + runAsyncArgv("sxmo_open.sh", item.fullPath.c_str()); + statusBanner = "Opening in media player..."; + statusBannerUntilMs = nowMs() + 1800; + } else if (isLikelyTextFile(item.fullPath, item.size)) { + openTextViewer(item); + } else { + buildActionsList(); + mode = ViewMode::Actions; + } + }; + + auto drawPango = [&](PangoLayout* layout, XftColor* color, int x, int y, int h, + const std::string& text, int maxW = -1) { + pango_layout_set_text(layout, text.c_str(), static_cast(text.size())); + if (maxW > 0) { + pango_layout_set_width(layout, maxW * PANGO_SCALE); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_END); + } else { + pango_layout_set_width(layout, -1); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE); + } + int tw = 0, th = 0; + pango_layout_get_pixel_size(layout, &tw, &th); + int dy = y + std::max(0, (h - th) / 2); + pango_xft_render_layout(xftDraw, color, layout, x * PANGO_SCALE, + dy * PANGO_SCALE); + return tw; + }; + + auto drawSoftkeyBar = [&](const std::string& leftLabel, + const std::string& centerLabel, + const std::string& rightLabel) { + const int barY = kWinHeight - 26; + XSetForeground(dpy, gc, 0x141414); + XFillRectangle(dpy, pix, gc, 0, barY, kWinWidth, 26); + XSetForeground(dpy, gc, 0x2d2d2d); + XDrawLine(dpy, pix, gc, 0, barY, kWinWidth, barY); + + drawPango(layoutMain, &colBlue, 6, barY, 26, leftLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, centerLabel.c_str(), + static_cast(centerLabel.size())); + int cw = 0, chh = 0; + pango_layout_get_pixel_size(layoutMain, &cw, &chh); + drawPango(layoutMain, &colWhite, (kWinWidth - cw) / 2, barY, 26, + centerLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, rightLabel.c_str(), + static_cast(rightLabel.size())); + int rw = 0, rhh = 0; + pango_layout_get_pixel_size(layoutMain, &rw, &rhh); + drawPango(layoutMain, &colLight, kWinWidth - rw - 6, barY, 26, rightLabel); + }; + + auto drawUI = [&]() { + if (!visible) return; + + XSetForeground(dpy, gc, 0x222222); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kWinHeight); + + if (mode == ViewMode::Browser) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 38); + XSetForeground(dpy, gc, 0x333333); + XDrawLine(dpy, pix, gc, 0, 37, kWinWidth, 37); + + if (searchQuery.empty()) { + drawPango(layoutIcon, &colYellow, 6, 2, 34, kIconFolderOpen); + drawPango(layoutMain, &colWhite, 28, 2, 34, shortenPath(currentDir), + 156); + const char* sTag = + (sortMode == SortMode::Name) + ? "N" + : (sortMode == SortMode::Date ? "T" : "S"); + std::string badge = std::string("[") + sTag + ":" + + std::to_string(filteredItems.size()) + "]"; + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, badge.c_str(), + static_cast(badge.size())); + int bw = 0, bhh = 0; + pango_layout_get_pixel_size(layoutSmall, &bw, &bhh); + drawPango(layoutSmall, &colMuted, kWinWidth - bw - 6, 2, 34, badge); + } else { + drawPango(layoutIcon, &colBlue, 6, 2, 34, kIconSearch); + drawPango(layoutMain, &colYellow, 28, 2, 34, searchQuery, 160); + std::string cnt = std::to_string(filteredItems.size()); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, cnt.c_str(), + static_cast(cnt.size())); + int bw = 0, bhh = 0; + pango_layout_get_pixel_size(layoutSmall, &bw, &bhh); + drawPango(layoutSmall, &colMuted, kWinWidth - bw - 6, 2, 34, cnt); + } + + const int listY = 38; + const int rowH = 33; + const int visibleRows = 7; + + if (filteredItems.empty()) { + drawPango(layoutMain, &colMuted, 16, 120, 24, "Empty folder", 208); + } else { + int scroll = 0; + if (selected >= visibleRows) { + scroll = selected - visibleRows + 1; + } + for (int i = 0; i < visibleRows; ++i) { + int idx = scroll + i; + if (idx >= static_cast(filteredItems.size())) break; + const auto& it = filteredItems[idx]; + int ry = listY + i * rowH; + bool isSel = (idx == selected); + + if (isSel) { + XSetForeground(dpy, gc, 0x005577); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + XSetForeground(dpy, gc, 0x0088bb); + XDrawLine(dpy, pix, gc, 0, ry, kWinWidth, ry); + } else { + XSetForeground(dpy, gc, (i % 2 == 0) ? 0x222222 : 0x1d1d1d); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + } + + const char* ic = kIconFile; + XftColor* icCol = &colMuted; + if (it.isParent) { + ic = kIconBackspace; + icCol = &colBlue; + } else if (it.isDir) { + ic = kIconFolder; + icCol = &colYellow; + } else { + std::string ext = getExtLower(it.name); + if (isImageExt(ext)) { + ic = kIconImage; + icCol = &colGreen; + } else if (isMediaExt(ext)) { + ic = kIconMusic; + icCol = &colBlue; + } else if (it.isExec || ext == "sh") { + ic = kIconTerm; + icCol = &colOrange; + } else { + ic = kIconFileText; + icCol = &colLight; + } + } + + drawPango(layoutIcon, icCol, 6, ry, rowH, ic); + std::string dispName = + it.isParent ? ".. (Up)" : (it.isDir ? (it.name + "/") : it.name); + drawPango(layoutMain, &colWhite, 28, ry, rowH, dispName, 154); + + std::string meta = + it.isParent ? "" : (it.isDir ? "DIR" : formatFileSize(it.size)); + if (!meta.empty()) { + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, meta.c_str(), + static_cast(meta.size())); + int mw = 0, mhh = 0; + pango_layout_get_pixel_size(layoutSmall, &mw, &mhh); + drawPango(layoutSmall, isSel ? &colLight : &colMuted, + kWinWidth - mw - 6, ry, rowH, meta); + } + } + } + + drawSoftkeyBar("Actions", "Open", + searchQuery.empty() ? "Close" : "Clear"); + + } else if (mode == ViewMode::Actions) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34); + drawPango(layoutIcon, &colBlue, 6, 0, 34, kIconList); + drawPango(layoutMain, &colWhite, 28, 0, 34, "File Actions", 200); + + const int listY = 36; + const int rowH = 33; + const int visibleRows = 7; + int scroll = 0; + if (actionSelected >= visibleRows) { + scroll = actionSelected - visibleRows + 1; + } + for (int i = 0; i < visibleRows; ++i) { + int idx = scroll + i; + if (idx >= static_cast(actionsList.size())) break; + int ry = listY + i * rowH; + bool isSel = (idx == actionSelected); + XSetForeground(dpy, gc, isSel ? 0x005577 : 0x222222); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + drawPango(layoutIcon, isSel ? &colYellow : &colBlue, 8, ry, rowH, + actionsList[idx].icon); + drawPango(layoutMain, &colWhite, 32, ry, rowH, actionsList[idx].label, + 202); + } + drawSoftkeyBar("Select", "Select", "Back"); + + } else if (mode == ViewMode::TextView) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 32); + drawPango(layoutIcon, &colBlue, 6, 0, 32, kIconFileText); + drawPango(layoutMain, &colWhite, 28, 0, 32, viewingFileName, 150); + + std::string posStr = std::to_string(textScrollLine + 1) + "/" + + std::to_string(textLines.size()); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, posStr.c_str(), + static_cast(posStr.size())); + int pw = 0, phh = 0; + pango_layout_get_pixel_size(layoutSmall, &pw, &phh); + drawPango(layoutSmall, &colMuted, kWinWidth - pw - 6, 0, 32, posStr); + + const int startY = 36; + const int lineH = 18; + const int maxVis = 12; + for (int i = 0; i < maxVis; ++i) { + int idx = textScrollLine + i; + if (idx >= static_cast(textLines.size())) break; + drawPango(layoutSmall, &colLight, 6, startY + i * lineH, lineH, + textLines[idx], kWinWidth - 12); + } + drawSoftkeyBar("Edit", "Down", "Back"); + + } else if (mode == ViewMode::ImageView) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + drawPango(layoutIcon, &colGreen, 6, 1, 18, kIconImage); + drawPango(layoutMain, &colWhite, 28, 0, 20, viewingFileName, 145); + std::string dim = imgValid + ? (std::to_string(imgOrigW) + "x" + + std::to_string(imgOrigH)) + : "Decode failed"; + drawPango(layoutSmall, &colMuted, 28, 19, 15, dim, 200); + + XPutImage(dpy, pix, gc, xImgPreview, 0, 0, 0, 36, kImgCanvasW, + kImgCanvasH); + drawSoftkeyBar("Full", "Next", "Back"); + + } else if (mode == ViewMode::PropsView && !filteredItems.empty()) { + const auto& it = filteredItems[selected]; + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34); + drawPango(layoutIcon, &colBlue, 6, 0, 34, kIconInfo); + drawPango(layoutMain, &colWhite, 28, 0, 34, "Properties", 200); + + drawPango(layoutSmall, &colMuted, 8, 42, 16, "Name:"); + drawPango(layoutMain, &colWhite, 8, 58, 22, it.name, 224); + + drawPango(layoutSmall, &colMuted, 8, 86, 16, "Path:"); + drawPango(layoutSmall, &colLight, 8, 102, 20, it.fullPath, 224); + + drawPango(layoutSmall, &colMuted, 8, 128, 16, "Size:"); + drawPango(layoutMain, &colYellow, 8, 144, 22, + formatFileSize(it.size) + " (" + std::to_string(it.size) + + " B)", + 224); + + drawPango(layoutSmall, &colMuted, 8, 172, 16, "Permissions:"); + drawPango(layoutSmall, &colLight, 8, 188, 20, formatPermissions(it.mode), + 224); + + struct tm tmM {}; + localtime_r(&it.mtime, &tmM); + char tbuf[32]; + std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%d %H:%M:%S", &tmM); + drawPango(layoutSmall, &colMuted, 8, 214, 16, "Modified:"); + drawPango(layoutSmall, &colLight, 8, 230, 20, tbuf, 224); + + drawSoftkeyBar("Back", "Back", "Back"); + + } else if (mode == ViewMode::InputModal) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + drawPango(layoutIcon, &colBlue, 6, 0, 36, kIconEdit); + drawPango(layoutMain, &colWhite, 28, 0, 36, inputTitle, 145); + + const char* mBadge = "[abc]"; + if (caseMode == TextCaseMode::Title) mBadge = "[Abc]"; + else if (caseMode == TextCaseMode::Upper) mBadge = "[ABC]"; + else if (caseMode == TextCaseMode::Num) mBadge = "[123]"; + drawPango(layoutSmall, &colYellow, kWinWidth - 46, 0, 36, mBadge); + + XSetForeground(dpy, gc, 0x182630); + XFillRectangle(dpy, pix, gc, 10, 74, kWinWidth - 20, 38); + XSetForeground(dpy, gc, 0x0088bb); + XDrawRectangle(dpy, pix, gc, 10, 74, kWinWidth - 21, 37); + drawPango(layoutMain, &colWhite, 16, 74, 38, inputValue + "|", + kWinWidth - 32); + + drawPango(layoutSmall, &colMuted, 10, 126, 20, + "0-9: multi-tap #: cycle case", 220); + drawSoftkeyBar("Save", "Save", + inputValue.empty() ? "Cancel" : "Del"); + + } else if (mode == ViewMode::ConfirmDelete && !filteredItems.empty()) { + const auto& it = filteredItems[selected]; + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + drawPango(layoutIcon, &colRed, 8, 0, 36, kIconTrash); + drawPango(layoutMain, &colWhite, 30, 0, 36, "Delete Item?", 200); + + drawPango(layoutMain, &colWhite, 14, 68, 26, it.name, 212); + drawPango(layoutSmall, &colMuted, 14, 98, 22, + it.isDir ? "Directory and all contents" : "File will be permanently", + 212); + drawPango(layoutSmall, &colMuted, 14, 118, 22, + it.isDir ? "will be deleted!" : "deleted.", 212); + + for (int i = 0; i < 2; ++i) { + int ry = 164 + i * 40; + bool isSel = (confirmSelected == i); + XSetForeground(dpy, gc, + isSel ? (i == 1 ? 0x922b21 : 0x005577) : 0x2a2a2a); + XFillRectangle(dpy, pix, gc, 16, ry, kWinWidth - 32, 34); + drawPango(layoutMain, &colWhite, 28, ry, 34, + i == 0 ? "No, Keep Item" : "Yes, Delete", 184); + } + drawSoftkeyBar("Select", "Confirm", "Cancel"); + } + + int64_t tNow = nowMs(); + if (!statusBanner.empty() && tNow < statusBannerUntilMs) { + XSetForeground(dpy, gc, 0x102535); + XFillRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 16, 28); + XSetForeground(dpy, gc, 0x5dade2); + XDrawRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 17, 27); + drawPango(layoutSmall, &colWhite, 14, kWinHeight - 58, 28, statusBanner, + kWinWidth - 28); + } + + XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0); + XFlush(dpy); + }; + + auto showWindow = [&](const std::string& dirArg) { + closeOtherPlugins("files"); + mode = ViewMode::Browser; + loadDirectory(dirArg.empty() ? currentDir : dirArg); + writeIntFile(activePidPath, static_cast(::getpid())); + if (!visible) { + visible = true; + XMapRaised(dpy, win); + for (int i = 0; i < 20; ++i) { + if (XGrabKeyboard(dpy, win, True, GrabModeAsync, GrabModeAsync, + CurrentTime) == GrabSuccess) { + break; + } + usleep(2000); + } + } else { + XRaiseWindow(dpy, win); + } + XSetInputFocus(dpy, win, RevertToPointerRoot, CurrentTime); + openStartMs = nowMs(); + startupIgnoreUntilMs = openStartMs + 180; + initialSoftkeysReleased = (querySoftkeysDown(dpy) == 0); + softkeyPending = 0; + drawUI(); + }; + + auto hideWindow = [&]() { + if (!visible) return; + writeSoftkeySuppress(nowMs() + 450); + XUngrabKeyboard(dpy, CurrentTime); + XUnmapWindow(dpy, win); + XFlush(dpy); + visible = false; + ::unlink(activePidPath.c_str()); + }; + + auto commitModalTap = [&](int digit) { + const char* chars = kMultiTapFile[digit]; + int len = static_cast(std::strlen(chars)); + if (len <= 0) return; + + int64_t t = nowMs(); + if (caseMode == TextCaseMode::Num) { + inputValue.push_back(static_cast('0' + digit)); + lastTapDigit = -1; + return; + } + + bool sameKey = (lastTapDigit == digit && (t - lastTapMs) <= 850 && + !inputValue.empty()); + if (sameKey) { + lastTapCycle = (lastTapCycle + 1) % len; + popUtf8(inputValue); + } else { + lastTapDigit = digit; + lastTapCycle = 0; + } + lastTapMs = t; + + char ch = chars[lastTapCycle]; + if (std::isalpha(static_cast(ch))) { + if (caseMode == TextCaseMode::Upper) { + ch = static_cast(std::toupper(static_cast(ch))); + } else if (caseMode == TextCaseMode::Title && inputValue.empty()) { + ch = static_cast(std::toupper(static_cast(ch))); + } + } + inputValue.push_back(ch); + }; + + auto executeAction = [&](int actId) { + if (actId == 1) { + mode = ViewMode::Browser; + openSelectedItem(); + } else if (actId == 2 && !filteredItems.empty()) { + std::string p = filteredItems[selected].fullPath; + hideWindow(); + runAsyncCmd("st -e nano \"" + p + "\""); + } else if (actId == 3) { + inputAction = InputAction::NewFolder; + inputTitle = "New Folder"; + inputValue.clear(); + lastTapDigit = -1; + mode = ViewMode::InputModal; + } else if (actId == 4) { + inputAction = InputAction::NewFile; + inputTitle = "New File"; + inputValue.clear(); + lastTapDigit = -1; + mode = ViewMode::InputModal; + } else if (actId == 5 && !filteredItems.empty()) { + inputAction = InputAction::Rename; + inputTitle = "Rename"; + inputValue = filteredItems[selected].name; + inputTargetPath = filteredItems[selected].fullPath; + lastTapDigit = -1; + mode = ViewMode::InputModal; + } else if (actId == 6 && !filteredItems.empty()) { + clipPath = filteredItems[selected].fullPath; + clipName = filteredItems[selected].name; + clipIsCut = false; + statusBanner = "Copied: " + clipName; + statusBannerUntilMs = nowMs() + 1800; + mode = ViewMode::Browser; + } else if (actId == 8 && !filteredItems.empty()) { + clipPath = filteredItems[selected].fullPath; + clipName = filteredItems[selected].name; + clipIsCut = true; + statusBanner = "Cut: " + clipName; + statusBannerUntilMs = nowMs() + 1800; + mode = ViewMode::Browser; + } else if (actId == 7 && !clipPath.empty()) { + std::string dst = (currentDir == "/") ? ("/" + clipName) + : (currentDir + "/" + clipName); + bool ok = false; + if (clipIsCut) { + ok = (::rename(clipPath.c_str(), dst.c_str()) == 0); + if (!ok && copyRecursive(clipPath, dst)) { + removeRecursive(clipPath); + ok = true; + } + if (ok) clipPath.clear(); + } else { + ok = copyRecursive(clipPath, dst); + } + statusBanner = ok ? "Paste complete" : "Paste failed"; + statusBannerUntilMs = nowMs() + 1800; + loadDirectory(currentDir, clipName); + mode = ViewMode::Browser; + } else if (actId == 9 && !filteredItems.empty()) { + mode = ViewMode::PropsView; + } else if (actId == 10) { + showHidden = !showHidden; + loadDirectory(currentDir); + mode = ViewMode::Browser; + } else if (actId == 11) { + sortMode = static_cast((static_cast(sortMode) + 1) % 3); + loadDirectory(currentDir); + mode = ViewMode::Browser; + } else if (actId == 12) { + std::string d = currentDir; + hideWindow(); + runAsyncCmd("cd \"" + d + "\" && exec st"); + } else if (actId == 13 && !filteredItems.empty()) { + confirmSelected = 0; + mode = ViewMode::ConfirmDelete; + } + }; + + auto stepImageInFolder = [&](int delta) { + if (filteredItems.empty()) return; + int n = static_cast(filteredItems.size()); + for (int step = 1; step <= n; ++step) { + int idx = (selected + delta * step + n * 2) % n; + if (!filteredItems[idx].isDir && + isImageExt(getExtLower(filteredItems[idx].name))) { + selected = idx; + openImageViewer(filteredItems[selected]); + return; + } + } + }; + + auto handleSoftkeyAction = [&](bool isLeft) { + if (mode == ViewMode::Browser) { + if (isLeft) { + buildActionsList(); + mode = ViewMode::Actions; + } else { + if (!searchQuery.empty()) { + popUtf8(searchQuery); + updateFilter(); + } else { + hideWindow(); + return; + } + } + } else if (mode == ViewMode::Actions) { + if (isLeft) { + if (!actionsList.empty()) { + executeAction(actionsList[actionSelected].id); + } + } else { + mode = ViewMode::Browser; + } + } else if (mode == ViewMode::TextView) { + if (isLeft) { + std::string p = viewingFilePath; + hideWindow(); + runAsyncCmd("st -e nano \"" + p + "\""); + return; + } else { + mode = ViewMode::Browser; + } + } else if (mode == ViewMode::ImageView) { + if (isLeft) { + runAsyncArgv("feh", viewingFilePath.c_str()); + } else { + mode = ViewMode::Browser; + } + } else if (mode == ViewMode::PropsView) { + mode = ViewMode::Browser; + } else if (mode == ViewMode::InputModal) { + if (isLeft) { + std::string name = trimStr(inputValue); + if (!name.empty() && name.find('/') == std::string::npos) { + std::string target = (currentDir == "/") ? ("/" + name) + : (currentDir + "/" + name); + if (inputAction == InputAction::NewFolder) { + ::mkdir(target.c_str(), 0755); + } else if (inputAction == InputAction::NewFile) { + int fd = ::open(target.c_str(), O_WRONLY | O_CREAT, 0644); + if (fd >= 0) ::close(fd); + } else if (inputAction == InputAction::Rename) { + ::rename(inputTargetPath.c_str(), target.c_str()); + } + loadDirectory(currentDir, name); + } + mode = ViewMode::Browser; + } else { + if (!inputValue.empty()) { + popUtf8(inputValue); + lastTapDigit = -1; + } else { + mode = ViewMode::Browser; + } + } + } else if (mode == ViewMode::ConfirmDelete) { + if (isLeft) { + if (confirmSelected == 1 && !filteredItems.empty()) { + removeRecursive(filteredItems[selected].fullPath); + loadDirectory(currentDir); + } + mode = ViewMode::Browser; + } else { + mode = ViewMode::Browser; + } + } + drawUI(); + }; + + if (!isPreload) { + showWindow(initDir); + } + + int xfd = ConnectionNumber(dpy); + while (gRunning) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "NOP") { + continue; + } else if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + if (!visible) continue; + + if (ev.type == Expose && ev.xexpose.count == 0) { + drawUI(); + } else if (ev.type == KeyPress) { + int64_t tNow = nowMs(); + unsigned int kc = ev.xkey.keycode; + KeySym sym = XLookupKeysym(&ev.xkey, 0); + + bool isLeftSk = + (kc == 135 || sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || + sym == _EVDEVK(0x246) || sym == XK_F1); + bool isRightSk = + (kc == 166 || sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 || + sym == _EVDEVK(0x247) || sym == XK_F2); + + if (!initialSoftkeysReleased) { + if (querySoftkeysDown(dpy) == 0) { + initialSoftkeysReleased = true; + } else if (isLeftSk || isRightSk) { + continue; + } + } + if (tNow < startupIgnoreUntilMs && (isLeftSk || isRightSk)) { + continue; + } + + if (isLeftSk || isRightSk) { + int bit = isLeftSk ? 1 : 2; + softkeyPending |= bit; + if (softkeyPending == 3 || querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + continue; + } + softkeyDeadlineMs = tNow + 35; + continue; + } + + if (sym == XK_Escape || kc == 124 || sym == XF86XK_PowerOff) { + hideWindow(); + continue; + } + + if (sym == XK_BackSpace || sym == XK_Delete) { + if (mode == ViewMode::Browser && searchQuery.empty() && + currentDir != "/") { + std::string prevBase = currentDir; + size_t sl = prevBase.rfind('/'); + std::string prevName = + (sl != std::string::npos) ? prevBase.substr(sl + 1) : ""; + std::string upDir = + (sl == 0 || sl == std::string::npos) ? "/" : prevBase.substr(0, sl); + loadDirectory(upDir, prevName); + drawUI(); + } else { + handleSoftkeyAction(false); + } + continue; + } + + if (mode == ViewMode::Browser) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (!filteredItems.empty()) { + selected = + (selected - 1 + static_cast(filteredItems.size())) % + static_cast(filteredItems.size()); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (!filteredItems.empty()) { + selected = + (selected + 1) % static_cast(filteredItems.size()); + drawUI(); + } + } else if (sym == XK_Left || sym == XK_KP_Left) { + if (currentDir != "/") { + std::string prevBase = currentDir; + size_t sl = prevBase.rfind('/'); + std::string prevName = + (sl != std::string::npos) ? prevBase.substr(sl + 1) : ""; + std::string upDir = (sl == 0 || sl == std::string::npos) + ? "/" + : prevBase.substr(0, sl); + loadDirectory(upDir, prevName); + drawUI(); + } + } else if (sym == XK_Right || sym == XK_KP_Right || + sym == XK_Return || sym == XK_KP_Enter) { + openSelectedItem(); + drawUI(); + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (d == '*') { + showHidden = !showHidden; + loadDirectory(currentDir); + drawUI(); + } else if (d == '#') { + sortMode = + static_cast((static_cast(sortMode) + 1) % 3); + loadDirectory(currentDir); + drawUI(); + } else if (d >= '0' && d <= '9') { + searchQuery.push_back(d); + updateFilter(); + drawUI(); + } + } + } else if (mode == ViewMode::Actions) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (!actionsList.empty()) { + actionSelected = + (actionSelected - 1 + static_cast(actionsList.size())) % + static_cast(actionsList.size()); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (!actionsList.empty()) { + actionSelected = + (actionSelected + 1) % static_cast(actionsList.size()); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } else if (mode == ViewMode::TextView) { + if (sym == XK_Up || sym == XK_KP_Up) { + textScrollLine = std::max(0, textScrollLine - 1); + drawUI(); + } else if (sym == XK_Down || sym == XK_KP_Down || + sym == XK_Return || sym == XK_KP_Enter) { + textScrollLine = std::min( + std::max(0, static_cast(textLines.size()) - 1), + textScrollLine + 1); + drawUI(); + } else if (sym == XK_Left || sym == XK_KP_Left || sym == XK_Prior) { + textScrollLine = std::max(0, textScrollLine - 10); + drawUI(); + } else if (sym == XK_Right || sym == XK_KP_Right || sym == XK_Next) { + textScrollLine = std::min( + std::max(0, static_cast(textLines.size()) - 1), + textScrollLine + 10); + drawUI(); + } + } else if (mode == ViewMode::ImageView) { + if (sym == XK_Left || sym == XK_KP_Left || sym == XK_Up || + sym == XK_KP_Up) { + stepImageInFolder(-1); + drawUI(); + } else if (sym == XK_Right || sym == XK_KP_Right || sym == XK_Down || + sym == XK_KP_Down || sym == XK_Return || + sym == XK_KP_Enter) { + stepImageInFolder(1); + drawUI(); + } + } else if (mode == ViewMode::PropsView) { + if (sym == XK_Return || sym == XK_KP_Enter) { + mode = ViewMode::Browser; + drawUI(); + } + } else if (mode == ViewMode::InputModal) { + if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } else if (sym == XK_Right || sym == XK_KP_Right) { + lastTapDigit = -1; + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (d == '#') { + lastTapDigit = -1; + caseMode = static_cast( + (static_cast(caseMode) + 1) % 4); + drawUI(); + } else if (d == '*') { + commitModalTap(1); + drawUI(); + } else if (d >= '0' && d <= '9') { + commitModalTap(d - '0'); + drawUI(); + } + } + } else if (mode == ViewMode::ConfirmDelete) { + if (sym == XK_Up || sym == XK_Down || sym == XK_KP_Up || + sym == XK_KP_Down) { + confirmSelected = 1 - confirmSelected; + drawUI(); + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } + } + } + + int64_t tNow = nowMs(); + if (visible && softkeyPending != 0 && tNow >= softkeyDeadlineMs) { + if (querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + } else { + bool isLeft = (softkeyPending == 1); + softkeyPending = 0; + handleSoftkeyAction(isLeft); + } + } + + int timeoutMs = -1; + if (visible && softkeyPending != 0) { + timeoutMs = std::max(1, static_cast(softkeyDeadlineMs - tNow)); + } + + struct pollfd pfds[2]; + int nfds = 0; + pfds[nfds++] = {xfd, POLLIN, 0}; + int fifoIdx = -1; + if (fifoFd >= 0) { + fifoIdx = nfds; + pfds[nfds++] = {fifoFd, POLLIN, 0}; + } + + int pr = ::poll(pfds, nfds, timeoutMs); + if (pr > 0 && fifoIdx >= 0 && (pfds[fifoIdx].revents & POLLIN)) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + } + } + + hideWindow(); + if (xImgPreview) { + xImgPreview->data = nullptr; + XDestroyImage(xImgPreview); + } + if (fifoFd >= 0) ::close(fifoFd); + ::unlink(daemonPidPath.c_str()); + return 0; +} + +int files_plugin_init(const DwmHostApi* host) { + if (host && host->spawn_plugin) { + host->spawn_plugin("files", "--preload"); + } + return 0; +} + +} // namespace + +extern "C" __attribute__((visibility("default"))) const DwmPlugin dwm_plugin_entry = { + DWM_PLUGIN_API_VERSION, + "files", + "Keypad-driven graphical file manager with text/image preview", + files_plugin_init, + nullptr, + nullptr, + nullptr, + nullptr, + runFilesApp +}; diff --git a/src/dwm-plugins/media_utils.h b/src/dwm-plugins/media_utils.h new file mode 100644 index 0000000..c5ff5c2 --- /dev/null +++ b/src/dwm-plugins/media_utils.h @@ -0,0 +1,269 @@ +#ifndef DWM_PLUGINS_MEDIA_UTILS_H +#define DWM_PLUGINS_MEDIA_UTILS_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace bananarchy { + +struct JpegErrMgr { + struct jpeg_error_mgr pub; + jmp_buf setjmpBuf; +}; + +inline void jpegErrorExit(j_common_ptr cinfo) { + JpegErrMgr* myerr = reinterpret_cast(cinfo->err); + longjmp(myerr->setjmpBuf, 1); +} + +inline bool saveRgb24AsJpeg(const std::string& path, + const uint8_t* rgb, + int width, + int height, + int quality = 92) { + if (!rgb || width <= 0 || height <= 0) { + return false; + } + FILE* fp = std::fopen(path.c_str(), "wb"); + if (!fp) { + return false; + } + + struct jpeg_compress_struct cinfo {}; + JpegErrMgr jerr {}; + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = jpegErrorExit; + if (setjmp(jerr.setjmpBuf)) { + jpeg_destroy_compress(&cinfo); + std::fclose(fp); + return false; + } + + jpeg_create_compress(&cinfo); + jpeg_stdio_dest(&cinfo, fp); + cinfo.image_width = static_cast(width); + cinfo.image_height = static_cast(height); + cinfo.input_components = 3; + cinfo.in_color_space = JCS_RGB; + + jpeg_set_defaults(&cinfo); + jpeg_set_quality(&cinfo, quality, TRUE); + jpeg_start_compress(&cinfo, TRUE); + + int rowStride = width * 3; + while (cinfo.next_scanline < cinfo.image_height) { + JSAMPROW rowPtr = + const_cast(rgb + cinfo.next_scanline * rowStride); + jpeg_write_scanlines(&cinfo, &rowPtr, 1); + } + + jpeg_finish_compress(&cinfo); + jpeg_destroy_compress(&cinfo); + std::fclose(fp); + return true; +} + +inline bool loadJpegRgb24(const std::string& path, + std::vector* outRgb, + int* outW, + int* outH) { + FILE* fp = std::fopen(path.c_str(), "rb"); + if (!fp) { + return false; + } + + struct jpeg_decompress_struct cinfo {}; + JpegErrMgr jerr {}; + cinfo.err = jpeg_std_error(&jerr.pub); + jerr.pub.error_exit = jpegErrorExit; + if (setjmp(jerr.setjmpBuf)) { + jpeg_destroy_decompress(&cinfo); + std::fclose(fp); + return false; + } + + jpeg_create_decompress(&cinfo); + jpeg_stdio_src(&cinfo, fp); + if (jpeg_read_header(&cinfo, TRUE) != JPEG_HEADER_OK) { + jpeg_destroy_decompress(&cinfo); + std::fclose(fp); + return false; + } + + // Use libjpeg fast DCT downscaling for large images so decoding is instant + if (cinfo.image_width > 960 || cinfo.image_height > 960) { + cinfo.scale_num = 1; + cinfo.scale_denom = 2; + } + cinfo.out_color_space = JCS_RGB; + jpeg_start_decompress(&cinfo); + + int w = static_cast(cinfo.output_width); + int h = static_cast(cinfo.output_height); + if (w <= 0 || h <= 0 || w > 8192 || h > 8192) { + jpeg_destroy_decompress(&cinfo); + std::fclose(fp); + return false; + } + + outRgb->resize(static_cast(w) * h * 3); + int rowStride = w * 3; + while (cinfo.output_scanline < cinfo.output_height) { + JSAMPROW rowPtr = outRgb->data() + cinfo.output_scanline * rowStride; + jpeg_read_scanlines(&cinfo, &rowPtr, 1); + } + + jpeg_finish_decompress(&cinfo); + jpeg_destroy_decompress(&cinfo); + std::fclose(fp); + *outW = w; + *outH = h; + return true; +} + +inline bool loadPngRgb24(const std::string& path, + std::vector* outRgb, + int* outW, + int* outH) { + png_image image {}; + image.version = PNG_IMAGE_VERSION; + if (!png_image_begin_read_from_file(&image, path.c_str())) { + return false; + } + image.format = PNG_FORMAT_RGB; + int w = static_cast(image.width); + int h = static_cast(image.height); + if (w <= 0 || h <= 0 || w > 8192 || h > 8192) { + png_image_free(&image); + return false; + } + outRgb->resize(PNG_IMAGE_SIZE(image)); + if (!png_image_finish_read(&image, nullptr, outRgb->data(), 0, nullptr)) { + png_image_free(&image); + return false; + } + png_image_free(&image); + *outW = w; + *outH = h; + return true; +} + +inline bool loadImageRgb24(const std::string& path, + std::vector* outRgb, + int* outW, + int* outH) { + std::string low = path; + for (char& c : low) { + c = static_cast(std::tolower(static_cast(c))); + } + if (low.size() >= 4 && low.substr(low.size() - 4) == ".png") { + if (loadPngRgb24(path, outRgb, outW, outH)) { + return true; + } + } + if (loadJpegRgb24(path, outRgb, outW, outH)) { + return true; + } + if (loadPngRgb24(path, outRgb, outW, outH)) { + return true; + } + return false; +} + +inline void scaleRgb24ToBgrxCanvas(const uint8_t* srcRgb, + int srcW, + int srcH, + uint32_t* dstBgrx, + int canvasW, + int canvasH, + uint32_t bgPixel = 0x111111) { + for (int i = 0; i < canvasW * canvasH; ++i) { + dstBgrx[i] = bgPixel; + } + if (!srcRgb || srcW <= 0 || srcH <= 0 || canvasW <= 0 || canvasH <= 0) { + return; + } + + int drawW = canvasW; + int drawH = (srcH * canvasW) / srcW; + if (drawH > canvasH) { + drawH = canvasH; + drawW = (srcW * canvasH) / srcH; + } + drawW = std::max(1, drawW); + drawH = std::max(1, drawH); + + int offX = (canvasW - drawW) / 2; + int offY = (canvasH - drawH) / 2; + + for (int dy = 0; dy < drawH; ++dy) { + int sy = std::min(srcH - 1, (dy * srcH) / drawH); + const uint8_t* srow = srcRgb + static_cast(sy) * srcW * 3; + uint32_t* drow = dstBgrx + static_cast(offY + dy) * canvasW + offX; + for (int dx = 0; dx < drawW; ++dx) { + int sx = std::min(srcW - 1, (dx * srcW) / drawW); + const uint8_t* px = srow + sx * 3; + drow[dx] = (static_cast(px[0]) << 16) | + (static_cast(px[1]) << 8) | + static_cast(px[2]); + } + } +} + +inline void downscaleViewfinder800x600To240x180(const uint8_t* srcRgb, + uint32_t* dstBgrx, + int zoomFactor = 1) { + if (!srcRgb || !dstBgrx) { + return; + } + zoomFactor = std::clamp(zoomFactor, 1, 4); + int cropW = 800 / zoomFactor; + int cropH = 600 / zoomFactor; + int startX = (800 - cropW) / 2; + int startY = (600 - cropH) / 2; + + for (int dy = 0; dy < 180; ++dy) { + int sy = startY + (dy * cropH) / 180; + const uint8_t* srow = srcRgb + static_cast(sy) * 800 * 3; + uint32_t* drow = dstBgrx + static_cast(dy) * 240; + for (int dx = 0; dx < 240; ++dx) { + int sx = startX + (dx * cropW) / 240; + const uint8_t* px = srow + sx * 3; + drow[dx] = (static_cast(px[0]) << 16) | + (static_cast(px[1]) << 8) | + static_cast(px[2]); + } + } +} + +inline std::string formatFileSize(off_t bytes) { + char buf[32]; + if (bytes >= 1024LL * 1024LL * 1024LL) { + std::snprintf(buf, sizeof(buf), "%.1fG", + static_cast(bytes) / (1024.0 * 1024.0 * 1024.0)); + } else if (bytes >= 1024LL * 1024LL) { + std::snprintf(buf, sizeof(buf), "%.1fM", + static_cast(bytes) / (1024.0 * 1024.0)); + } else if (bytes >= 1024LL) { + std::snprintf(buf, sizeof(buf), "%.1fK", + static_cast(bytes) / 1024.0); + } else { + std::snprintf(buf, sizeof(buf), "%lldB", static_cast(bytes)); + } + return buf; +} + +} // namespace bananarchy + +#endif // DWM_PLUGINS_MEDIA_UTILS_H diff --git a/src/dwm-plugins/phone_data.h b/src/dwm-plugins/phone_data.h index 7ba8aed..e2b1505 100644 --- a/src/dwm-plugins/phone_data.h +++ b/src/dwm-plugins/phone_data.h @@ -16,6 +16,7 @@ #include #include #include +#include #include #include #include @@ -53,6 +54,21 @@ constexpr const char* kIconTrash = "\xef\x87\xb8"; // U+F1F8 delete/trash constexpr const char* kIconSms = "\xef\x83\xa0"; // U+F0E0 envelope/SMS constexpr const char* kIconList = "\xef\x80\xba"; // U+F03A list constexpr const char* kIconCheck = "\xef\x80\x8c"; // U+F00C check +constexpr const char* kIconFolder = "\xef\x81\xbb"; // U+F07B folder +constexpr const char* kIconFolderOpen = "\xef\x81\xbc"; // U+F07C folder-open +constexpr const char* kIconFile = "\xef\x80\x96"; // U+F016 file +constexpr const char* kIconFileText = "\xef\x85\x9c"; // U+F15C file-text +constexpr const char* kIconImage = "\xef\x80\xbe"; // U+F03E image +constexpr const char* kIconMusic = "\xef\x80\x81"; // U+F001 music/audio +constexpr const char* kIconTerm = "\xef\x84\xa0"; // U+F120 terminal +constexpr const char* kIconCamera = "\xef\x80\xb0"; // U+F030 camera +constexpr const char* kIconFlash = "\xef\x83\xa7"; // U+F0E7 bolt/flash +constexpr const char* kIconSend = "\xef\x87\x98"; // U+F1D8 paper-plane +constexpr const char* kIconCopy = "\xef\x83\x85"; // U+F0C5 copy +constexpr const char* kIconInfo = "\xef\x81\x9a"; // U+F05A info-circle +constexpr const char* kIconRefresh = "\xef\x80\xa1"; // U+F021 refresh +constexpr const char* kIconSort = "\xef\x83\x9c"; // U+F0DC sort +constexpr const char* kIconEye = "\xef\x81\xae"; // U+F06E eye/view inline int64_t nowMs() { struct timespec ts {}; @@ -249,7 +265,9 @@ inline void runAsyncCmd(const std::string& cmd) { } } -inline void runAsyncArgv(const char* prog, const char* arg1 = nullptr) { +inline void runAsyncArgv(const char* prog, + const char* arg1 = nullptr, + const char* arg2 = nullptr) { pid_t pid = ::fork(); if (pid == 0) { ::setsid(); @@ -262,7 +280,13 @@ inline void runAsyncArgv(const char* prog, const char* arg1 = nullptr) { ::close(nullfd); } } - if (arg1) { + if (arg1 && arg2) { + execlp(prog, prog, arg1, arg2, static_cast(nullptr)); + std::string localBin = std::string("/home/user/.local/bin/") + prog; + execl(localBin.c_str(), prog, arg1, arg2, static_cast(nullptr)); + std::string usrBin = std::string("/usr/bin/") + prog; + execl(usrBin.c_str(), prog, arg1, arg2, static_cast(nullptr)); + } else if (arg1) { execlp(prog, prog, arg1, static_cast(nullptr)); std::string localBin = std::string("/home/user/.local/bin/") + prog; execl(localBin.c_str(), prog, arg1, static_cast(nullptr)); @@ -896,6 +920,296 @@ inline void placePhoneCall(const std::string& rawNumber) { runAsyncArgv("sxmo_modemdial.sh", num.c_str()); } +inline void closeOtherPlugins(const char* selfPluginName) { + struct PluginRef { + const char* pidFile; + const char* name; + }; + static const PluginRef kAllPlugins[] = { + {"xdrawer.pid", "xdrawer"}, + {"xdialer.pid", "dialer"}, + {"xcalllog.pid", "calllog"}, + {"xcontacts.pid", "contacts"}, + {"xsms.pid", "sms"}, + {"xfiles.pid", "files"}, + {"xcamera.pid", "camera"}, + }; + for (const auto& p : kAllPlugins) { + if (selfPluginName && std::strcmp(p.name, selfPluginName) == 0) { + continue; + } + if (isPidFileAlive(getRuntimeFile(p.pidFile))) { + sendPluginCommand(p.name, "CLOSE"); + } + } +} + +inline std::string getModemLogDirPath() { + const char* env = std::getenv("SXMO_LOGDIR"); + if (env && env[0]) { + return env; + } + const char* xdg = std::getenv("XDG_DATA_HOME"); + if (xdg && xdg[0]) { + return std::string(xdg) + "/sxmo/modem"; + } + const char* home = std::getenv("HOME"); + return std::string(home && home[0] ? home : "/home/user") + + "/.local/share/sxmo/modem"; +} + +struct SmsMessage { + bool incoming = true; + std::string sender; + std::string isoTime; + std::string displayTime; + time_t epoch = 0; + std::string text; +}; + +struct SmsThread { + std::string number; + std::string normNumber; + std::string contactName; + std::string t9Name; + std::string lowerName; + std::string lastPreview; + std::string lastDisplayTime; + time_t lastEpoch = 0; + bool lastIncoming = true; + bool hasDraft = false; + int messageCount = 0; +}; + +inline bool parseSmsHeaderLine(const std::string& line, + bool* outIncoming, + std::string* outSender, + std::string* outIsoTime) { + if (line.empty() || line.back() != ':') { + return false; + } + if (line.rfind("Received at ", 0) == 0 && line.size() > 13) { + *outIncoming = true; + outSender->clear(); + *outIsoTime = line.substr(12, line.size() - 13); + return true; + } + if (line.rfind("Received from ", 0) == 0) { + size_t atPos = line.find(" at "); + if (atPos != std::string::npos && atPos + 4 < line.size() - 1) { + *outIncoming = true; + *outSender = line.substr(14, atPos - 14); + *outIsoTime = line.substr(atPos + 4, line.size() - (atPos + 4) - 1); + return true; + } + } + if (line.rfind("Sent at ", 0) == 0 && line.size() > 9) { + *outIncoming = false; + outSender->clear(); + *outIsoTime = line.substr(8, line.size() - 9); + return true; + } + return false; +} + +inline std::vector loadSmsThreadMessages(const std::string& number) { + std::vector msgs; + std::string path = getModemLogDirPath() + "/" + number + "/sms.txt"; + std::ifstream in(path); + if (!in.is_open()) { + return msgs; + } + + std::string line; + bool hasCur = false; + SmsMessage cur; + + auto flushCur = [&]() { + if (!hasCur) { + return; + } + while (!cur.text.empty() && + (cur.text.back() == '\n' || cur.text.back() == '\r')) { + cur.text.pop_back(); + } + msgs.push_back(cur); + cur = SmsMessage{}; + hasCur = false; + }; + + while (std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + bool inc = true; + std::string sender; + std::string iso; + if (parseSmsHeaderLine(line, &inc, &sender, &iso)) { + flushCur(); + hasCur = true; + cur.incoming = inc; + cur.sender = sender.empty() ? number : sender; + cur.isoTime = iso; + cur.displayTime = formatShortTime(iso); + cur.epoch = parseIsoTime(iso); + cur.text.clear(); + } else if (hasCur) { + if (!cur.text.empty()) { + cur.text.push_back('\n'); + } + cur.text += line; + } else if (!trimStr(line).empty()) { + hasCur = true; + cur.incoming = true; + cur.sender = number; + cur.text = line; + } + } + flushCur(); + return msgs; +} + +inline std::string loadSmsDraft(const std::string& number) { + if (number.empty()) { + return ""; + } + std::string path = getModemLogDirPath() + "/" + number + "/draft.txt"; + std::ifstream in(path); + if (!in.is_open()) { + return ""; + } + std::ostringstream ss; + ss << in.rdbuf(); + std::string s = ss.str(); + while (!s.empty() && (s.back() == '\n' || s.back() == '\r')) { + s.pop_back(); + } + if (s == "Enter message here") { + return ""; + } + return s; +} + +inline bool saveSmsDraft(const std::string& number, const std::string& text) { + if (number.empty()) { + return false; + } + std::string dir = getModemLogDirPath() + "/" + number; + std::string path = dir + "/draft.txt"; + if (trimStr(text).empty()) { + ::unlink(path.c_str()); + return true; + } + ContactBook::ensureParentDir(path); + std::ofstream out(path, std::ios::trunc); + if (!out.is_open()) { + return false; + } + out << text << "\n"; + return true; +} + +inline void removeSmsDraft(const std::string& number) { + if (number.empty()) { + return; + } + std::string path = getModemLogDirPath() + "/" + number + "/draft.txt"; + ::unlink(path.c_str()); +} + +inline bool deleteSmsThread(const std::string& number) { + if (number.empty()) { + return false; + } + std::string dir = getModemLogDirPath() + "/" + number; + std::string smsPath = dir + "/sms.txt"; + std::string draftPath = dir + "/draft.txt"; + ::unlink(smsPath.c_str()); + ::unlink(draftPath.c_str()); + ::rmdir(dir.c_str()); + return true; +} + +inline std::vector loadSmsThreads(const ContactBook& book) { + std::vector threads; + std::string baseDir = getModemLogDirPath(); + DIR* d = ::opendir(baseDir.c_str()); + if (!d) { + return threads; + } + + struct dirent* ent; + while ((ent = ::readdir(d)) != nullptr) { + std::string name = ent->d_name; + if (name.empty() || name[0] == '.') { + continue; + } + std::string subDir = baseDir + "/" + name; + struct stat st {}; + if (::stat(subDir.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { + continue; + } + std::string smsPath = subDir + "/sms.txt"; + std::string draftPath = subDir + "/draft.txt"; + bool hasSms = (::access(smsPath.c_str(), R_OK) == 0); + std::string draft = loadSmsDraft(name); + bool hasDraft = !draft.empty(); + if (!hasSms && !hasDraft) { + continue; + } + + auto msgs = loadSmsThreadMessages(name); + if (msgs.empty() && !hasDraft) { + continue; + } + + SmsThread th; + th.number = name; + th.normNumber = normalizePhone(name); + th.contactName = book.resolveName(name); + std::string label = th.contactName.empty() ? name : th.contactName; + th.t9Name = toT9Digits(label); + th.lowerName = toLowerStr(label); + th.hasDraft = hasDraft; + th.messageCount = static_cast(msgs.size()); + + if (!msgs.empty()) { + const auto& last = msgs.back(); + th.lastIncoming = last.incoming; + th.lastDisplayTime = last.displayTime; + th.lastEpoch = last.epoch; + std::string prev = last.text; + for (char& c : prev) { + if (c == '\n' || c == '\r' || c == '\t') { + c = ' '; + } + } + th.lastPreview = hasDraft ? ("[Draft] " + draft) : prev; + } else { + th.lastIncoming = false; + th.lastPreview = "[Draft] " + draft; + struct stat dst {}; + if (::stat(draftPath.c_str(), &dst) == 0) { + th.lastEpoch = dst.st_mtim.tv_sec; + } + } + if (th.lastEpoch == 0 && ::stat(smsPath.c_str(), &st) == 0) { + th.lastEpoch = st.st_mtim.tv_sec; + } + threads.push_back(th); + } + ::closedir(d); + + std::sort(threads.begin(), threads.end(), + [](const SmsThread& a, const SmsThread& b) { + if (a.lastEpoch != b.lastEpoch) { + return a.lastEpoch > b.lastEpoch; + } + return a.number < b.number; + }); + return threads; +} + } // namespace bananarchy #endif // DWM_PLUGINS_PHONE_DATA_H diff --git a/src/dwm-plugins/sms_plugin.cpp b/src/dwm-plugins/sms_plugin.cpp new file mode 100644 index 0000000..312bd0a --- /dev/null +++ b/src/dwm-plugins/sms_plugin.cpp @@ -0,0 +1,1468 @@ +#include "phone_data.h" +#include "../sxmo-dwm/dwm_plugin.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace bananarchy; + +volatile sig_atomic_t gRunning = 1; + +void handleSignal(int) { + gRunning = 0; +} + +enum class ViewMode { + Threads = 0, + ThreadView = 1, + Compose = 2, + PickContact = 3, + Options = 4, + ConfirmDelete = 5, +}; + +enum class TextCaseMode { + Title = 0, // Abc + Upper = 1, // ABC + Lower = 2, // abc + Num = 3, // 123 +}; + +const char* kMultiTapSms[10] = { + " 0\n", // 0 + ".,?!'-:;()@/1", // 1 + "abc2", // 2 + "def3", // 3 + "ghi4", // 4 + "jkl5", // 5 + "mno6", // 6 + "pqrs7", // 7 + "tuv8", // 8 + "wxyz9" // 9 +}; + +void popUtf8Char(std::string& s) { + if (s.empty()) { + return; + } + while (!s.empty() && (static_cast(s.back()) & 0xC0) == 0x80) { + s.pop_back(); + } + if (!s.empty()) { + s.pop_back(); + } +} + +int runSmsApp(int argc, char** argv) { + gRunning = 1; + std::setlocale(LC_ALL, ""); + XSetLocaleModifiers(""); + + bool isPreload = false; + std::string initArg; + for (int i = 1; i < argc; ++i) { + if (!argv[i]) continue; + if (std::strcmp(argv[i], "--preload") == 0) { + isPreload = true; + } else if (std::strcmp(argv[i], "sendtextmenu") == 0 || + std::strcmp(argv[i], "conversationloop") == 0) { + if (i + 1 < argc && argv[i + 1]) { + initArg = std::string("send:") + argv[++i]; + } else { + initArg = "send:"; + } + } else if (std::strcmp(argv[i], "tailtextlog") == 0) { + if (i + 1 < argc && argv[i + 1]) { + initArg = std::string("thread:") + argv[++i]; + } + } else if ( std::strcmp(argv[i], "readtextmenu") == 0 ) { + initArg.clear(); + } else if (initArg.empty()) { + initArg = argv[i]; + } + } + + std::string daemonPidPath = getPluginDaemonPidPath("sms"); + std::string activePidPath = getRuntimeFile("xsms.pid"); + + int existingDaemon = readIntFile(daemonPidPath, -1); + if (existingDaemon > 0 && existingDaemon != static_cast(::getpid()) && + ::kill(existingDaemon, 0) == 0) { + if (!isPreload) { + sendPluginCommand("sms", initArg.empty() ? "OPEN" : ("OPEN " + initArg)); + } + return 0; + } + + writeIntFile(daemonPidPath, static_cast(::getpid())); + int fifoFd = setupPluginDaemonFifo("sms"); + + int notifyPipe[2] = {-1, -1}; + if (::pipe(notifyPipe) == 0) { + ::fcntl(notifyPipe[0], F_SETFL, O_NONBLOCK | O_CLOEXEC); + ::fcntl(notifyPipe[1], F_SETFL, O_NONBLOCK | O_CLOEXEC); + } + + struct sigaction sa {}; + sa.sa_handler = handleSignal; + sigemptyset(&sa.sa_mask); + sigaction(SIGINT, &sa, nullptr); + sigaction(SIGTERM, &sa, nullptr); + + Display* dpy = XOpenDisplay(nullptr); + if (!dpy) { + if (fifoFd >= 0) ::close(fifoFd); + if (notifyPipe[0] >= 0) ::close(notifyPipe[0]); + if (notifyPipe[1] >= 0) ::close(notifyPipe[1]); + ::unlink(daemonPidPath.c_str()); + return 1; + } + + int screen = DefaultScreen(dpy); + Window root = RootWindow(dpy, screen); + Visual* visual = DefaultVisual(dpy, screen); + Colormap cmap = DefaultColormap(dpy, screen); + + XSetWindowAttributes swa {}; + swa.override_redirect = True; + swa.background_pixel = 0x222222; + swa.border_pixel = 0x005577; + swa.event_mask = ExposureMask | KeyPressMask | KeyReleaseMask | + VisibilityChangeMask | StructureNotifyMask; + + Window win = XCreateWindow( + dpy, root, kWinX, kWinY, kWinWidth, kWinHeight, 0, + DefaultDepth(dpy, screen), CopyFromParent, visual, + CWOverrideRedirect | CWBackPixel | CWBorderPixel | CWEventMask, &swa); + + XClassHint ch {}; + char resName[] = "xsms"; + char resClass[] = "XSms"; + ch.res_name = resName; + ch.res_class = resClass; + XSetClassHint(dpy, win, &ch); + XStoreName(dpy, win, "Messages"); + + Pixmap pix = XCreatePixmap(dpy, win, kWinWidth, kWinHeight, + DefaultDepth(dpy, screen)); + GC gc = XCreateGC(dpy, pix, 0, nullptr); + XftDraw* xftDraw = XftDrawCreate(dpy, pix, visual, cmap); + + PangoFontMap* fontMap = pango_xft_get_font_map(dpy, screen); + PangoContext* pangoCtx = pango_font_map_create_context(fontMap); + + PangoLayout* layoutMain = pango_layout_new(pangoCtx); + PangoFontDescription* descMain = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 15px"); + pango_layout_set_font_description(layoutMain, descMain); + + PangoLayout* layoutSmall = pango_layout_new(pangoCtx); + PangoFontDescription* descSmall = + pango_font_description_from_string("Fira Code, DejaVuSansM Nerd Font 12px"); + pango_layout_set_font_description(layoutSmall, descSmall); + + PangoLayout* layoutIcon = pango_layout_new(pangoCtx); + PangoFontDescription* descIcon = + pango_font_description_from_string("DejaVuSansM Nerd Font, Fira Code 16px"); + pango_layout_set_font_description(layoutIcon, descIcon); + + auto allocColor = [&](const char* hex) { + XftColor col {}; + XftColorAllocName(dpy, visual, cmap, hex, &col); + return col; + }; + + XftColor colWhite = allocColor("#ffffff"); + XftColor colLight = allocColor("#dddddd"); + XftColor colMuted = allocColor("#999999"); + XftColor colYellow = allocColor("#f4d03f"); + XftColor colGreen = allocColor("#2ecc71"); + XftColor colBlue = allocColor("#5dade2"); + XftColor colRed = allocColor("#e74c3c"); + + ContactBook book; + std::vector allThreads; + std::vector filteredThreads; + std::vector threadMessages; + std::vector pickerContacts; + + FileStamp contactsStamp; + FileStamp modemLogStamp; + bool loadedOnce = false; + + ViewMode mode = ViewMode::Threads; + ViewMode prevMode = ViewMode::Threads; + std::string searchQuery; + int selectedThread = 0; + int selectedMsg = 0; + int selectedOption = 0; + int selectedPicker = 0; + int confirmSelected = 0; + + std::string activeNumber; + std::string composeRecipient; + std::string composeText; + int composeField = 1; // 0 = To, 1 = Message body, 2 = Send button, 3 = Save Draft + TextCaseMode caseMode = TextCaseMode::Title; + int lastTapDigit = -1; + int lastTapCycle = 0; + int64_t lastTapMs = 0; + bool zeroHeld = false; + int64_t zeroPressMs = 0; + + std::mutex sendMutex; + bool sendingInProgress = false; + std::string statusBanner; + int64_t statusBannerUntilMs = 0; + + bool visible = false; + int64_t openStartMs = 0; + int64_t startupIgnoreUntilMs = 0; + bool initialSoftkeysReleased = true; + int softkeyPending = 0; + int64_t softkeyDeadlineMs = 0; + + struct OptionEntry { + int id; + std::string icon; + std::string label; + }; + std::vector optionsList; + + auto updateFilter = [&]() { + filteredThreads.clear(); + if (searchQuery.empty()) { + filteredThreads = allThreads; + } else { + std::string qNorm = normalizePhone(searchQuery); + std::string qLower = toLowerStr(searchQuery); + for (const auto& t : allThreads) { + bool match = + (!qLower.empty() && t.lowerName.find(qLower) != std::string::npos) || + (!qNorm.empty() && t.t9Name.find(qNorm) != std::string::npos) || + (!qNorm.empty() && t.normNumber.find(qNorm) != std::string::npos) || + (!qLower.empty() && + toLowerStr(t.lastPreview).find(qLower) != std::string::npos); + if (match) { + filteredThreads.push_back(t); + } + } + } + if (selectedThread >= static_cast(filteredThreads.size())) { + selectedThread = + std::max(0, static_cast(filteredThreads.size()) - 1); + } + }; + + auto reloadAllData = [&](bool force = false) { + bool cChanged = contactsStamp.checkAndUpdate(getContactsFilePath()); + bool lChanged = modemLogStamp.checkAndUpdate(getModemLogFilePath()); + if (force || cChanged || lChanged || !loadedOnce) { + loadedOnce = true; + book.load(); + allThreads = loadSmsThreads(book); + updateFilter(); + if (!activeNumber.empty()) { + threadMessages = loadSmsThreadMessages(activeNumber); + if (selectedMsg >= static_cast(threadMessages.size())) { + selectedMsg = + std::max(0, static_cast(threadMessages.size()) - 1); + } + } + } + }; + + reloadAllData(true); + + auto openThreadView = [&](const std::string& num) { + activeNumber = trimStr(num); + threadMessages = loadSmsThreadMessages(activeNumber); + selectedMsg = std::max(0, static_cast(threadMessages.size()) - 1); + mode = ViewMode::ThreadView; + }; + + auto openComposeView = [&](const std::string& num, const std::string& prefill = "") { + prevMode = mode; + composeRecipient = trimStr(num); + if (!prefill.empty()) { + composeText = prefill; + } else if (!composeRecipient.empty()) { + composeText = loadSmsDraft(composeRecipient); + } else { + composeText.clear(); + } + composeField = composeRecipient.empty() ? 0 : 1; + lastTapDigit = -1; + mode = ViewMode::Compose; + }; + + auto buildOptions = [&]() { + optionsList.clear(); + if (mode == ViewMode::Threads) { + optionsList.push_back({1, kIconPlus, "New Message"}); + if (!filteredThreads.empty()) { + optionsList.push_back({2, kIconEye, "Open Conversation"}); + optionsList.push_back({3, kIconSend, "Reply to Selected"}); + optionsList.push_back({4, kIconPhone, "Call Contact"}); + optionsList.push_back({5, kIconUser, "Open in Contacts"}); + optionsList.push_back({6, kIconTrash, "Delete Conversation"}); + } + } else if (mode == ViewMode::ThreadView) { + optionsList.push_back({3, kIconSend, "Reply / Compose"}); + optionsList.push_back({4, kIconPhone, "Call Contact"}); + if (!threadMessages.empty()) { + optionsList.push_back({7, kIconCopy, "Forward Selected Msg"}); + } + optionsList.push_back({5, kIconUser, "Save / View Contact"}); + optionsList.push_back({6, kIconTrash, "Delete Conversation"}); + } + selectedOption = 0; + }; + + auto startAsyncSendSms = [&](const std::string& rawRecipient, + const std::string& body) { + std::string recipient = normalizePhone(rawRecipient); + if (recipient.empty() || trimStr(body).empty()) { + statusBanner = "Set recipient and message"; + statusBannerUntilMs = nowMs() + 2000; + return; + } + { + std::lock_guard lk(sendMutex); + if (sendingInProgress) { + return; + } + sendingInProgress = true; + } + saveSmsDraft(recipient, body); + statusBanner = "Sending SMS to " + recipient + "..."; + statusBannerUntilMs = nowMs() + 15000; + + int writePipe = notifyPipe[1]; + std::thread([recipient, body, writePipe, &sendMutex, &sendingInProgress, + &statusBanner, &statusBannerUntilMs]() { + std::string draftPath = + getModemLogDirPath() + "/" + recipient + "/draft.txt"; + ContactBook::ensureParentDir(draftPath); + { + std::ofstream out(draftPath, std::ios::trunc); + out << body << "\n"; + } + + int rc = -1; + pid_t pid = ::fork(); + if (pid == 0) { + int nullfd = ::open("/dev/null", O_RDWR); + if (nullfd >= 0) { + ::dup2(nullfd, STDIN_FILENO); + ::dup2(nullfd, STDOUT_FILENO); + ::dup2(nullfd, STDERR_FILENO); + if (nullfd > 2) ::close(nullfd); + } + execlp("sxmo_modemsendsms.sh", "sxmo_modemsendsms.sh", + recipient.c_str(), "-f", draftPath.c_str(), + static_cast(nullptr)); + execl("/usr/bin/sxmo_modemsendsms.sh", "sxmo_modemsendsms.sh", + recipient.c_str(), "-f", draftPath.c_str(), + static_cast(nullptr)); + _exit(127); + } else if (pid > 0) { + int status = 0; + ::waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + rc = WEXITSTATUS(status); + } + } + + // If modem send failed (e.g. offline/no SIM), still log locally if user + // wants local record or keep draft so nothing is lost + if (rc == 0) { + removeSmsDraft(recipient); + } else { + // Record in local thread log as Sent (or Draft) so conversation is visible + time_t nowSec = std::time(nullptr); + struct tm tmNow {}; + localtime_r(&nowSec, &tmNow); + char tbuf[32]; + std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%dT%H:%M:%S%z", &tmNow); + std::string smsPath = + getModemLogDirPath() + "/" + recipient + "/sms.txt"; + ContactBook::ensureParentDir(smsPath); + std::ofstream smsOut(smsPath, std::ios::app); + if (smsOut.is_open()) { + smsOut << "Sent at " << tbuf << ":\n" << body << "\n\n"; + } + std::ofstream logOut(getModemLogFilePath(), std::ios::app); + if (logOut.is_open()) { + logOut << tbuf << "\tsent_txt\t" << recipient << "\t" + << body.size() << " chars\n"; + } + removeSmsDraft(recipient); + } + + { + std::lock_guard lk(sendMutex); + sendingInProgress = false; + statusBanner = (rc == 0) ? "SMS Sent!" : "Saved to Sent (modem offline)"; + statusBannerUntilMs = nowMs() + 2200; + } + if (writePipe >= 0) { + char c = 'S'; + (void)::write(writePipe, &c, 1); + } + }).detach(); + }; + + auto drawPango = [&](PangoLayout* layout, XftColor* color, int x, int y, int h, + const std::string& text, int maxW = -1) { + pango_layout_set_text(layout, text.c_str(), static_cast(text.size())); + if (maxW > 0) { + pango_layout_set_width(layout, maxW * PANGO_SCALE); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_END); + } else { + pango_layout_set_width(layout, -1); + pango_layout_set_ellipsize(layout, PANGO_ELLIPSIZE_NONE); + } + int tw = 0, th = 0; + pango_layout_get_pixel_size(layout, &tw, &th); + int dy = y + std::max(0, (h - th) / 2); + pango_xft_render_layout(xftDraw, color, layout, x * PANGO_SCALE, + dy * PANGO_SCALE); + return tw; + }; + + auto drawSoftkeyBar = [&](const std::string& leftLabel, + const std::string& centerLabel, + const std::string& rightLabel) { + const int barY = kWinHeight - 26; + XSetForeground(dpy, gc, 0x141414); + XFillRectangle(dpy, pix, gc, 0, barY, kWinWidth, 26); + XSetForeground(dpy, gc, 0x2d2d2d); + XDrawLine(dpy, pix, gc, 0, barY, kWinWidth, barY); + + drawPango(layoutMain, &colBlue, 6, barY, 26, leftLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, centerLabel.c_str(), + static_cast(centerLabel.size())); + int cw = 0, chh = 0; + pango_layout_get_pixel_size(layoutMain, &cw, &chh); + drawPango(layoutMain, &colWhite, (kWinWidth - cw) / 2, barY, 26, + centerLabel); + + pango_layout_set_width(layoutMain, -1); + pango_layout_set_text(layoutMain, rightLabel.c_str(), + static_cast(rightLabel.size())); + int rw = 0, rhh = 0; + pango_layout_get_pixel_size(layoutMain, &rw, &rhh); + drawPango(layoutMain, &colLight, kWinWidth - rw - 6, barY, 26, rightLabel); + }; + + auto drawUI = [&]() { + if (!visible) return; + + XSetForeground(dpy, gc, 0x222222); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kWinHeight); + + if (mode == ViewMode::Threads) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 38); + XSetForeground(dpy, gc, 0x333333); + XDrawLine(dpy, pix, gc, 0, 37, kWinWidth, 37); + + if (searchQuery.empty()) { + drawPango(layoutIcon, &colYellow, 8, 2, 34, kIconSms); + drawPango(layoutMain, &colWhite, 30, 2, 34, "Messages", 145); + std::string cnt = "[" + std::to_string(allThreads.size()) + "]"; + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, cnt.c_str(), + static_cast(cnt.size())); + int cw = 0, chh = 0; + pango_layout_get_pixel_size(layoutSmall, &cw, &chh); + drawPango(layoutSmall, &colMuted, kWinWidth - cw - 8, 2, 34, cnt); + } else { + drawPango(layoutIcon, &colBlue, 8, 2, 34, kIconSearch); + drawPango(layoutMain, &colYellow, 30, 2, 34, searchQuery, 155); + std::string cnt = std::to_string(filteredThreads.size()); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, cnt.c_str(), + static_cast(cnt.size())); + int cw = 0, chh = 0; + pango_layout_get_pixel_size(layoutSmall, &cw, &chh); + drawPango(layoutSmall, &colMuted, kWinWidth - cw - 8, 2, 34, cnt); + } + + const int listY = 38; + const int rowH = 38; + const int visibleRows = 6; + + if (filteredThreads.empty()) { + drawPango(layoutMain, &colMuted, 16, 110, 24, + searchQuery.empty() ? "No messages yet" : "No matches", 208); + drawPango(layoutSmall, &colBlue, 16, 138, 20, + "Press Left Softkey for New SMS", 208); + } else { + int scroll = 0; + if (selectedThread >= visibleRows) { + scroll = selectedThread - visibleRows + 1; + } + for (int i = 0; i < visibleRows; ++i) { + int idx = scroll + i; + if (idx >= static_cast(filteredThreads.size())) break; + const auto& th = filteredThreads[idx]; + int ry = listY + i * rowH; + bool isSel = (idx == selectedThread); + + if (isSel) { + XSetForeground(dpy, gc, 0x005577); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + XSetForeground(dpy, gc, 0x0088bb); + XDrawLine(dpy, pix, gc, 0, ry, kWinWidth, ry); + } else { + XSetForeground(dpy, gc, (i % 2 == 0) ? 0x222222 : 0x1d1d1d); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + } + + const char* ic = th.hasDraft + ? kIconEdit + : (th.lastIncoming ? kIconInCall : kIconOutCall); + XftColor* icCol = th.hasDraft + ? &colYellow + : (th.lastIncoming ? &colGreen : &colBlue); + drawPango(layoutIcon, icCol, 6, ry + 1, 20, ic); + + std::string title = + th.contactName.empty() ? th.number : th.contactName; + drawPango(layoutMain, &colWhite, 26, ry + 1, 20, title, 128); + + if (!th.lastDisplayTime.empty()) { + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, th.lastDisplayTime.c_str(), + static_cast(th.lastDisplayTime.size())); + int tw = 0, thh = 0; + pango_layout_get_pixel_size(layoutSmall, &tw, &thh); + drawPango(layoutSmall, isSel ? &colLight : &colMuted, + kWinWidth - tw - 6, ry + 2, 18, th.lastDisplayTime); + } + + drawPango(layoutSmall, isSel ? &colLight : &colMuted, 26, ry + 20, 16, + th.lastPreview, 206); + } + } + + drawSoftkeyBar("Options", + filteredThreads.empty() ? "New SMS" : "Open", + searchQuery.empty() ? "Close" : "Clear"); + + } else if (mode == ViewMode::ThreadView) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + XSetForeground(dpy, gc, 0x333333); + XDrawLine(dpy, pix, gc, 0, 35, kWinWidth, 35); + + std::string cname = book.resolveName(activeNumber); + std::string title = cname.empty() ? activeNumber : cname; + drawPango(layoutIcon, &colYellow, 6, 1, 18, kIconSms); + drawPango(layoutMain, &colWhite, 26, 0, 20, title, 160); + std::string sub = cname.empty() + ? (std::to_string(threadMessages.size()) + " msgs") + : activeNumber; + drawPango(layoutSmall, &colMuted, 26, 19, 15, sub, 204); + + if (threadMessages.empty()) { + drawPango(layoutMain, &colMuted, 16, 110, 24, "No messages in thread", + 208); + drawPango(layoutSmall, &colBlue, 16, 138, 20, + "Press OK to write a message", 208); + } else { + // Measure and render message cards from bottom up or around selectedMsg + const int areaTop = 38; + const int areaBot = kWinHeight - 28; + const int areaH = areaBot - areaTop; + + struct CardGeom { + int idx; + int h; + std::string header; + std::string body; + bool incoming; + }; + std::vector cards; + cards.reserve(threadMessages.size()); + + for (size_t i = 0; i < threadMessages.size(); ++i) { + const auto& m = threadMessages[i]; + CardGeom cg; + cg.idx = static_cast(i); + cg.incoming = m.incoming; + cg.header = (m.incoming ? "Recv " : "Sent ") + m.displayTime; + cg.body = m.text; + + pango_layout_set_width(layoutSmall, 204 * PANGO_SCALE); + pango_layout_set_wrap(layoutSmall, PANGO_WRAP_WORD_CHAR); + pango_layout_set_ellipsize(layoutSmall, PANGO_ELLIPSIZE_NONE); + pango_layout_set_text(layoutSmall, cg.body.c_str(), + static_cast(cg.body.size())); + int bw = 0, bh = 0; + pango_layout_get_pixel_size(layoutSmall, &bw, &bh); + cg.h = std::max(38, bh + 22); + cards.push_back(cg); + } + + // Determine visible slice ending at selectedMsg (or covering selectedMsg) + int endIdx = std::clamp(selectedMsg, 0, + static_cast(cards.size()) - 1); + int startIdx = endIdx; + int usedH = cards[endIdx].h; + while (startIdx > 0 && usedH + cards[startIdx - 1].h + 4 <= areaH) { + --startIdx; + usedH += cards[startIdx].h + 4; + } + while (endIdx + 1 < static_cast(cards.size()) && + usedH + cards[endIdx + 1].h + 4 <= areaH) { + ++endIdx; + usedH += cards[endIdx].h + 4; + } + + int curY = areaTop + 2; + for (int i = startIdx; i <= endIdx; ++i) { + const auto& cg = cards[i]; + bool isSel = (cg.idx == selectedMsg); + int cardX = cg.incoming ? 4 : 14; + int cardW = kWinWidth - 18; + int cardH = std::min(cg.h, areaH - 4); + + unsigned long bg = isSel + ? 0x004466 + : (cg.incoming ? 0x262d32 : 0x1f2d38); + XSetForeground(dpy, gc, bg); + XFillRectangle(dpy, pix, gc, cardX, curY, cardW, cardH); + + unsigned long accent = cg.incoming ? 0x2ecc71 : 0x5dade2; + XSetForeground(dpy, gc, isSel ? 0xf4d03f : accent); + if (cg.incoming) { + XFillRectangle(dpy, pix, gc, cardX, curY, 3, cardH); + } else { + XFillRectangle(dpy, pix, gc, cardX + cardW - 3, curY, 3, cardH); + } + + drawPango(layoutSmall, cg.incoming ? &colGreen : &colBlue, + cardX + 8, curY + 2, 14, cg.header, cardW - 14); + + pango_layout_set_width(layoutSmall, (cardW - 14) * PANGO_SCALE); + pango_layout_set_wrap(layoutSmall, PANGO_WRAP_WORD_CHAR); + pango_layout_set_ellipsize(layoutSmall, PANGO_ELLIPSIZE_NONE); + pango_layout_set_text(layoutSmall, cg.body.c_str(), + static_cast(cg.body.size())); + pango_xft_render_layout(xftDraw, &colWhite, layoutSmall, + (cardX + 8) * PANGO_SCALE, + (curY + 18) * PANGO_SCALE); + + curY += cardH + 4; + } + } + + drawSoftkeyBar("Options", "Reply", "Back"); + + } else if (mode == ViewMode::Compose) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 32); + XSetForeground(dpy, gc, 0x333333); + XDrawLine(dpy, pix, gc, 0, 31, kWinWidth, 31); + + drawPango(layoutIcon, &colBlue, 6, 0, 30, kIconSend); + drawPango(layoutMain, &colWhite, 26, 0, 30, "Compose", 100); + + const char* modeBadge = "[Abc]"; + if (composeField == 0) { + modeBadge = "[123]"; + } else if (caseMode == TextCaseMode::Upper) { + modeBadge = "[ABC]"; + } else if (caseMode == TextCaseMode::Lower) { + modeBadge = "[abc]"; + } else if (caseMode == TextCaseMode::Num) { + modeBadge = "[123]"; + } + int segs = std::max(1, static_cast(composeText.size() + 159) / 160); + std::string rightHdr = std::string(modeBadge) + " " + + std::to_string(composeText.size()) + "/" + + std::to_string(segs * 160); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, rightHdr.c_str(), + static_cast(rightHdr.size())); + int rw = 0, rhh = 0; + pango_layout_get_pixel_size(layoutSmall, &rw, &rhh); + drawPango(layoutSmall, &colYellow, kWinWidth - rw - 6, 0, 30, rightHdr); + + // Field 0: Recipient + bool toSel = (composeField == 0); + XSetForeground(dpy, gc, toSel ? 0x004466 : 0x1a1a1a); + XFillRectangle(dpy, pix, gc, 6, 36, kWinWidth - 12, 32); + XSetForeground(dpy, gc, toSel ? 0x0088bb : 0x383838); + XDrawRectangle(dpy, pix, gc, 6, 36, kWinWidth - 13, 31); + + std::string cname = book.resolveName(composeRecipient); + std::string toDisp = "To: " + composeRecipient; + if (toSel) toDisp += "|"; + if (!cname.empty()) { + toDisp += " (" + cname + ")"; + } + drawPango(layoutMain, toSel ? &colWhite : &colLight, 10, 36, 32, toDisp, + kWinWidth - 20); + + // Field 1: Message Body + bool msgSel = (composeField == 1); + XSetForeground(dpy, gc, msgSel ? 0x182630 : 0x181818); + XFillRectangle(dpy, pix, gc, 6, 72, kWinWidth - 12, 156); + XSetForeground(dpy, gc, msgSel ? 0x0088bb : 0x383838); + XDrawRectangle(dpy, pix, gc, 6, 72, kWinWidth - 13, 155); + + std::string bodyDisp = composeText; + if (msgSel) bodyDisp += "|"; + if (bodyDisp.empty()) { + drawPango(layoutSmall, &colMuted, 12, 78, 20, + "Type message (0-9, # case)...", kWinWidth - 24); + } else { + pango_layout_set_width(layoutMain, (kWinWidth - 24) * PANGO_SCALE); + pango_layout_set_wrap(layoutMain, PANGO_WRAP_WORD_CHAR); + pango_layout_set_ellipsize(layoutMain, PANGO_ELLIPSIZE_NONE); + pango_layout_set_text(layoutMain, bodyDisp.c_str(), + static_cast(bodyDisp.size())); + int tw = 0, th = 0; + pango_layout_get_pixel_size(layoutMain, &tw, &th); + int textY = 76; + if (th > 146) { + textY = 76 - (th - 146); + } + XRectangle clipRect {6, 73, static_cast(kWinWidth - 12), + 154}; + XftDrawSetClipRectangles(xftDraw, 0, 0, &clipRect, 1); + pango_xft_render_layout(xftDraw, &colWhite, layoutMain, + 12 * PANGO_SCALE, textY * PANGO_SCALE); + XftDrawSetClip(xftDraw, nullptr); + } + + // Field 2 & 3: Send & Save Draft buttons + bool sendSel = (composeField == 2); + bool draftSel = (composeField == 3); + XSetForeground(dpy, gc, sendSel ? 0x005577 : 0x262626); + XFillRectangle(dpy, pix, gc, 6, 234, 118, 30); + XSetForeground(dpy, gc, sendSel ? 0x0088bb : 0x444444); + XDrawRectangle(dpy, pix, gc, 6, 234, 117, 29); + drawPango(layoutMain, &colGreen, 18, 234, 30, "Send SMS", 100); + + XSetForeground(dpy, gc, draftSel ? 0x005577 : 0x262626); + XFillRectangle(dpy, pix, gc, 130, 234, 104, 30); + XSetForeground(dpy, gc, draftSel ? 0x0088bb : 0x444444); + XDrawRectangle(dpy, pix, gc, 130, 234, 103, 29); + drawPango(layoutMain, &colYellow, 146, 234, 30, "Draft", 84); + + if (composeField == 0) { + drawSoftkeyBar("Contacts", "Next", + composeRecipient.empty() ? "Cancel" : "Clear"); + } else { + drawSoftkeyBar("Send", "Send", + composeText.empty() ? "Cancel" : "Del"); + } + + } else if (mode == ViewMode::PickContact) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34); + drawPango(layoutIcon, &colBlue, 8, 0, 34, kIconBook); + drawPango(layoutMain, &colWhite, 30, 0, 34, "Select Recipient", 200); + + const int listY = 36; + const int rowH = 38; + const int visibleRows = 6; + if (pickerContacts.empty()) { + drawPango(layoutMain, &colMuted, 16, 110, 24, "No saved contacts", 208); + } else { + int scroll = 0; + if (selectedPicker >= visibleRows) { + scroll = selectedPicker - visibleRows + 1; + } + for (int i = 0; i < visibleRows; ++i) { + int idx = scroll + i; + if (idx >= static_cast(pickerContacts.size())) break; + const auto& c = pickerContacts[idx]; + int ry = listY + i * rowH; + bool isSel = (idx == selectedPicker); + XSetForeground(dpy, gc, isSel ? 0x005577 : 0x222222); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + drawPango(layoutIcon, c.isRecent ? &colYellow : &colBlue, 6, ry + 1, + 20, c.isRecent ? kIconClock : kIconUser); + drawPango(layoutMain, &colWhite, 28, ry + 1, 20, c.name, 204); + drawPango(layoutSmall, isSel ? &colLight : &colMuted, 28, ry + 20, 16, + c.number, 204); + } + } + drawSoftkeyBar("Select", "Select", "Back"); + + } else if (mode == ViewMode::Options) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 34); + drawPango(layoutIcon, &colBlue, 8, 0, 34, kIconList); + drawPango(layoutMain, &colWhite, 30, 0, 34, "Message Options", 200); + + const int listY = 38; + const int rowH = 36; + for (size_t i = 0; i < optionsList.size(); ++i) { + int ry = listY + static_cast(i) * rowH; + bool isSel = (static_cast(i) == selectedOption); + XSetForeground(dpy, gc, isSel ? 0x005577 : 0x222222); + XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH - 1); + drawPango(layoutIcon, isSel ? &colYellow : &colBlue, 10, ry, rowH, + optionsList[i].icon); + drawPango(layoutMain, &colWhite, 34, ry, rowH, optionsList[i].label, + 198); + } + drawSoftkeyBar("Select", "Select", "Back"); + + } else if (mode == ViewMode::ConfirmDelete) { + XSetForeground(dpy, gc, 0x161616); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 36); + drawPango(layoutIcon, &colRed, 8, 0, 36, kIconTrash); + drawPango(layoutMain, &colWhite, 30, 0, 36, "Delete Thread?", 200); + + drawPango(layoutMain, &colWhite, 14, 70, 26, activeNumber, 212); + drawPango(layoutSmall, &colMuted, 14, 98, 22, + "All SMS history for this number", 212); + drawPango(layoutSmall, &colMuted, 14, 118, 22, "will be removed.", 212); + + for (int i = 0; i < 2; ++i) { + int ry = 164 + i * 40; + bool isSel = (confirmSelected == i); + XSetForeground(dpy, gc, isSel ? (i == 1 ? 0x922b21 : 0x005577) : 0x2a2a2a); + XFillRectangle(dpy, pix, gc, 16, ry, kWinWidth - 32, 34); + drawPango(layoutMain, &colWhite, 28, ry, 34, + i == 0 ? "No, Keep Thread" : "Yes, Delete Thread", 184); + } + drawSoftkeyBar("Select", "Confirm", "Cancel"); + } + + int64_t tNow = nowMs(); + { + std::lock_guard lk(sendMutex); + if (!statusBanner.empty() && tNow < statusBannerUntilMs) { + XSetForeground(dpy, gc, 0x102535); + XFillRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 16, 28); + XSetForeground(dpy, gc, 0x5dade2); + XDrawRectangle(dpy, pix, gc, 8, kWinHeight - 58, kWinWidth - 17, 27); + drawPango(layoutSmall, &colWhite, 14, kWinHeight - 58, 28, statusBanner, + kWinWidth - 28); + } + } + + XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0); + XFlush(dpy); + }; + + auto showWindow = [&](const std::string& argStr) { + closeOtherPlugins("sms"); + reloadAllData(true); + lastTapDigit = -1; + zeroHeld = false; + softkeyPending = 0; + + if (argStr.rfind("send:", 0) == 0) { + openComposeView(argStr.substr(5)); + } else if (argStr.rfind("thread:", 0) == 0) { + openThreadView(argStr.substr(7)); + } else if (!argStr.empty()) { + openThreadView(argStr); + } else { + mode = ViewMode::Threads; + searchQuery.clear(); + updateFilter(); + selectedThread = 0; + } + + writeIntFile(activePidPath, static_cast(::getpid())); + if (!visible) { + visible = true; + XMapRaised(dpy, win); + for (int i = 0; i < 20; ++i) { + if (XGrabKeyboard(dpy, win, True, GrabModeAsync, GrabModeAsync, + CurrentTime) == GrabSuccess) { + break; + } + usleep(2000); + } + } else { + XRaiseWindow(dpy, win); + } + XSetInputFocus(dpy, win, RevertToPointerRoot, CurrentTime); + openStartMs = nowMs(); + startupIgnoreUntilMs = openStartMs + 180; + initialSoftkeysReleased = (querySoftkeysDown(dpy) == 0); + drawUI(); + }; + + auto hideWindow = [&]() { + if (!visible) return; + if (mode == ViewMode::Compose && !composeRecipient.empty()) { + saveSmsDraft(normalizePhone(composeRecipient), composeText); + } + writeSoftkeySuppress(nowMs() + 450); + XUngrabKeyboard(dpy, CurrentTime); + XUnmapWindow(dpy, win); + XFlush(dpy); + visible = false; + ::unlink(activePidPath.c_str()); + }; + + auto commitMultiTapChar = [&](int digit) { + const char* chars = kMultiTapSms[digit]; + int len = static_cast(std::strlen(chars)); + if (len <= 0) return; + + int64_t t = nowMs(); + if (caseMode == TextCaseMode::Num) { + composeText.push_back(static_cast('0' + digit)); + lastTapDigit = -1; + return; + } + + bool sameKey = (lastTapDigit == digit && (t - lastTapMs) <= 850 && + !composeText.empty()); + if (sameKey) { + lastTapCycle = (lastTapCycle + 1) % len; + popUtf8Char(composeText); + } else { + lastTapDigit = digit; + lastTapCycle = 0; + } + lastTapMs = t; + + char ch = chars[lastTapCycle]; + if (std::isalpha(static_cast(ch))) { + if (caseMode == TextCaseMode::Upper) { + ch = static_cast(std::toupper(static_cast(ch))); + } else if (caseMode == TextCaseMode::Title) { + bool startOfWord = composeText.empty() || + composeText.back() == ' ' || + composeText.back() == '\n' || + composeText.back() == '.'; + if (startOfWord) { + ch = static_cast(std::toupper(static_cast(ch))); + } + } + } + composeText.push_back(ch); + }; + + auto executeOption = [&](int optId) { + if (optId == 1) { + openComposeView(""); + } else if (optId == 2 && !filteredThreads.empty()) { + openThreadView(filteredThreads[selectedThread].number); + } else if (optId == 3) { + std::string target = (prevMode == ViewMode::ThreadView) + ? activeNumber + : (!filteredThreads.empty() + ? filteredThreads[selectedThread].number + : ""); + openComposeView(target); + } else if (optId == 4) { + std::string target = (prevMode == ViewMode::ThreadView) + ? activeNumber + : (!filteredThreads.empty() + ? filteredThreads[selectedThread].number + : ""); + if (!target.empty()) { + hideWindow(); + placePhoneCall(target); + } + } else if (optId == 5) { + std::string target = (prevMode == ViewMode::ThreadView) + ? activeNumber + : (!filteredThreads.empty() + ? filteredThreads[selectedThread].number + : ""); + hideWindow(); + if (!target.empty() && book.resolveName(target).empty()) { + sendPluginCommand("contacts", "OPEN new:" + target); + } else { + sendPluginCommand("contacts", "OPEN " + target); + } + } else if (optId == 6) { + activeNumber = (prevMode == ViewMode::ThreadView) + ? activeNumber + : (!filteredThreads.empty() + ? filteredThreads[selectedThread].number + : ""); + if (!activeNumber.empty()) { + confirmSelected = 0; + mode = ViewMode::ConfirmDelete; + } else { + mode = prevMode; + } + } else if (optId == 7 && !threadMessages.empty()) { + std::string body = threadMessages[selectedMsg].text; + openComposeView("", body); + } + }; + + auto handleSoftkeyAction = [&](bool isLeft) { + if (mode == ViewMode::Threads) { + if (isLeft) { + prevMode = ViewMode::Threads; + buildOptions(); + mode = ViewMode::Options; + } else { + if (!searchQuery.empty()) { + popUtf8Char(searchQuery); + updateFilter(); + } else { + hideWindow(); + return; + } + } + } else if (mode == ViewMode::ThreadView) { + if (isLeft) { + prevMode = ViewMode::ThreadView; + buildOptions(); + mode = ViewMode::Options; + } else { + reloadAllData(true); + mode = ViewMode::Threads; + } + } else if (mode == ViewMode::Compose) { + if (isLeft) { + if (composeField == 0) { + pickerContacts = book.buildDialerList(); + selectedPicker = 0; + mode = ViewMode::PickContact; + } else { + startAsyncSendSms(composeRecipient, composeText); + } + } else { + if (composeField == 0 && !composeRecipient.empty()) { + popUtf8Char(composeRecipient); + } else if (composeField == 1 && !composeText.empty()) { + popUtf8Char(composeText); + lastTapDigit = -1; + } else { + if (!composeRecipient.empty()) { + saveSmsDraft(normalizePhone(composeRecipient), composeText); + } + reloadAllData(true); + mode = (prevMode == ViewMode::ThreadView) ? ViewMode::ThreadView + : ViewMode::Threads; + } + } + } else if (mode == ViewMode::PickContact) { + if (isLeft) { + if (!pickerContacts.empty()) { + composeRecipient = pickerContacts[selectedPicker].number; + composeField = 1; + } + mode = ViewMode::Compose; + } else { + mode = ViewMode::Compose; + } + } else if (mode == ViewMode::Options) { + if (isLeft) { + if (!optionsList.empty()) { + executeOption(optionsList[selectedOption].id); + } + } else { + mode = prevMode; + } + } else if (mode == ViewMode::ConfirmDelete) { + if (isLeft) { + if (confirmSelected == 1) { + deleteSmsThread(activeNumber); + reloadAllData(true); + mode = ViewMode::Threads; + } else { + mode = prevMode; + } + } else { + mode = prevMode; + } + } + drawUI(); + }; + + if (!isPreload) { + showWindow(initArg); + } + + int xfd = ConnectionNumber(dpy); + while (gRunning) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "NOP") { + continue; + } else if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + + while (XPending(dpy) > 0) { + XEvent ev; + XNextEvent(dpy, &ev); + if (!visible) continue; + + if (ev.type == Expose && ev.xexpose.count == 0) { + drawUI(); + } else if (ev.type == KeyRelease) { + unsigned int kc = ev.xkey.keycode; + KeySym sym = XLookupKeysym(&ev.xkey, 0); + if (kc == 19 || sym == XK_0 || sym == XK_KP_0) { + zeroHeld = false; + } + } else if (ev.type == KeyPress) { + int64_t tNow = nowMs(); + unsigned int kc = ev.xkey.keycode; + KeySym sym = XLookupKeysym(&ev.xkey, 0); + + bool isLeftSk = + (kc == 135 || sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || + sym == _EVDEVK(0x246) || sym == XK_F1); + bool isRightSk = + (kc == 166 || sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 || + sym == _EVDEVK(0x247) || sym == XK_F2); + + if (!initialSoftkeysReleased) { + if (querySoftkeysDown(dpy) == 0) { + initialSoftkeysReleased = true; + } else if (isLeftSk || isRightSk) { + continue; + } + } + if (tNow < startupIgnoreUntilMs && (isLeftSk || isRightSk)) { + continue; + } + + if (isLeftSk || isRightSk) { + int bit = isLeftSk ? 1 : 2; + softkeyPending |= bit; + if (softkeyPending == 3 || querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + continue; + } + softkeyDeadlineMs = tNow + 35; + continue; + } + + if (sym == XK_Escape || kc == 124 || sym == XF86XK_PowerOff) { + hideWindow(); + continue; + } + + bool isCallKey = + (kc == 177 || sym == XF86XK_PickupPhone || sym == XF86XK_Phone || + sym == _EVDEVK(0x1bd) || sym == _EVDEVK(0x0a9)); + if (isCallKey) { + if (mode == ViewMode::Compose) { + startAsyncSendSms(composeRecipient, composeText); + drawUI(); + } else if (mode == ViewMode::ThreadView && !activeNumber.empty()) { + hideWindow(); + placePhoneCall(activeNumber); + } else if (mode == ViewMode::Threads && !filteredThreads.empty()) { + std::string num = filteredThreads[selectedThread].number; + hideWindow(); + placePhoneCall(num); + } + continue; + } + + if (sym == XK_BackSpace || sym == XK_Delete) { + handleSoftkeyAction(false); + continue; + } + + if (mode == ViewMode::Threads) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (!filteredThreads.empty()) { + selectedThread = + (selectedThread - 1 + static_cast(filteredThreads.size())) % + static_cast(filteredThreads.size()); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (!filteredThreads.empty()) { + selectedThread = + (selectedThread + 1) % static_cast(filteredThreads.size()); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + if (filteredThreads.empty()) { + openComposeView(""); + } else { + openThreadView(filteredThreads[selectedThread].number); + } + drawUI(); + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (d >= '0' && d <= '9') { + searchQuery.push_back(d); + updateFilter(); + drawUI(); + } else if (d == '*') { + openComposeView(""); + drawUI(); + } + } + } else if (mode == ViewMode::ThreadView) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (selectedMsg > 0) { + --selectedMsg; + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (selectedMsg + 1 < static_cast(threadMessages.size())) { + ++selectedMsg; + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + openComposeView(activeNumber); + drawUI(); + } + } else if (mode == ViewMode::Compose) { + if (sym == XK_Up || sym == XK_KP_Up) { + lastTapDigit = -1; + composeField = std::max(0, composeField - 1); + drawUI(); + } else if (sym == XK_Down || sym == XK_KP_Down) { + lastTapDigit = -1; + composeField = std::min(3, composeField + 1); + drawUI(); + } else if (sym == XK_Left || sym == XK_KP_Left) { + if (composeField == 3) { + composeField = 2; + drawUI(); + } else { + lastTapDigit = -1; + } + } else if (sym == XK_Right || sym == XK_KP_Right) { + if (composeField == 2) { + composeField = 3; + drawUI(); + } else if (composeField == 0 && composeRecipient.empty()) { + composeRecipient = "+"; + drawUI(); + } else { + lastTapDigit = -1; + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + if (composeField == 0) { + if (composeRecipient.empty()) { + pickerContacts = book.buildDialerList(); + selectedPicker = 0; + mode = ViewMode::PickContact; + } else { + composeField = 1; + } + } else if (composeField == 1 || composeField == 2) { + startAsyncSendSms(composeRecipient, composeText); + } else if (composeField == 3) { + saveSmsDraft(normalizePhone(composeRecipient), composeText); + statusBanner = "Draft saved"; + statusBannerUntilMs = nowMs() + 1800; + reloadAllData(true); + mode = (prevMode == ViewMode::ThreadView) ? ViewMode::ThreadView + : ViewMode::Threads; + } + drawUI(); + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (composeField == 0) { + if (d == '0') { + zeroHeld = true; + zeroPressMs = tNow; + composeRecipient.push_back('0'); + drawUI(); + } else if ((d >= '1' && d <= '9') || d == '+') { + composeRecipient.push_back(d); + drawUI(); + } + } else if (composeField == 1) { + if (d == '#') { + lastTapDigit = -1; + caseMode = static_cast( + (static_cast(caseMode) + 1) % 4); + drawUI(); + } else if (d == '*') { + commitMultiTapChar(1); + drawUI(); + } else if (d >= '0' && d <= '9') { + commitMultiTapChar(d - '0'); + drawUI(); + } else if (sym == XK_space) { + composeText.push_back(' '); + lastTapDigit = -1; + drawUI(); + } + } + } + } else if (mode == ViewMode::PickContact) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (!pickerContacts.empty()) { + selectedPicker = + (selectedPicker - 1 + static_cast(pickerContacts.size())) % + static_cast(pickerContacts.size()); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (!pickerContacts.empty()) { + selectedPicker = + (selectedPicker + 1) % static_cast(pickerContacts.size()); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } else if (mode == ViewMode::Options) { + if (sym == XK_Up || sym == XK_KP_Up) { + if (!optionsList.empty()) { + selectedOption = + (selectedOption - 1 + static_cast(optionsList.size())) % + static_cast(optionsList.size()); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + if (!optionsList.empty()) { + selectedOption = + (selectedOption + 1) % static_cast(optionsList.size()); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } else if (mode == ViewMode::ConfirmDelete) { + if (sym == XK_Up || sym == XK_Down || sym == XK_KP_Up || + sym == XK_KP_Down) { + confirmSelected = 1 - confirmSelected; + drawUI(); + } else if (sym == XK_Return || sym == XK_KP_Enter) { + handleSoftkeyAction(true); + } + } + } + } + + int64_t tNow = nowMs(); + if (visible && softkeyPending != 0 && tNow >= softkeyDeadlineMs) { + if (querySoftkeysDown(dpy) == 3) { + softkeyPending = 0; + sendPluginCommand("xdrawer", "TOGGLE"); + } else { + bool isLeft = (softkeyPending == 1); + softkeyPending = 0; + handleSoftkeyAction(isLeft); + } + } + + if (visible && zeroHeld && mode == ViewMode::Compose && composeField == 0 && + (tNow - zeroPressMs) >= 420) { + if (isKeycodeDown(dpy, 19)) { + if (!composeRecipient.empty() && composeRecipient.back() == '0') { + composeRecipient.back() = '+'; + drawUI(); + } + } + zeroHeld = false; + } + + int timeoutMs = -1; + if (visible) { + timeoutMs = 250; + if (softkeyPending != 0) { + timeoutMs = std::min(timeoutMs, + std::max(1, static_cast(softkeyDeadlineMs - tNow))); + } + if (zeroHeld) { + timeoutMs = std::min( + timeoutMs, + std::max(1, static_cast((zeroPressMs + 420) - tNow))); + } + } + + struct pollfd pfds[3]; + int nfds = 0; + pfds[nfds++] = {xfd, POLLIN, 0}; + int fifoIdx = -1; + if (fifoFd >= 0) { + fifoIdx = nfds; + pfds[nfds++] = {fifoFd, POLLIN, 0}; + } + int pipeIdx = -1; + if (notifyPipe[0] >= 0) { + pipeIdx = nfds; + pfds[nfds++] = {notifyPipe[0], POLLIN, 0}; + } + + int pr = ::poll(pfds, nfds, timeoutMs); + if (pr > 0) { + if (fifoIdx >= 0 && (pfds[fifoIdx].revents & POLLIN)) { + for (const auto& cmd : readFifoLines(fifoFd)) { + if (cmd == "CLOSE") { + hideWindow(); + } else if (cmd == "OPEN") { + showWindow(""); + } else if (cmd.rfind("OPEN ", 0) == 0) { + showWindow(trimStr(cmd.substr(5))); + } + } + } + if (pipeIdx >= 0 && (pfds[pipeIdx].revents & POLLIN)) { + char drain[32]; + while (::read(notifyPipe[0], drain, sizeof(drain)) > 0) { + } + std::string sentTo = normalizePhone(composeRecipient); + reloadAllData(true); + if (!sentTo.empty()) { + openThreadView(sentTo); + } + drawUI(); + } + } else if (pr == 0 && visible) { + reloadAllData(false); + } + } + + hideWindow(); + if (fifoFd >= 0) ::close(fifoFd); + if (notifyPipe[0] >= 0) ::close(notifyPipe[0]); + if (notifyPipe[1] >= 0) ::close(notifyPipe[1]); + ::unlink(daemonPidPath.c_str()); + return 0; +} + +int sms_plugin_init(const DwmHostApi* host) { + if (host && host->spawn_plugin) { + host->spawn_plugin("sms", "--preload"); + } + return 0; +} + +} // namespace + +extern "C" __attribute__((visibility("default"))) const DwmPlugin dwm_plugin_entry = { + DWM_PLUGIN_API_VERSION, + "sms", + "Keypad-driven SMS conversation manager and editor integrated with Sxmo", + sms_plugin_init, + nullptr, + nullptr, + nullptr, + nullptr, + runSmsApp +}; diff --git a/src/sxmo-dwm/config.def.h b/src/sxmo-dwm/config.def.h index cd21599..38c3ed6 100644 --- a/src/sxmo-dwm/config.def.h +++ b/src/sxmo-dwm/config.def.h @@ -103,7 +103,7 @@ static Key keys[] = { /* Quick Navigation */ { KeyPress, MODKEY, XK_p, spawn, SHCMD("sxmo_appmenu.sh") }, - { KeyPress, MODKEY, XK_d, spawn, SHCMD("sxmo_files.sh") }, + { KeyPress, MODKEY, XK_d, spawn, SHCMD("xfiles") }, { KeyPress, MODKEY|ShiftMask, XK_Return, spawn, SHCMD("st") }, { KeyPress, MODKEY, XK_b, togglebar, {0} }, { KeyPress, MODKEY, XK_j, focusstack, {.i = +1 } }, diff --git a/src/sxmo-dwm/config.h b/src/sxmo-dwm/config.h index cd21599..38c3ed6 100644 --- a/src/sxmo-dwm/config.h +++ b/src/sxmo-dwm/config.h @@ -103,7 +103,7 @@ static Key keys[] = { /* Quick Navigation */ { KeyPress, MODKEY, XK_p, spawn, SHCMD("sxmo_appmenu.sh") }, - { KeyPress, MODKEY, XK_d, spawn, SHCMD("sxmo_files.sh") }, + { KeyPress, MODKEY, XK_d, spawn, SHCMD("xfiles") }, { KeyPress, MODKEY|ShiftMask, XK_Return, spawn, SHCMD("st") }, { KeyPress, MODKEY, XK_b, togglebar, {0} }, { KeyPress, MODKEY, XK_j, focusstack, {.i = +1 } }, diff --git a/src/xdrawer/xdrawer.cpp b/src/xdrawer/xdrawer.cpp index 5dc6bc6..e4db2b1 100644 --- a/src/xdrawer/xdrawer.cpp +++ b/src/xdrawer/xdrawer.cpp @@ -704,6 +704,9 @@ static int xdrawer_run_main(int argc, char** argv) { closeOtherPlugin("xdialer.pid", "/tmp/dwm_plugin_dialer.fifo"); closeOtherPlugin("xcalllog.pid", "/tmp/dwm_plugin_calllog.fifo"); closeOtherPlugin("xcontacts.pid", "/tmp/dwm_plugin_contacts.fifo"); + closeOtherPlugin("xsms.pid", "/tmp/dwm_plugin_sms.fifo"); + closeOtherPlugin("xfiles.pid", "/tmp/dwm_plugin_files.fifo"); + closeOtherPlugin("xcamera.pid", "/tmp/dwm_plugin_camera.fifo"); openStartMs = nowMs(); startupIgnoreUntilMs = openStartMs + 180; initialKeysReleased = (querySoftkeysDown(dpy) == 0); @@ -795,11 +798,6 @@ static int xdrawer_run_main(int argc, char** argv) { if (ev.xexpose.count == 0) { render(); } - } else if (ev.type == VisibilityNotify) { - if (ev.xvisibility.state != VisibilityUnobscured) { - XRaiseWindow(dpy, win); - XFlush(dpy); - } } else if (ev.type == KeyRelease) { KeySym sym = XLookupKeysym(&ev.xkey, 0); if (sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 ||