diff --git a/CHANGELOG.md b/CHANGELOG.md index e9f8ab3..8193d1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,10 @@ Release versions follow the `
` format (where `2026` is `a`, - Readjustable keypad controls persisted in `~/.config/sxmo/gambatte.cfg` with defaults `B = 8`, `Turbo B = 5`, `A = 9`, `Turbo A = 6`, `Directions = D-Pad`, `Start = 2`, `Select = 3`, `Palette = *`, and `Fast Forward = 0`. - 10 built-in DMG color palettes, 4 screen scaling modes (`Fit 240x216` 1.5x area-interpolated, `1:1 160x144`, `Fill 240x246`, `Full 240x296`), 10 save-state slots (`.st0`–`.st9`), automatic battery SRAM/RTC persistence, seamless `Xdrawer` focus handoff, and direct low-latency `libasound` (`hw:0,0`) audio with hardware volume synchronization and analog-stage low-pass filtering. - Preinstalled open-source / public-domain GB and GBC games in `/usr/share/games/gb/` and `~/Games/` (`2048.gb`, `adjustris.gb`, `blastah.gb`, `brickster.gbc`, `geometrix.gbc`, `ucity.gbc`). -- **Preinstalled scripting and calculation tools**: +- **Preinstalled scripting, calculation tools, and File Manager (`xfiles`) integration**: - Added **Lua** (`lua5.5` and `lua5.4` with `/usr/bin/lua` and `/usr/bin/luac`), **GNU `bc` / `dc`**, and **Yabasic (`yabasic`)** to the default system image. + - Selecting any `.yab` file in **File Manager (`xfiles`)** (or choosing **Run in Yabasic (Terminal)** in its Actions menu) runs `yabasic` inside the `st` terminal and pauses on exit so output can be inspected. + - Added an **integrated GUI Text Editor (`Edit in Text Editor`)** inside `xfiles`, available as an action on any file (and automatically opened when creating a **New File** or pressing **Edit** in the text viewer), featuring line numbers, 2D cursor navigation, 5 multi-tap input modes (`[abc]`, `[Abc]`, `[ABC]`, `[123]`, `[Sym]`), live candidate bar, and an **Editor Menu** (`Save File`, `Save & Close`, `Insert New Line`, `Insert Space`, `Duplicate Line`, `Delete Line`, `Close Without Saving`). --- diff --git a/MANUAL.md b/MANUAL.md index 9cefbb9..22199dc 100644 --- a/MANUAL.md +++ b/MANUAL.md @@ -169,10 +169,15 @@ All fifteen phone applications are compiled as shared-object plugins (`/usr/lib/ 8. **File manager (`files.so` / `xfiles`)**: - **Open**: select **Files** from the System menu or press `Mod4+d`. - **Controls and viewers**: navigate directories with `Up`/`Down`/`Left`/`Right`/`Return`, filter with `0`–`9`, toggle dotfiles with `*`, and cycle sort order (**Name** / **Date** / **Size**) with `#`. + - **Integrated GUI Text Editor (`Edit in Text Editor`)**: available as an action in the **Left softkey (`Actions`)** menu for **any non-directory file** (as well as via **Left softkey (`Edit`)** from the built-in text viewer, or automatically after **New File**): + - **Navigation**: `Up`/`Down`/`Left`/`Right` moves the cursor across lines and UTF-8 characters with automatic vertical and horizontal viewport scrolling and line numbers (`Ln:Col`). + - **Multi-tap keypad text entry**: type with `0`–`9` and `*` (with a live bottom candidate bar), press `#` to cycle input modes (`[abc]` -> `[Abc]` -> `[ABC]` -> `[123]` -> `[Sym]`), press **Center `OK` (`Return`)** to split the line / insert a newline, and press **Right softkey (`Del`)** or `BackSpace` to delete the character before the cursor (or join with the previous line at column `0`). + - **Editor Menu (`Left softkey`)**: opens the **Editor Menu** with **Save File** (press **Left softkey** twice to save immediately), **Save & Close**, **Insert New Line**, **Insert Space**, **Duplicate Line**, **Delete Line**, and **Close Without Saving**. + - **Yabasic (`.yab`) script execution**: pressing `Return` on any `.yab` file (or selecting **Run in Yabasic (Terminal)** from the **Left softkey (`Actions`)** menu) launches `yabasic` inside `st` in the file's directory and waits for `Enter` after completion so output can be inspected. - **Game Boy / Game Boy Color ROM launching**: pressing `Return` on any `.gb` or `.gbc` file (or selecting **Play in Game Boy Emulator** from the **Left softkey (`Actions`)** menu) opens it directly in the **Gambatte (`gambatte`)** emulator. - **Image preview and wallpaper installation**: pressing `Return` on any image file (`.jpg`, `.png`, `.bmp`, `.gif`, `.webp`, `.ppm`, `.tif`, `.ico`, `.svg`) opens the built-in image viewer (`*` toggles full-screen preview; **Left softkey (`Set Wall`)** installs the image into `~/Pictures/Wallpapers/`, applies it via `feh`, and refreshes Conky). You can also select **Preview Image** or **Set as Wallpaper** directly from the **Left softkey (`Actions`)** menu. - **Audio playback and ringtone installation**: pressing `Return` on any audio or tracker module file opens it immediately in **Music (`xmusic`)**. From the **Left softkey (`Actions`)** menu on any audio/tracker file, select **Play in Music Player** to play it in `xmusic`, or **Set as Ringtone** to copy it to `~/Ringtones/` and set it as the active phone ringtone. - - **File management actions**: the **Left softkey (`Actions`)** menu also provides **View Text**, **Edit in Nano**, **New Folder**, **New File**, **Rename**, **Copy**, **Cut / Move**, **Paste Here**, **Properties**, **Open Terminal Here**, and **Delete**. + - **File management actions**: the **Left softkey (`Actions`)** menu also provides **Edit in Text Editor**, **View Text**, **Edit in nano**, **New Folder**, **New File**, **Rename**, **Copy**, **Cut / Move**, **Paste Here**, **Properties**, **Open Terminal Here**, and **Delete**. 9. **Camera (`camera.so` / `xcamera`)**: - **Open**: select **Camera** from the System menu or run `xcamera`. diff --git a/README.md b/README.md index 370e93b..01c6290 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ Instead of spawning slow shell scripts for everyday phone tasks, Bananarchy runs - **Contacts (`xcontacts`) and SMS manager (`xsms`)**: full phonebook editor with direct Qualcomm QMI SIM card contact import and export across SIM 1 and SIM 2, paired with a threaded SMS conversation view, multi-tap message composer, and built-in anti-spam blocklist / `Spam Folder` (`~/.local/share/sxmo/block`) with one-key thread blocking and restoration. - **Modem and Dual-SIM manager (`xmodem`)**: 4-tab cellular control center (`SIMs`, `Radio`, `AT`, `Log`) supporting physical SIM 1 / SIM 2 active slot switching, SIM PIN unlock and PUK unblock wizards, 2G/3G/4G radio mode selection, interactive USSD sessions, and a live AT command console. - **Networks and Bluetooth manager (`xnetworks`)**: 3-tab connectivity manager for Wi-Fi networks, WPA2 password entry, Wi-Fi hotspot, USB tethering, 4G/LTE mobile data (`wwan0`) and APN editing, and Bluetooth pairing. -- **File manager (`xfiles`), music and tracker player (`xmusic`), and camera (`xcamera`)**: keypad directory browser with built-in text and image viewers (plus one-click actions to launch `.gb`/`.gbc` ROMs in the Game Boy emulator, play audio, preview images, set any image as wallpaper, or install any audio or tracker file as ringtone), a 3-tab music and chiptune tracker module player (`WAV`, `MP3`, `OGG`, `FLAC`, `Opus`, `MOD`, `XM`, `IT`, `S3M`, and more), and a live V4L2 camera viewfinder with digital zoom, flash/torch control, self-timer, and photo gallery. +- **File manager (`xfiles`), music and tracker player (`xmusic`), and camera (`xcamera`)**: keypad directory browser with built-in text viewer, integrated GUI multi-tap text editor (**Edit in Text Editor**, available as an action on any file), and image viewer (plus one-click actions to launch `.gb`/`.gbc` ROMs in the Game Boy emulator, run `.yab` scripts in Yabasic inside the terminal, play audio, preview images, set any image as wallpaper, or install any audio or tracker file as ringtone), a 3-tab music and chiptune tracker module player (`WAV`, `MP3`, `OGG`, `FLAC`, `Opus`, `MOD`, `XM`, `IT`, `S3M`, and more), and a live V4L2 camera viewfinder with digital zoom, flash/torch control, self-timer, and photo gallery. - **Themes (`xthemes`), wallpapers (`xwallpapers`), and ringtones (`xringtones`)**: installable `.Xresources` color and font themes (`~/.xthemes/*.xth`, 20 preinstalled schemes) with live zero-restart reload across the entire UI and terminal, alongside dedicated managers for desktop wallpapers and call ringtones (supporting both standard audio and tracker modules) with 3-level vibration control. ### Input, terminal, and developer tools diff --git a/overlay/rootfs/usr/lib/dwm/plugins/files.so b/overlay/rootfs/usr/lib/dwm/plugins/files.so index 7cf957f..cea126b 100755 Binary files a/overlay/rootfs/usr/lib/dwm/plugins/files.so and b/overlay/rootfs/usr/lib/dwm/plugins/files.so differ diff --git a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so index 7cf957f..cea126b 100755 Binary files a/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so and b/prebuilt/sxmo-x11/usr/lib/dwm/plugins/files.so differ diff --git a/src/dwm-plugins/files_plugin.cpp b/src/dwm-plugins/files_plugin.cpp index c35f12c..5410cda 100644 --- a/src/dwm-plugins/files_plugin.cpp +++ b/src/dwm-plugins/files_plugin.cpp @@ -29,6 +29,7 @@ enum class ViewMode { PropsView = 4, InputModal = 5, ConfirmDelete = 6, + TextEdit = 7, }; enum class SortMode { @@ -48,6 +49,7 @@ enum class TextCaseMode { Title = 1, // Abc Upper = 2, // ABC Num = 3, // 123 + Sym = 4, // Sym }; const char* kMultiTapFile[10] = { @@ -63,6 +65,34 @@ const char* kMultiTapFile[10] = { "wxyz9" // 9 }; +const char* kMultiTapEditorAlpha[11] = { + " 0", // 0 + ".,:;!?-_1/\"'()", // 1 + "abc2", // 2 + "def3", // 3 + "ghi4", // 4 + "jkl5", // 5 + "mno6", // 6 + "pqrs7", // 7 + "tuv8", // 8 + "wxyz9", // 9 + ".,!?+-*/=<>()[]{}\"'_#$%&|\\~^@" // 10 (*) +}; + +const char* kMultiTapEditorSym[11] = { + " _0", // 0 + ".,:;!?1", // 1 + "\"'`~@2", // 2 + "()[]{}<>3", // 3 + "+-*/=%4", // 4 + "|&^#$5", // 5 + "_\\/$@6", // 6 + "=<>!~7", // 7 + "();,\"8", // 8 + "{}[]#9", // 9 + "+-*/=<>()[]{}\"'.,:;!?_|&^#$%~@\\" // 10 (*) +}; + struct FileItem { std::string name; std::string fullPath; @@ -84,6 +114,48 @@ void popUtf8(std::string& s) { if (!s.empty()) s.pop_back(); } +size_t prevUtf8Pos(const std::string& s, size_t pos) { + if (pos == 0) return 0; + if (pos > s.size()) pos = s.size(); + --pos; + while (pos > 0 && (static_cast(s[pos]) & 0xC0) == 0x80) { + --pos; + } + return pos; +} + +size_t nextUtf8Pos(const std::string& s, size_t pos) { + if (pos >= s.size()) return s.size(); + ++pos; + while (pos < s.size() && (static_cast(s[pos]) & 0xC0) == 0x80) { + ++pos; + } + return pos; +} + +size_t clampUtf8Pos(const std::string& s, size_t pos) { + if (pos >= s.size()) return s.size(); + while (pos > 0 && (static_cast(s[pos]) & 0xC0) == 0x80) { + --pos; + } + return pos; +} + +std::string expandTabsForDisplay(const std::string& s) { + std::string out; + out.reserve(s.size()); + for (char c : s) { + if (c == '\t') { + out += " "; + } else if (static_cast(c) < 0x20) { + out.push_back('?'); + } else { + out.push_back(c); + } + } + return out; +} + std::string getExtLower(const std::string& name) { size_t dot = name.rfind('.'); if (dot == std::string::npos || dot + 1 >= name.size()) { @@ -122,6 +194,10 @@ bool isGameBoyExt(const std::string& ext) { return ext == "gb" || ext == "gbc"; } +bool isYabasicExt(const std::string& ext) { + return ext == "yab"; +} + bool isLikelyTextFile(const std::string& path, off_t size) { if (size == 0) return true; if (size > 256 * 1024) return false; @@ -323,6 +399,21 @@ int runFilesApp(int argc, char** argv) { std::vector textLines; int textScrollLine = 0; + // Integrated GUI text editor state + std::string editFilePath; + std::string editFileName; + std::vector editLines = {""}; + int editCursorRow = 0; + size_t editCursorCol = 0; + int editScrollRow = 0; + bool editModified = false; + bool editMenuOpen = false; + int editMenuSel = 0; + TextCaseMode editCaseMode = TextCaseMode::Lower; + int editLastTapDigit = -1; + int editLastTapCycle = 0; + int64_t editLastTapMs = 0; + // Image viewer state constexpr int kImgCanvasW = 240; constexpr int kImgCanvasH = 232; @@ -505,6 +596,86 @@ int runFilesApp(int argc, char** argv) { mode = ViewMode::TextView; }; + constexpr int kEditVisibleRows = 10; + + auto syncEditorScroll = [&]() { + if (editLines.empty()) { + editLines.push_back(""); + } + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + editCursorCol = + clampUtf8Pos(editLines[editCursorRow], editCursorCol); + if (editCursorRow < editScrollRow) { + editScrollRow = editCursorRow; + } else if (editCursorRow >= editScrollRow + kEditVisibleRows) { + editScrollRow = editCursorRow - kEditVisibleRows + 1; + } + int maxScroll = + std::max(0, static_cast(editLines.size()) - kEditVisibleRows); + editScrollRow = std::clamp(editScrollRow, 0, maxScroll); + }; + + auto openTextEditor = [&](const std::string& fullPath, + const std::string& fileName) { + editFilePath = fullPath; + editFileName = fileName; + editLines.clear(); + editCursorRow = 0; + editCursorCol = 0; + editScrollRow = 0; + editModified = false; + editMenuOpen = false; + editMenuSel = 0; + editLastTapDigit = -1; + + std::ifstream in(fullPath, std::ios::binary); + if (in.is_open()) { + std::string line; + int maxLines = 4000; + while (maxLines-- > 0 && std::getline(in, line)) { + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + for (char& c : line) { + if (c == '\0') c = ' '; + } + editLines.push_back(line); + } + } + if (editLines.empty()) { + editLines.push_back(""); + } + syncEditorScroll(); + mode = ViewMode::TextEdit; + }; + + auto saveEditedFile = [&]() -> bool { + std::ofstream out(editFilePath, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + statusBanner = "Save failed (read-only)"; + statusBannerUntilMs = nowMs() + 2000; + return false; + } + if (!(editLines.size() == 1 && editLines[0].empty())) { + for (size_t i = 0; i < editLines.size(); ++i) { + out.write(editLines[i].data(), + static_cast(editLines[i].size())); + out.put('\n'); + } + } + out.flush(); + if (!out.good()) { + statusBanner = "Write error"; + statusBannerUntilMs = nowMs() + 2000; + return false; + } + editModified = false; + statusBanner = "Saved: " + editFileName; + statusBannerUntilMs = nowMs() + 1800; + return true; + }; + auto openImageViewer = [&](const FileItem& item) { viewingFilePath = item.fullPath; viewingFileName = item.name; @@ -618,9 +789,13 @@ int runFilesApp(int argc, char** argv) { actionsList.push_back({15, kIconBell, "Set as Ringtone"}); } else if (isGameBoyExt(ext)) { actionsList.push_back({1, kIconGamepad, "Play in Game Boy Emulator"}); + } else if (isYabasicExt(ext)) { + actionsList.push_back({1, kIconTerm, "Run in Yabasic (Terminal)"}); + actionsList.push_back({17, kIconEye, "View Text"}); } else { actionsList.push_back({1, kIconEye, "Open / View"}); } + actionsList.push_back({16, kIconEdit, "Edit in Text Editor"}); } else { actionsList.push_back({1, kIconFolderOpen, "Open Folder"}); } @@ -673,6 +848,14 @@ int runFilesApp(int argc, char** argv) { } }; + auto runYabasicInTerminal = [&](const std::string& path) { + std::string d = currentDir; + hideWindow(); + runAsyncCmd("cd " + shellQuote(d) + + " && exec st -e sh -c 'yabasic " + shellQuote(path) + + "; rc=$?; printf \"\\n[Yabasic exited (%d) - press Enter]\" \"$rc\"; read -r _'"); + }; + auto openSelectedItem = [&]() { if (filteredItems.empty()) return; const FileItem item = filteredItems[selected]; @@ -699,6 +882,9 @@ int runFilesApp(int argc, char** argv) { hideWindow(); runAsyncArgv("gambatte", item.fullPath.c_str()); return; + } else if (isYabasicExt(ext)) { + runYabasicInTerminal(item.fullPath); + return; } else if (isMediaExt(ext)) { hideWindow(); runAsyncArgv("sxmo_open.sh", item.fullPath.c_str()); @@ -845,7 +1031,7 @@ int runFilesApp(int argc, char** argv) { } else if (isGameBoyExt(ext)) { ic = kIconGamepad; icCol = &colYellow; - } else if (it.isExec || ext == "sh") { + } else if (isYabasicExt(ext) || it.isExec || ext == "sh") { ic = kIconTerm; icCol = &colOrange; } else { @@ -1032,6 +1218,174 @@ int runFilesApp(int argc, char** argv) { i == 0 ? "No, Keep Item" : "Yes, Delete", 184); } drawSoftkeyBar("Select", "Confirm", "Cancel"); + + } else if (mode == ViewMode::TextEdit) { + syncEditorScroll(); + XSetForeground(dpy, gc, pt.spec.bgHeader); + XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, 32); + XSetForeground(dpy, gc, pt.spec.border); + XDrawLine(dpy, pix, gc, 0, 31, kWinWidth, 31); + + drawPango(layoutIcon, editModified ? &colOrange : &colBlue, 5, 0, 32, + kIconEdit); + std::string titleDisp = (editModified ? "*" : "") + editFileName; + drawPango(layoutMain, &colWhite, 25, 0, 32, titleDisp, 114); + + const char* mBadge = "[abc]"; + if (editCaseMode == TextCaseMode::Title) mBadge = "[Abc]"; + else if (editCaseMode == TextCaseMode::Upper) mBadge = "[ABC]"; + else if (editCaseMode == TextCaseMode::Num) mBadge = "[123]"; + else if (editCaseMode == TextCaseMode::Sym) mBadge = "[Sym]"; + + std::string rightHdr = std::to_string(editCursorRow + 1) + ":" + + std::to_string(editCursorCol + 1) + " " + mBadge; + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_text(layoutSmall, rightHdr.c_str(), + static_cast(rightHdr.size())); + int rhw = 0, rhh = 0; + pango_layout_get_pixel_size(layoutSmall, &rhw, &rhh); + drawPango(layoutSmall, &colYellow, kWinWidth - rhw - 5, 0, 32, rightHdr); + + const int startY = 33; + const int rowH = 21; + const int gutterW = 28; + + XSetForeground(dpy, gc, pt.spec.bgAltOdd); + XFillRectangle(dpy, pix, gc, 0, startY, gutterW, + kEditVisibleRows * rowH); + XSetForeground(dpy, gc, pt.spec.divider); + XDrawLine(dpy, pix, gc, gutterW, startY, gutterW, + startY + kEditVisibleRows * rowH); + + int64_t tCur = nowMs(); + bool tapActive = + (editLastTapDigit != -1 && (tCur - editLastTapMs) <= 850); + + for (int i = 0; i < kEditVisibleRows; ++i) { + int idx = editScrollRow + i; + if (idx >= static_cast(editLines.size())) break; + int ry = startY + i * rowH; + bool isCurRow = (idx == editCursorRow); + + if (isCurRow) { + XSetForeground(dpy, gc, pt.spec.bgInputFocus); + XFillRectangle(dpy, pix, gc, gutterW + 1, ry, + kWinWidth - gutterW - 1, rowH); + } + + std::string lnStr = std::to_string(idx + 1); + drawPango(layoutSmall, isCurRow ? &colYellow : &colMuted, 3, ry, rowH, + lnStr, gutterW - 4); + + const std::string& rawLine = editLines[idx]; + size_t sliceStart = 0; + if (isCurRow && editCursorCol > 22) { + sliceStart = clampUtf8Pos(rawLine, editCursorCol - 22); + } + std::string rawSlice = rawLine.substr(sliceStart); + std::string dispSlice = expandTabsForDisplay(rawSlice); + + drawPango(layoutSmall, isCurRow ? &colWhite : &colLight, gutterW + 4, + ry, rowH, dispSlice, kWinWidth - gutterW - 8); + + if (isCurRow && !editMenuOpen) { + size_t curOff = + (editCursorCol >= sliceStart) ? (editCursorCol - sliceStart) : 0; + std::string beforeCur = + expandTabsForDisplay(rawSlice.substr(0, curOff)); + pango_layout_set_width(layoutSmall, -1); + pango_layout_set_ellipsize(layoutSmall, PANGO_ELLIPSIZE_NONE); + pango_layout_set_text(layoutSmall, beforeCur.c_str(), + static_cast(beforeCur.size())); + int cx = 0, chh = 0; + pango_layout_get_pixel_size(layoutSmall, &cx, &chh); + int curX = std::min(kWinWidth - 4, gutterW + 4 + cx); + XSetForeground(dpy, gc, + tapActive ? pt.spec.success : pt.spec.accentAlt); + XFillRectangle(dpy, pix, gc, curX, ry + 3, 2, rowH - 6); + } + } + + // Bottom candidate / hint bar + const int hintY = kWinHeight - 46; + XSetForeground(dpy, gc, pt.spec.bgAltOdd); + XFillRectangle(dpy, pix, gc, 0, hintY, kWinWidth, 20); + XSetForeground(dpy, gc, pt.spec.divider); + XDrawLine(dpy, pix, gc, 0, hintY, kWinWidth, hintY); + + if (tapActive && editLastTapDigit >= 0 && editLastTapDigit <= 10) { + const char* table = + (editCaseMode == TextCaseMode::Sym) + ? kMultiTapEditorSym[editLastTapDigit] + : kMultiTapEditorAlpha[editLastTapDigit]; + int tlen = static_cast(std::strlen(table)); + std::string cand = + (editLastTapDigit == 10) + ? "*: " + : (std::to_string(editLastTapDigit) + ": "); + for (int k = 0; k < tlen; ++k) { + char c = table[k]; + if (std::isalpha(static_cast(c)) && + editCaseMode == TextCaseMode::Upper) { + c = static_cast(std::toupper(static_cast(c))); + } + std::string label = (c == ' ') ? "SPC" : std::string(1, c); + if (k == editLastTapCycle) { + cand += "[" + label + "] "; + } else { + cand += label + " "; + } + } + drawPango(layoutSmall, &colYellow, 6, hintY, 20, cand, kWinWidth - 12); + } else { + drawPango(layoutSmall, &colMuted, 6, hintY, 20, + "#:Mode OK:Newline *:Sym Menu:Save", kWinWidth - 12); + } + + if (editMenuOpen) { + static const char* kEditMenuLabels[7] = { + "Save File", + "Save & Close", + "Insert New Line", + "Insert Space", + "Duplicate Line", + "Delete Line", + "Close Without Saving" + }; + const int mW = 212; + const int mRowH = 26; + const int mH = 28 + 7 * mRowH + 6; + const int mX = (kWinWidth - mW) / 2; + const int mY = 26; + + XSetForeground(dpy, gc, pt.spec.bgHeader); + XFillRectangle(dpy, pix, gc, mX, mY, mW, mH); + XSetForeground(dpy, gc, pt.spec.accentAlt); + XDrawRectangle(dpy, pix, gc, mX, mY, mW - 1, mH - 1); + + drawPango(layoutMain, &colYellow, mX + 10, mY + 2, 24, "Editor Menu", + mW - 20); + XSetForeground(dpy, gc, pt.spec.divider); + XDrawLine(dpy, pix, gc, mX + 1, mY + 26, mX + mW - 2, mY + 26); + + for (int i = 0; i < 7; ++i) { + int ry = mY + 28 + i * mRowH; + bool isSel = (i == editMenuSel); + if (isSel) { + XSetForeground( + dpy, gc, + (i == 6 && editModified) ? pt.spec.dangerBg : pt.spec.selBg); + XFillRectangle(dpy, pix, gc, mX + 2, ry, mW - 4, mRowH - 1); + } + drawPango(layoutSmall, &colWhite, mX + 12, ry, mRowH, + kEditMenuLabels[i], mW - 24); + } + drawSoftkeyBar("Select", "Select", "Back"); + } else { + bool docEmpty = + (editLines.size() == 1 && editLines[0].empty() && !editModified); + drawSoftkeyBar("Menu/Save", "Enter", docEmpty ? "Close" : "Del"); + } } int64_t tNow = nowMs(); @@ -1101,6 +1455,169 @@ int runFilesApp(int argc, char** argv) { inputValue.push_back(ch); }; + auto insertEditorStr = [&](const std::string& s) { + if (s.empty()) return; + if (editLines.empty()) editLines.push_back(""); + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + std::string& line = editLines[editCursorRow]; + editCursorCol = clampUtf8Pos(line, editCursorCol); + line.insert(static_cast(editCursorCol), s); + editCursorCol += static_cast(s.size()); + editModified = true; + syncEditorScroll(); + }; + + auto deleteEditorCharBefore = [&]() { + editLastTapDigit = -1; + if (editLines.empty()) { + editLines.push_back(""); + editCursorRow = 0; + editCursorCol = 0; + return; + } + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + std::string& line = editLines[editCursorRow]; + editCursorCol = clampUtf8Pos(line, editCursorCol); + if (editCursorCol > 0) { + int prevPos = prevUtf8Pos(line, editCursorCol); + line.erase(static_cast(prevPos), + static_cast(editCursorCol - prevPos)); + editCursorCol = prevPos; + editModified = true; + } else if (editCursorRow > 0) { + int prevLen = static_cast(editLines[editCursorRow - 1].size()); + editLines[editCursorRow - 1] += line; + editLines.erase(editLines.begin() + editCursorRow); + editCursorRow--; + editCursorCol = prevLen; + editModified = true; + } + syncEditorScroll(); + }; + + auto insertEditorNewline = [&]() { + editLastTapDigit = -1; + if (editLines.empty()) editLines.push_back(""); + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + std::string& line = editLines[editCursorRow]; + editCursorCol = clampUtf8Pos(line, editCursorCol); + std::string tail = line.substr(static_cast(editCursorCol)); + line.erase(static_cast(editCursorCol)); + editLines.insert(editLines.begin() + editCursorRow + 1, tail); + editCursorRow++; + editCursorCol = 0; + editModified = true; + syncEditorScroll(); + }; + + auto commitEditorTap = [&](int keyIdx) { + if (keyIdx < 0 || keyIdx > 10) return; + if (editLines.empty()) editLines.push_back(""); + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + std::string& line = editLines[editCursorRow]; + editCursorCol = clampUtf8Pos(line, editCursorCol); + + if (editCaseMode == TextCaseMode::Num) { + editLastTapDigit = -1; + char c = (keyIdx == 10) ? '*' : static_cast('0' + keyIdx); + insertEditorStr(std::string(1, c)); + return; + } + + int64_t t = nowMs(); + const char* chars = (editCaseMode == TextCaseMode::Sym) + ? kMultiTapEditorSym[keyIdx] + : kMultiTapEditorAlpha[keyIdx]; + int len = static_cast(std::strlen(chars)); + if (len <= 0) return; + + bool replacePrev = + (keyIdx == editLastTapDigit && (t - editLastTapMs) <= 850 && + editCursorCol > 0); + if (replacePrev) { + int prevPos = prevUtf8Pos(line, editCursorCol); + line.erase(static_cast(prevPos), + static_cast(editCursorCol - prevPos)); + editCursorCol = prevPos; + editLastTapCycle = (editLastTapCycle + 1) % len; + } else { + editLastTapDigit = keyIdx; + editLastTapCycle = 0; + } + editLastTapMs = t; + + char ch = chars[editLastTapCycle]; + if (std::isalpha(static_cast(ch))) { + if (editCaseMode == TextCaseMode::Upper) { + ch = static_cast(std::toupper(static_cast(ch))); + } else if (editCaseMode == TextCaseMode::Title) { + bool atWordStart = (editCursorCol == 0); + if (!atWordStart && editCursorCol > 0) { + unsigned char prevCh = + static_cast(line[editCursorCol - 1]); + atWordStart = !std::isalnum(prevCh) && prevCh != '_'; + } + if (atWordStart) { + ch = static_cast(std::toupper(static_cast(ch))); + } + } + } + line.insert(static_cast(editCursorCol), 1, ch); + editCursorCol += 1; + editModified = true; + syncEditorScroll(); + }; + + auto executeEditorMenuAction = [&](int menuIdx) { + editMenuOpen = false; + editLastTapDigit = -1; + if (menuIdx == 0) { + saveEditedFile(); + } else if (menuIdx == 1) { + if (saveEditedFile()) { + loadDirectory(currentDir, editFileName); + mode = ViewMode::Browser; + } + } else if (menuIdx == 2) { + insertEditorNewline(); + } else if (menuIdx == 3) { + insertEditorStr(" "); + } else if (menuIdx == 4) { + if (editLines.empty()) editLines.push_back(""); + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + std::string copyLine = editLines[editCursorRow]; + editLines.insert(editLines.begin() + editCursorRow + 1, copyLine); + editCursorRow++; + editCursorCol = clampUtf8Pos(editLines[editCursorRow], editCursorCol); + editModified = true; + syncEditorScroll(); + } else if (menuIdx == 5) { + if (editLines.size() <= 1) { + editLines = {""}; + editCursorRow = 0; + editCursorCol = 0; + } else { + editCursorRow = std::clamp(editCursorRow, 0, + static_cast(editLines.size()) - 1); + editLines.erase(editLines.begin() + editCursorRow); + if (editCursorRow >= static_cast(editLines.size())) { + editCursorRow = static_cast(editLines.size()) - 1; + } + editCursorCol = clampUtf8Pos(editLines[editCursorRow], editCursorCol); + } + editModified = true; + syncEditorScroll(); + } else if (menuIdx == 6) { + loadDirectory(currentDir, editFileName); + mode = ViewMode::Browser; + } + }; + auto executeAction = [&](int actId) { if (actId == 1) { mode = ViewMode::Browser; @@ -1108,7 +1625,7 @@ int runFilesApp(int argc, char** argv) { } else if (actId == 2 && !filteredItems.empty()) { std::string p = filteredItems[selected].fullPath; hideWindow(); - runAsyncCmd("st -e nano \"" + p + "\""); + runAsyncCmd("st -e nano " + shellQuote(p)); } else if (actId == 3) { inputAction = InputAction::NewFolder; inputTitle = "New Folder"; @@ -1173,7 +1690,7 @@ int runFilesApp(int argc, char** argv) { } else if (actId == 12) { std::string d = currentDir; hideWindow(); - runAsyncCmd("cd \"" + d + "\" && exec st"); + runAsyncCmd("cd " + shellQuote(d) + " && exec st"); } else if (actId == 13 && !filteredItems.empty()) { confirmSelected = 0; mode = ViewMode::ConfirmDelete; @@ -1185,6 +1702,11 @@ int runFilesApp(int argc, char** argv) { installAsRingtone(filteredItems[selected].fullPath, filteredItems[selected].name); mode = ViewMode::Browser; + } else if (actId == 16 && !filteredItems.empty()) { + openTextEditor(filteredItems[selected].fullPath, + filteredItems[selected].name); + } else if (actId == 17 && !filteredItems.empty()) { + openTextViewer(filteredItems[selected]); } }; @@ -1226,13 +1748,33 @@ int runFilesApp(int argc, char** argv) { } } else if (mode == ViewMode::TextView) { if (isLeft) { - std::string p = viewingFilePath; - hideWindow(); - runAsyncCmd("st -e nano \"" + p + "\""); - return; + openTextEditor(viewingFilePath, viewingFileName); } else { mode = ViewMode::Browser; } + } else if (mode == ViewMode::TextEdit) { + if (editMenuOpen) { + if (isLeft) { + executeEditorMenuAction(editMenuSel); + } else { + editMenuOpen = false; + } + } else { + if (isLeft) { + editLastTapDigit = -1; + editMenuOpen = true; + editMenuSel = 0; + } else { + bool isEmptyDoc = + (editLines.size() == 1 && editLines[0].empty()); + if (isEmptyDoc && !editModified) { + loadDirectory(currentDir, editFileName); + mode = ViewMode::Browser; + } else { + deleteEditorCharBefore(); + } + } + } } else if (mode == ViewMode::ImageView) { if (isLeft) { installAsWallpaper(viewingFilePath, viewingFileName); @@ -1253,15 +1795,21 @@ int runFilesApp(int argc, char** argv) { : (currentDir + "/" + name); if (inputAction == InputAction::NewFolder) { ::mkdir(target.c_str(), 0755); + loadDirectory(currentDir, name); + mode = ViewMode::Browser; } else if (inputAction == InputAction::NewFile) { int fd = ::open(target.c_str(), O_WRONLY | O_CREAT, 0644); if (fd >= 0) ::close(fd); + loadDirectory(currentDir, name); + openTextEditor(target, name); } else if (inputAction == InputAction::Rename) { ::rename(inputTargetPath.c_str(), target.c_str()); + loadDirectory(currentDir, name); + mode = ViewMode::Browser; } - loadDirectory(currentDir, name); + } else { + mode = ViewMode::Browser; } - mode = ViewMode::Browser; } else { if (!inputValue.empty()) { popUtf8(inputValue); @@ -1461,6 +2009,88 @@ int runFilesApp(int argc, char** argv) { textScrollLine + 10); drawUI(); } + } else if (mode == ViewMode::TextEdit) { + if (editMenuOpen) { + if (sym == XK_Up || sym == XK_KP_Up) { + editMenuSel = (editMenuSel - 1 + 7) % 7; + drawUI(); + } else if (sym == XK_Down || sym == XK_KP_Down) { + editMenuSel = (editMenuSel + 1) % 7; + drawUI(); + } else if (sym == XK_Return || sym == XK_KP_Enter) { + executeEditorMenuAction(editMenuSel); + drawUI(); + } + } else { + if (sym == XK_Up || sym == XK_KP_Up) { + editLastTapDigit = -1; + if (editCursorRow > 0) { + editCursorRow--; + editCursorCol = + clampUtf8Pos(editLines[editCursorRow], editCursorCol); + syncEditorScroll(); + drawUI(); + } + } else if (sym == XK_Down || sym == XK_KP_Down) { + editLastTapDigit = -1; + if (editCursorRow + 1 < static_cast(editLines.size())) { + editCursorRow++; + editCursorCol = + clampUtf8Pos(editLines[editCursorRow], editCursorCol); + syncEditorScroll(); + drawUI(); + } + } else if (sym == XK_Left || sym == XK_KP_Left) { + editLastTapDigit = -1; + if (editCursorCol > 0) { + editCursorCol = + prevUtf8Pos(editLines[editCursorRow], editCursorCol); + } else if (editCursorRow > 0) { + editCursorRow--; + editCursorCol = + static_cast(editLines[editCursorRow].size()); + } + syncEditorScroll(); + drawUI(); + } else if (sym == XK_Right || sym == XK_KP_Right) { + if (editLastTapDigit != -1 && + (nowMs() - editLastTapMs) <= 850) { + editLastTapDigit = -1; + drawUI(); + } else { + editLastTapDigit = -1; + int lineLen = + static_cast(editLines[editCursorRow].size()); + if (editCursorCol < lineLen) { + editCursorCol = + nextUtf8Pos(editLines[editCursorRow], editCursorCol); + } else if (editCursorRow + 1 < + static_cast(editLines.size())) { + editCursorRow++; + editCursorCol = 0; + } + syncEditorScroll(); + drawUI(); + } + } else if (sym == XK_Return || sym == XK_KP_Enter) { + insertEditorNewline(); + drawUI(); + } else { + char d = mapDialKey(sym, kc, ev.xkey.state); + if (d == '#') { + editLastTapDigit = -1; + editCaseMode = static_cast( + (static_cast(editCaseMode) + 1) % 5); + drawUI(); + } else if (d == '*') { + commitEditorTap(10); + drawUI(); + } else if (d >= '0' && d <= '9') { + commitEditorTap(d - '0'); + drawUI(); + } + } + } } else if (mode == ViewMode::ImageView) { char d = mapDialKey(sym, kc, ev.xkey.state); if (d == '*') { @@ -1524,11 +2154,30 @@ int runFilesApp(int argc, char** argv) { handleSoftkeyAction(isLeft); } } + if (visible && mode == ViewMode::TextEdit && editLastTapDigit != -1 && + (tNow - editLastTapMs) > 850) { + editLastTapDigit = -1; + drawUI(); + } int timeoutMs = -1; if (visible && softkeyPending != 0) { timeoutMs = std::max(1, static_cast(softkeyDeadlineMs - tNow)); } + if (visible && mode == ViewMode::TextEdit && editLastTapDigit != -1) { + int tapRem = std::max(1, static_cast(851 - (tNow - editLastTapMs))); + timeoutMs = (timeoutMs < 0) ? tapRem : std::min(timeoutMs, tapRem); + } + if (visible && !statusBanner.empty() && statusBannerUntilMs > 0) { + if (tNow >= statusBannerUntilMs) { + statusBanner.clear(); + statusBannerUntilMs = 0; + drawUI(); + } else { + int banRem = std::max(1, static_cast(statusBannerUntilMs - tNow)); + timeoutMs = (timeoutMs < 0) ? banRem : std::min(timeoutMs, banRem); + } + } struct pollfd pfds[2]; int nfds = 0;