#include "phone_data.h" #include "../sxmo-dwm/dwm_plugin.h" #include #include #include #include #include #include namespace { using namespace bananarchy; volatile sig_atomic_t gRunning = 1; void handleSignal(int) { gRunning = 0; } enum class ViewMode { List = 0, Detail = 1, Options = 2, Edit = 3, PickRecent = 4, ConfirmDelete = 5, }; enum class TextCaseMode { Title = 0, // Abc Upper = 1, // ABC Lower = 2, // abc Num = 3, // 123 }; const char* kMultiTapLower[10] = { " 0+", // 0 ".,-@_1", // 1 "abc2", // 2 "def3", // 3 "ghi4", // 4 "jkl5", // 5 "mno6", // 6 "pqrs7", // 7 "tuv8", // 8 "wxyz9" // 9 }; int runContactsApp(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], "newcontact") == 0) { if (i + 1 < argc && argv[i + 1]) { initArg = std::string("new:") + argv[++i]; } else { initArg = "new:"; } } else if (initArg.empty()) { initArg = argv[i]; } } std::string daemonPidPath = getPluginDaemonPidPath("contacts"); std::string activePidPath = getRuntimeFile("xcontacts.pid"); if (isPluginDaemonAlive("contacts")) { if (!isPreload) { sendPluginCommand("contacts", initArg.empty() ? "OPEN" : ("OPEN " + initArg)); } return 0; } writeIntFile(daemonPidPath, static_cast(::getpid())); int fifoFd = setupPluginDaemonFifo("contacts"); 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[] = "xcontacts"; char resClass[] = "XContacts"; ch.res_name = resName; ch.res_class = resClass; XSetClassHint(dpy, win, &ch); XStoreName(dpy, win, "Contacts"); 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 filtered; std::vector recentUnknowns; FileStamp contactsStamp; FileStamp logStamp; bool contactsLoadedOnce = false; ViewMode mode = ViewMode::List; ViewMode prevModeBeforeEdit = ViewMode::List; std::string searchQuery; int selected = 0; int detailSelected = 0; int optionSelected = 0; int confirmSelected = 0; int recentSelected = 0; // Editor state bool editIsNew = true; std::string editOrigName; std::string editOrigNumber; std::string editName; std::string editNumber; int editField = 0; // 0 = Name, 1 = Number, 2 = From Recent, 3 = Save TextCaseMode caseMode = TextCaseMode::Title; int lastTapDigit = -1; int lastTapCycle = 0; int64_t lastTapMs = 0; bool zeroHeld = false; int64_t zeroPressMs = 0; bool visible = false; int64_t openStartMs = 0; int64_t startupIgnoreUntilMs = 0; bool initialSoftkeysReleased = true; int softkeyPending = 0; int64_t softkeyDeadlineMs = 0; auto updateFilter = [&]() { filtered.clear(); const auto& all = book.savedContacts(); if (searchQuery.empty()) { filtered = all; } else { std::string qNorm = normalizePhone(searchQuery); std::string qLower = toLowerStr(searchQuery); std::vector pfxMatches; std::vector subMatches; for (const auto& c : all) { bool pfx = (!qLower.empty() && c.lowerName.rfind(qLower, 0) == 0) || (!qNorm.empty() && c.t9Name.rfind(qNorm, 0) == 0) || (!qNorm.empty() && c.normNumber.rfind(qNorm, 0) == 0); bool sub = pfx || (!qLower.empty() && c.lowerName.find(qLower) != std::string::npos) || (!qNorm.empty() && c.t9Name.find(qNorm) != std::string::npos) || (!qNorm.empty() && c.normNumber.find(qNorm) != std::string::npos); if (pfx) { pfxMatches.push_back(c); } else if (sub) { subMatches.push_back(c); } } filtered.insert(filtered.end(), pfxMatches.begin(), pfxMatches.end()); filtered.insert(filtered.end(), subMatches.begin(), subMatches.end()); } if (selected >= static_cast(filtered.size())) { selected = std::max(0, static_cast(filtered.size()) - 1); } }; auto reloadContactsIfNeeded = [&]() -> bool { bool cChanged = contactsStamp.checkAndUpdate(getContactsFilePath()); bool lChanged = logStamp.checkAndUpdate(getModemLogFilePath()); if (cChanged || lChanged || !contactsLoadedOnce) { contactsLoadedOnce = true; book.load(); updateFilter(); return true; } return false; }; reloadContactsIfNeeded(); 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); } PangoRectangle ink{}, logical{}; pango_layout_get_pixel_extents(layout, &ink, &logical); int ty = y + (h - logical.height) / 2; pango_xft_render_layout(xftDraw, color, layout, x * PANGO_SCALE, ty * PANGO_SCALE); }; auto pangoWidth = [&](PangoLayout* layout, const std::string& text) -> int { pango_layout_set_width(layout, -1); pango_layout_set_text(layout, text.c_str(), static_cast(text.size())); PangoRectangle ink{}, logical{}; pango_layout_get_pixel_extents(layout, &ink, &logical); return logical.width; }; constexpr int kHeaderH = 38; constexpr int kSoftbarH = 26; constexpr int kListY = kHeaderH; constexpr int kListH = kWinHeight - kHeaderH - kSoftbarH; constexpr int kVisibleRows = 5; auto drawSoftbar = [&](const std::string& leftLabel, const std::string& centerLabel, const std::string& rightLabel, XftColor* centerCol = nullptr) { int sbY = kWinHeight - kSoftbarH; XSetForeground(dpy, gc, 0x181818); XFillRectangle(dpy, pix, gc, 0, sbY, kWinWidth, kSoftbarH); XSetForeground(dpy, gc, 0x444444); XFillRectangle(dpy, pix, gc, 0, sbY, kWinWidth, 1); if (!leftLabel.empty()) { drawPango(layoutMain, &colLight, 6, sbY + 1, kSoftbarH - 1, leftLabel); } if (!centerLabel.empty()) { int cw = pangoWidth(layoutMain, centerLabel); drawPango(layoutMain, centerCol ? centerCol : &colGreen, (kWinWidth - cw) / 2, sbY + 1, kSoftbarH - 1, centerLabel); } if (!rightLabel.empty()) { int rw = pangoWidth(layoutMain, rightLabel); drawPango(layoutMain, &colLight, kWinWidth - rw - 6, sbY + 1, kSoftbarH - 1, rightLabel); } }; auto render = [&]() { XSetForeground(dpy, gc, 0x222222); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kWinHeight); if (mode == ViewMode::List || mode == ViewMode::Options) { // Header bar with live search / count XSetForeground(dpy, gc, 0x1c1c1c); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kHeaderH); XSetForeground(dpy, gc, 0x444444); XFillRectangle(dpy, pix, gc, 0, kHeaderH - 1, kWinWidth, 1); if (searchQuery.empty()) { drawPango(layoutIcon, &colBlue, 8, 0, kHeaderH, kIconBook); std::string title = "Contacts (" + std::to_string(book.savedContacts().size()) + ")"; drawPango(layoutMain, &colWhite, 32, 0, kHeaderH, title, 200); } else { drawPango(layoutIcon, &colYellow, 8, 0, kHeaderH, kIconSearch); drawPango(layoutMain, &colYellow, 32, 0, kHeaderH, "Find: " + searchQuery, 178); drawPango(layoutIcon, &colMuted, 216, 0, kHeaderH, kIconBackspace); } if (filtered.empty()) { drawPango(layoutIcon, &colMuted, 112, kListY + 46, 24, kIconUser); drawPango(layoutMain, &colMuted, 32, kListY + 76, 26, searchQuery.empty() ? "Phonebook is empty" : "No matching contacts", 190); drawPango(layoutSmall, &colMuted, 20, kListY + 106, 22, "Press Options or Enter to add", 204); } else { int total = static_cast(filtered.size()); int scrollOffset = 0; if (selected >= kVisibleRows) { scrollOffset = selected - kVisibleRows + 1; } if (scrollOffset > std::max(0, total - kVisibleRows)) { scrollOffset = std::max(0, total - kVisibleRows); } for (int i = 0; i < kVisibleRows; ++i) { int idx = scrollOffset + i; if (idx >= total) break; int ry = kListY + (i * kListH) / kVisibleRows; int nextRy = kListY + ((i + 1) * kListH) / kVisibleRows; int rowH = nextRy - ry; bool isSel = (idx == selected); XSetForeground(dpy, gc, isSel ? 0x005577 : ((i % 2 == 0) ? 0x282828 : 0x2e2e2e)); XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH); if (isSel) { XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 0, ry, 4, rowH); } const ContactEntry& c = filtered[idx]; drawPango(layoutIcon, isSel ? &colYellow : &colBlue, 8, ry, rowH, kIconUser); drawPango(layoutMain, isSel ? &colWhite : &colLight, 30, ry + 3, 22, c.name, 202); drawPango(layoutSmall, isSel ? &colYellow : &colMuted, 30, ry + 24, 19, c.number, 202); } if (total > kVisibleRows) { int sbH = std::max(16, (kListH * kVisibleRows) / total); int sbY = kListY + ((kListH - sbH) * scrollOffset) / (total - kVisibleRows); XSetForeground(dpy, gc, 0x666666); XFillRectangle(dpy, pix, gc, kWinWidth - 3, sbY, 3, sbH); } } if (mode == ViewMode::Options) { // Popup options menu overlay const char* optLabels[6] = { "New Contact", "Add from Recent", "Call Contact", "Send SMS", "Edit Contact", "Delete Contact" }; const char* optIcons[6] = { kIconPlus, kIconClock, kIconPhone, kIconSms, kIconEdit, kIconTrash }; int optCount = filtered.empty() ? 2 : 6; int menuRowH = 34; int menuH = optCount * menuRowH + 4; int menuY = kWinHeight - kSoftbarH - menuH; XSetForeground(dpy, gc, 0x181818); XFillRectangle(dpy, pix, gc, 8, menuY, kWinWidth - 16, menuH); XSetForeground(dpy, gc, 0x005577); XDrawRectangle(dpy, pix, gc, 8, menuY, kWinWidth - 17, menuH - 1); for (int i = 0; i < optCount; ++i) { int ry = menuY + 2 + i * menuRowH; bool isSel = (i == optionSelected); if (isSel) { XSetForeground(dpy, gc, 0x005577); XFillRectangle(dpy, pix, gc, 10, ry, kWinWidth - 20, menuRowH); XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 10, ry, 4, menuRowH); } XftColor* icCol = (i == 5) ? &colRed : (isSel ? &colYellow : &colGreen); drawPango(layoutIcon, icCol, 18, ry, menuRowH, optIcons[i]); drawPango(layoutMain, isSel ? &colWhite : &colLight, 42, ry, menuRowH, optLabels[i], 180); } drawSoftbar("Select", "OK", "Back", &colYellow); } else { drawSoftbar("Options", filtered.empty() ? "New" : "Open", searchQuery.empty() ? "Close" : "Clear", &colGreen); } } else if (mode == ViewMode::Detail) { ContactEntry c; if (selected >= 0 && selected < static_cast(filtered.size())) { c = filtered[selected]; } constexpr int kCardH = 56; XSetForeground(dpy, gc, 0x1b3b4b); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kCardH); XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 0, 0, 4, kCardH); XSetForeground(dpy, gc, 0x005577); XFillRectangle(dpy, pix, gc, 0, kCardH - 2, kWinWidth, 2); drawPango(layoutIcon, &colYellow, 10, 6, 24, kIconUser); drawPango(layoutMain, &colWhite, 34, 4, 26, c.name, 198); drawPango(layoutSmall, &colYellow, 34, 30, 20, c.number, 198); const char* actLabels[6] = { "Call Contact", "Send SMS", "Read Messages", "Open in Dialer", "Edit Contact", "Delete Contact" }; const char* actIcons[6] = { kIconPhone, kIconSms, kIconList, kIconPhoneLog, kIconEdit, kIconTrash }; constexpr int kActCount = 6; constexpr int kActVis = 5; int actListY = kCardH; int actListH = kWinHeight - kCardH - kSoftbarH; int scrollOffset = 0; if (detailSelected >= kActVis) { scrollOffset = detailSelected - kActVis + 1; } for (int i = 0; i < kActVis; ++i) { int idx = scrollOffset + i; if (idx >= kActCount) break; int ry = actListY + (i * actListH) / kActVis; int nextRy = actListY + ((i + 1) * actListH) / kActVis; int rowH = nextRy - ry; bool isSel = (idx == detailSelected); XSetForeground(dpy, gc, isSel ? 0x005577 : ((i % 2 == 0) ? 0x282828 : 0x2e2e2e)); XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH); if (isSel) { XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 0, ry, 4, rowH); } XftColor* icCol = (idx == 5) ? &colRed : (isSel ? &colYellow : &colGreen); drawPango(layoutIcon, icCol, 10, ry, rowH, actIcons[idx]); drawPango(layoutMain, (idx == 5 && !isSel) ? &colRed : (isSel ? &colWhite : &colLight), 34, ry, rowH, actLabels[idx], 196); } drawSoftbar("Edit", "Select", "Back", &colGreen); } else if (mode == ViewMode::Edit) { XSetForeground(dpy, gc, 0x1c1c1c); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kHeaderH); XSetForeground(dpy, gc, 0x444444); XFillRectangle(dpy, pix, gc, 0, kHeaderH - 1, kWinWidth, 1); drawPango(layoutIcon, &colGreen, 8, 0, kHeaderH, editIsNew ? kIconPlus : kIconEdit); drawPango(layoutMain, &colWhite, 30, 0, kHeaderH, editIsNew ? "New Contact" : "Edit Contact", 145); const char* modeNames[4] = {"[Abc]", "[ABC]", "[abc]", "[123]"}; std::string modeBadge = (editField == 0) ? modeNames[static_cast(caseMode)] : "[+0-9]"; int mw = pangoWidth(layoutSmall, modeBadge); drawPango(layoutSmall, &colYellow, kWinWidth - mw - 6, 0, kHeaderH, modeBadge); // Field 0: Name box (y = 44..108) auto drawFieldBox = [&](int y, int h, bool active, const char* label, const std::string& val, const char* placeholder) { XSetForeground(dpy, gc, active ? 0x1b3b4b : 0x2a2a2a); XFillRectangle(dpy, pix, gc, 6, y, kWinWidth - 12, h); XSetForeground(dpy, gc, active ? 0xf4d03f : 0x444444); XDrawRectangle(dpy, pix, gc, 6, y, kWinWidth - 13, h - 1); drawPango(layoutSmall, active ? &colYellow : &colMuted, 12, y + 3, 18, label); std::string shown = val; if (active) { shown.push_back('|'); } if (val.empty() && !active) { drawPango(layoutMain, &colMuted, 12, y + 23, 28, placeholder, 214); } else { if (shown.size() > 21) { shown = ".." + shown.substr(shown.size() - 19); } drawPango(layoutMain, &colWhite, 12, y + 23, 28, shown, 214); } }; drawFieldBox(44, 56, editField == 0, "Name (# changes case):", editName, "Type name on keypad..."); drawFieldBox(106, 56, editField == 1, "Number (Hold 0 for +):", editNumber, "Enter phone number..."); // Button Row 2: Pick from Recent Numbers (y = 168..208) auto drawBtnRow = [&](int y, int h, bool active, const char* icon, const char* label, XftColor* icCol) { XSetForeground(dpy, gc, active ? 0x005577 : 0x2c2c2c); XFillRectangle(dpy, pix, gc, 6, y, kWinWidth - 12, h); if (active) { XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 6, y, 4, h); } drawPango(layoutIcon, active ? &colYellow : icCol, 14, y, h, icon); drawPango(layoutMain, active ? &colWhite : &colLight, 38, y, h, label, 190); }; drawBtnRow(168, 42, editField == 2, kIconClock, "Use Recent Number", &colBlue); drawBtnRow(216, 44, editField == 3, kIconCheck, "Save Contact", &colGreen); bool canClearChar = (editField == 0 && !editName.empty()) || (editField == 1 && !editNumber.empty()); drawSoftbar("Save", (editField >= 2) ? "Select" : "Next", canClearChar ? "Clear" : "Cancel", &colGreen); } else if (mode == ViewMode::PickRecent) { XSetForeground(dpy, gc, 0x1c1c1c); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kHeaderH); XSetForeground(dpy, gc, 0x444444); XFillRectangle(dpy, pix, gc, 0, kHeaderH - 1, kWinWidth, 1); drawPango(layoutIcon, &colBlue, 8, 0, kHeaderH, kIconClock); drawPango(layoutMain, &colWhite, 32, 0, kHeaderH, "Recent Numbers", 200); if (recentUnknowns.empty()) { drawPango(layoutMain, &colMuted, 24, kListY + 70, 28, "No recent numbers", 196); } else { int total = static_cast(recentUnknowns.size()); int scrollOffset = 0; if (recentSelected >= kVisibleRows) { scrollOffset = recentSelected - kVisibleRows + 1; } for (int i = 0; i < kVisibleRows; ++i) { int idx = scrollOffset + i; if (idx >= total) break; int ry = kListY + (i * kListH) / kVisibleRows; int nextRy = kListY + ((i + 1) * kListH) / kVisibleRows; int rowH = nextRy - ry; bool isSel = (idx == recentSelected); XSetForeground(dpy, gc, isSel ? 0x005577 : ((i % 2 == 0) ? 0x282828 : 0x2e2e2e)); XFillRectangle(dpy, pix, gc, 0, ry, kWinWidth, rowH); if (isSel) { XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 0, ry, 4, rowH); } drawPango(layoutIcon, isSel ? &colYellow : &colGreen, 10, ry, rowH, kIconPhone); drawPango(layoutMain, isSel ? &colWhite : &colLight, 34, ry, rowH, recentUnknowns[idx], 198); } } drawSoftbar("Use", "Select", "Back", &colGreen); } else if (mode == ViewMode::ConfirmDelete) { XSetForeground(dpy, gc, 0x1c1c1c); XFillRectangle(dpy, pix, gc, 0, 0, kWinWidth, kHeaderH); drawPango(layoutIcon, &colRed, 8, 0, kHeaderH, kIconTrash); drawPango(layoutMain, &colWhite, 32, 0, kHeaderH, "Delete Contact?", 200); ContactEntry c; if (selected >= 0 && selected < static_cast(filtered.size())) { c = filtered[selected]; } drawPango(layoutMain, &colWhite, 14, 54, 28, c.name, 212); drawPango(layoutSmall, &colYellow, 14, 82, 22, c.number, 212); const char* ynLabels[2] = {"No, keep contact", "Yes, delete contact"}; for (int i = 0; i < 2; ++i) { int ry = 130 + i * 50; bool isSel = (i == confirmSelected); XSetForeground(dpy, gc, isSel ? 0x005577 : 0x2c2c2c); XFillRectangle(dpy, pix, gc, 8, ry, kWinWidth - 16, 44); if (isSel) { XSetForeground(dpy, gc, 0xf4d03f); XFillRectangle(dpy, pix, gc, 8, ry, 4, 44); } drawPango(layoutIcon, (i == 1) ? &colRed : &colGreen, 16, ry, 44, (i == 1) ? kIconTrash : kIconCheck); drawPango(layoutMain, isSel ? &colWhite : &colLight, 40, ry, 44, ynLabels[i], 184); } drawSoftbar("Delete", "Select", "Cancel", &colRed); } if (visible) { XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0); XFlush(dpy); } }; // Pre-warm Pango/FreeType/Xrender glyph caches into offscreen Pixmap at startup drawPango(layoutIcon, &colGreen, 0, 0, 20, std::string(kIconBook) + kIconUser + kIconSearch + kIconPlus + kIconEdit + kIconTrash + kIconSms + kIconList + kIconCheck + kIconPhone + kIconClock); render(); XSync(dpy, False); auto beginNewContact = [&](const std::string& prefillNumber) { prevModeBeforeEdit = mode; editIsNew = true; editOrigName.clear(); editOrigNumber.clear(); editName.clear(); editNumber = normalizePhone(prefillNumber); editField = 0; caseMode = TextCaseMode::Title; lastTapDigit = -1; mode = ViewMode::Edit; }; auto beginEditContact = [&](const ContactEntry& c) { prevModeBeforeEdit = mode; editIsNew = false; editOrigName = c.name; editOrigNumber = c.number; editName = c.name; editNumber = c.number; editField = 0; caseMode = TextCaseMode::Title; lastTapDigit = -1; mode = ViewMode::Edit; }; auto saveEditedContact = [&]() -> bool { std::string nm = trimStr(editName); std::string num = normalizePhone(editNumber); if (nm.empty() || num.empty()) { return false; } if (!book.addOrUpdateContact(editIsNew ? "" : editOrigNumber, editIsNew ? "" : editOrigName, num, nm)) { return false; } contactsStamp.checkAndUpdate(getContactsFilePath()); searchQuery.clear(); updateFilter(); std::string targetLower = toLowerStr(nm); for (size_t i = 0; i < filtered.size(); ++i) { if (filtered[i].lowerName == targetLower && filtered[i].normNumber == num) { selected = static_cast(i); break; } } mode = ViewMode::List; return true; }; auto showWindow = [&](const std::string& arg) { closeOtherPlugins("contacts"); bool openNewMode = (arg.rfind("new:", 0) == 0); std::string prefill = openNewMode ? arg.substr(4) : ""; if (!visible) { openStartMs = nowMs(); startupIgnoreUntilMs = openStartMs + 90; initialSoftkeysReleased = (querySoftkeysDown(dpy) == 0); softkeyPending = 0; softkeyDeadlineMs = 0; zeroHeld = false; visible = true; XMapRaised(dpy, win); if (!openNewMode) { XCopyArea(dpy, pix, win, gc, 0, 0, kWinWidth, kWinHeight, 0, 0); } for (int i = 0; i < 20; ++i) { if (XGrabKeyboard(dpy, win, True, GrabModeAsync, GrabModeAsync, CurrentTime) == GrabSuccess) { break; } usleep(2000); } bool reloaded = reloadContactsIfNeeded(); if (openNewMode) { beginNewContact(prefill); render(); } else if (reloaded || mode != ViewMode::List || !searchQuery.empty() || selected != 0) { mode = ViewMode::List; searchQuery.clear(); selected = 0; updateFilter(); render(); } XFlush(dpy); writeIntFile(activePidPath, static_cast(::getpid())); } else { reloadContactsIfNeeded(); if (openNewMode) { beginNewContact(prefill); } render(); } }; auto hideWindow = [&](bool drainSoftkeys) { if (!visible) { return; } int xfd = ConnectionNumber(dpy); if (drainSoftkeys) { int64_t drainDeadline = nowMs() + 180; while (nowMs() < drainDeadline && querySoftkeysDown(dpy) != 0) { while (XPending(dpy)) { XEvent ev; XNextEvent(dpy, &ev); } struct pollfd pfd {}; pfd.fd = xfd; pfd.events = POLLIN; ::poll(&pfd, 1, 10); } writeSoftkeySuppress(nowMs() + 300); } XUngrabKeyboard(dpy, CurrentTime); XUnmapWindow(dpy, win); XFlush(dpy); ::unlink(activePidPath.c_str()); visible = false; softkeyPending = 0; softkeyDeadlineMs = 0; zeroHeld = false; if (mode != ViewMode::List || !searchQuery.empty() || selected != 0) { mode = ViewMode::List; searchQuery.clear(); selected = 0; updateFilter(); render(); XFlush(dpy); } }; if (!isPreload) { showWindow(initArg); } int xfd = ConnectionNumber(dpy); auto handleMultiTapNameChar = [&](int digit) { if (digit < 0 || digit > 9) return; int64_t now = nowMs(); if (caseMode == TextCaseMode::Num) { editName.push_back(static_cast('0' + digit)); lastTapDigit = -1; return; } const char* seq = kMultiTapLower[digit]; int seqLen = static_cast(std::strlen(seq)); if (digit == lastTapDigit && (now - lastTapMs) <= 650 && !editName.empty()) { lastTapCycle = (lastTapCycle + 1) % seqLen; char ch = seq[lastTapCycle]; bool makeUpper = false; if (caseMode == TextCaseMode::Upper) { makeUpper = true; } else if (caseMode == TextCaseMode::Title) { makeUpper = (editName.size() == 1 || editName[editName.size() - 2] == ' '); } if (makeUpper) { ch = static_cast(std::toupper(static_cast(ch))); } editName.back() = ch; } else { lastTapDigit = digit; lastTapCycle = 0; char ch = seq[0]; bool makeUpper = false; if (caseMode == TextCaseMode::Upper) { makeUpper = true; } else if (caseMode == TextCaseMode::Title) { makeUpper = (editName.empty() || editName.back() == ' '); } if (makeUpper) { ch = static_cast(std::toupper(static_cast(ch))); } editName.push_back(ch); } lastTapMs = now; }; auto executeDetailAction = [&](int actIdx) { if (selected < 0 || selected >= static_cast(filtered.size())) { return; } ContactEntry c = filtered[selected]; switch (actIdx) { case 0: // Call Contact writeSoftkeySuppress(nowMs() + 350); hideWindow(true); placePhoneCall(c.number); break; case 1: // Send SMS writeSoftkeySuppress(nowMs() + 350); 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(false); if (!sendPluginCommand("sms", "OPEN thread:" + c.number)) { runAsyncArgv("xsms", "tailtextlog", c.number.c_str()); } break; case 3: // Open in Dialer writeSoftkeySuppress(nowMs() + 300); hideWindow(false); if (!sendPluginCommand("dialer", "OPEN " + c.number)) { runAsyncArgv("xdialer", c.number.c_str()); } break; case 4: // Edit Contact beginEditContact(c); render(); break; case 5: // Delete Contact confirmSelected = 0; mode = ViewMode::ConfirmDelete; render(); break; } }; auto executeOptionAction = [&](int optIdx) { if (filtered.empty()) { if (optIdx == 0) { beginNewContact(searchQuery); render(); } else if (optIdx == 1) { recentUnknowns = book.unknownRecentNumbers(); recentSelected = 0; beginNewContact(""); mode = ViewMode::PickRecent; render(); } return; } switch (optIdx) { case 0: // New Contact beginNewContact(""); render(); break; case 1: // Add from Recent recentUnknowns = book.unknownRecentNumbers(); recentSelected = 0; beginNewContact(""); mode = ViewMode::PickRecent; render(); break; case 2: // Call executeDetailAction(0); break; case 3: // Send SMS executeDetailAction(1); break; case 4: // Edit executeDetailAction(4); break; case 5: // Delete executeDetailAction(5); break; } }; while (gRunning) { for (const std::string& cmd : readFifoLines(fifoFd)) { if (cmd == "NOP") { continue; } else if (cmd == "CLOSE") { hideWindow(false); } else if (cmd == "TOGGLE") { if (visible) { hideWindow(false); } else { showWindow(""); } } else if (cmd == "OPEN") { showWindow(""); } else if (cmd.rfind("OPEN ", 0) == 0) { showWindow(cmd.substr(5)); } } if (visible && mode == ViewMode::Edit && editField == 1 && zeroHeld) { if (!isKeycodeDown(dpy, 19)) { zeroHeld = false; } else if (nowMs() - zeroPressMs >= 420) { zeroHeld = false; if (!editNumber.empty() && editNumber.back() == '0') { editNumber.back() = '+'; render(); } } } if (visible && !initialSoftkeysReleased && querySoftkeysDown(dpy) == 0) { initialSoftkeysReleased = true; } while (XPending(dpy)) { XEvent ev; XNextEvent(dpy, &ev); if (!visible) { continue; } if (!initialSoftkeysReleased && querySoftkeysDown(dpy) == 0) { initialSoftkeysReleased = true; } if (ev.type == Expose) { if (ev.xexpose.count == 0) { render(); } } else if (ev.type == KeyRelease) { KeySym sym = XLookupKeysym(&ev.xkey, 0); if (ev.xkey.keycode == 19 || sym == XK_0) { zeroHeld = false; } if (sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 || ev.xkey.keycode == 135 || ev.xkey.keycode == 166) { if (querySoftkeysDown(dpy) == 0) { initialSoftkeysReleased = true; } } } else if (ev.type == KeyPress) { KeySym sym = XLookupKeysym(&ev.xkey, 0); bool isLeftSoft = (sym == XK_Menu || sym == XF86XK_KbdLcdMenu1 || ev.xkey.keycode == 135); bool isRightSoft = (sym == XF86XK_Back || sym == XF86XK_KbdLcdMenu2 || ev.xkey.keycode == 166); if (isLeftSoft || isRightSoft) { if (!initialSoftkeysReleased || nowMs() < startupIgnoreUntilMs) { continue; } int which = (isLeftSoft ? 1 : 0) | (isRightSoft ? 2 : 0); softkeyPending |= which | querySoftkeysDown(dpy); if (softkeyPending == 3) { writeSoftkeySuppress(nowMs() + 500); hideWindow(false); if (!sendPluginCommand("xdrawer", "TOGGLE")) { runAsyncArgv("xdrawer"); } break; } if (softkeyDeadlineMs == 0) { softkeyDeadlineMs = nowMs() + 40; } continue; } if (sym == XK_Escape) { if (mode == ViewMode::List) { writeSoftkeySuppress(nowMs() + 300); hideWindow(true); } else if (mode == ViewMode::PickRecent) { mode = ViewMode::Edit; render(); } else { mode = ViewMode::List; render(); } continue; } // Handle mode-specific keys if (mode == ViewMode::List) { char dialCh = mapDialKey(sym, ev.xkey.keycode, ev.xkey.state); if (dialCh >= '0' && dialCh <= '9') { searchQuery.push_back(dialCh); selected = 0; updateFilter(); render(); continue; } switch (sym) { case XK_BackSpace: case XK_Left: case XK_KP_Left: if (!searchQuery.empty()) { searchQuery.pop_back(); selected = 0; updateFilter(); render(); } else if (sym == XK_BackSpace) { hideWindow(true); } break; case XK_Up: case XK_KP_Up: if (!filtered.empty()) { selected = (selected <= 0) ? (static_cast(filtered.size()) - 1) : (selected - 1); render(); } break; case XK_Down: case XK_KP_Down: if (!filtered.empty()) { selected = (selected + 1 >= static_cast(filtered.size())) ? 0 : (selected + 1); render(); } break; case XF86XK_Phone: case XF86XK_PickupPhone: if (selected >= 0 && selected < static_cast(filtered.size())) { std::string target = filtered[selected].number; writeSoftkeySuppress(nowMs() + 350); hideWindow(true); placePhoneCall(target); } break; case XK_Return: case XK_KP_Enter: if (filtered.empty()) { beginNewContact(searchQuery); } else { detailSelected = 0; mode = ViewMode::Detail; } render(); break; default: if (ev.xkey.keycode == 177 && selected >= 0 && selected < static_cast(filtered.size())) { std::string target = filtered[selected].number; writeSoftkeySuppress(nowMs() + 350); hideWindow(true); placePhoneCall(target); } break; } } else if (mode == ViewMode::Options) { int optCount = filtered.empty() ? 2 : 6; switch (sym) { case XK_Up: case XK_KP_Up: optionSelected = (optionSelected <= 0) ? (optCount - 1) : (optionSelected - 1); render(); break; case XK_Down: case XK_KP_Down: optionSelected = (optionSelected + 1 >= optCount) ? 0 : (optionSelected + 1); render(); break; case XK_Return: case XK_KP_Enter: executeOptionAction(optionSelected); break; case XK_BackSpace: mode = ViewMode::List; render(); break; default: break; } } else if (mode == ViewMode::Detail) { constexpr int kActCount = 6; switch (sym) { case XK_Up: case XK_KP_Up: detailSelected = (detailSelected <= 0) ? (kActCount - 1) : (detailSelected - 1); render(); break; case XK_Down: case XK_KP_Down: detailSelected = (detailSelected + 1 >= kActCount) ? 0 : (detailSelected + 1); render(); break; case XK_Return: case XK_KP_Enter: executeDetailAction(detailSelected); break; case XF86XK_Phone: case XF86XK_PickupPhone: executeDetailAction(0); break; case XK_BackSpace: case XK_Left: case XK_KP_Left: mode = ViewMode::List; render(); break; default: if (ev.xkey.keycode == 177) { executeDetailAction(0); } break; } } else if (mode == ViewMode::Edit) { char dialCh = mapDialKey(sym, ev.xkey.keycode, ev.xkey.state); if (editField == 0) { if (dialCh == '#') { caseMode = static_cast( (static_cast(caseMode) + 1) % 4); lastTapDigit = -1; render(); continue; } if (dialCh == '*') { editName.push_back(' '); lastTapDigit = -1; render(); continue; } if (dialCh >= '0' && dialCh <= '9') { handleMultiTapNameChar(dialCh - '0'); render(); continue; } if (sym >= XK_a && sym <= XK_z) { char c = static_cast('a' + (sym - XK_a)); if (ev.xkey.state & ShiftMask) { c = static_cast(std::toupper(static_cast(c))); } editName.push_back(c); lastTapDigit = -1; render(); continue; } if (sym == XK_space) { editName.push_back(' '); lastTapDigit = -1; render(); continue; } } else if (editField == 1) { if (dialCh != '\0') { editNumber.push_back(dialCh); if (dialCh == '0') { zeroHeld = true; zeroPressMs = nowMs(); } else { zeroHeld = false; } render(); continue; } } switch (sym) { case XK_Up: case XK_KP_Up: editField = (editField <= 0) ? 3 : (editField - 1); lastTapDigit = -1; render(); break; case XK_Down: case XK_KP_Down: editField = (editField >= 3) ? 0 : (editField + 1); lastTapDigit = -1; render(); break; case XK_BackSpace: case XK_Left: case XK_KP_Left: lastTapDigit = -1; if (editField == 0 && !editName.empty()) { editName.pop_back(); render(); } else if (editField == 1 && !editNumber.empty()) { editNumber.pop_back(); render(); } else if (sym == XK_BackSpace) { mode = prevModeBeforeEdit; render(); } break; case XK_Right: case XK_KP_Right: lastTapDigit = -1; if (editField == 1 && (editNumber.empty() || editNumber[0] != '+')) { editNumber = "+" + editNumber; render(); } break; case XK_Return: case XK_KP_Enter: lastTapDigit = -1; if (editField == 0) { editField = 1; render(); } else if (editField == 1) { if (!saveEditedContact()) { editField = editName.empty() ? 0 : 1; } render(); } else if (editField == 2) { recentUnknowns = book.unknownRecentNumbers(); recentSelected = 0; mode = ViewMode::PickRecent; render(); } else if (editField == 3) { if (!saveEditedContact()) { editField = editName.empty() ? 0 : 1; } render(); } break; default: break; } } else if (mode == ViewMode::PickRecent) { switch (sym) { case XK_Up: case XK_KP_Up: if (!recentUnknowns.empty()) { recentSelected = (recentSelected <= 0) ? (static_cast(recentUnknowns.size()) - 1) : (recentSelected - 1); render(); } break; case XK_Down: case XK_KP_Down: if (!recentUnknowns.empty()) { recentSelected = (recentSelected + 1 >= static_cast(recentUnknowns.size())) ? 0 : (recentSelected + 1); render(); } break; case XK_Return: case XK_KP_Enter: if (recentSelected >= 0 && recentSelected < static_cast(recentUnknowns.size())) { editNumber = recentUnknowns[recentSelected]; editField = editName.empty() ? 0 : 3; } mode = ViewMode::Edit; render(); break; case XK_BackSpace: case XK_Left: case XK_KP_Left: mode = ViewMode::Edit; render(); break; default: break; } } else if (mode == ViewMode::ConfirmDelete) { switch (sym) { case XK_Up: case XK_KP_Up: case XK_Down: case XK_KP_Down: confirmSelected = 1 - confirmSelected; render(); break; case XK_Return: case XK_KP_Enter: if (confirmSelected == 1 && selected >= 0 && selected < static_cast(filtered.size())) { book.deleteContact(filtered[selected].number, filtered[selected].name); contactsStamp.checkAndUpdate(getContactsFilePath()); updateFilter(); mode = ViewMode::List; } else { mode = ViewMode::Detail; } render(); break; case XK_BackSpace: mode = ViewMode::Detail; render(); break; default: break; } } } } if (visible && softkeyPending != 0 && nowMs() >= softkeyDeadlineMs) { int comb = softkeyPending | querySoftkeysDown(dpy); softkeyPending = 0; softkeyDeadlineMs = 0; if (comb == 3) { writeSoftkeySuppress(nowMs() + 500); hideWindow(false); if (!sendPluginCommand("xdrawer", "TOGGLE")) { runAsyncArgv("xdrawer"); } } else if (comb == 1) { // Left softkey if (mode == ViewMode::List) { optionSelected = 0; mode = ViewMode::Options; render(); } else if (mode == ViewMode::Options) { executeOptionAction(optionSelected); } else if (mode == ViewMode::Detail) { if (selected >= 0 && selected < static_cast(filtered.size())) { beginEditContact(filtered[selected]); render(); } } else if (mode == ViewMode::Edit) { if (!saveEditedContact()) { editField = editName.empty() ? 0 : 1; } render(); } else if (mode == ViewMode::PickRecent) { if (recentSelected >= 0 && recentSelected < static_cast(recentUnknowns.size())) { editNumber = recentUnknowns[recentSelected]; editField = editName.empty() ? 0 : 3; } mode = ViewMode::Edit; render(); } else if (mode == ViewMode::ConfirmDelete) { if (selected >= 0 && selected < static_cast(filtered.size())) { book.deleteContact(filtered[selected].number, filtered[selected].name); contactsStamp.checkAndUpdate(getContactsFilePath()); updateFilter(); } mode = ViewMode::List; render(); } } else if (comb == 2) { // Right softkey if (mode == ViewMode::List) { if (!searchQuery.empty()) { searchQuery.clear(); selected = 0; updateFilter(); render(); } else { writeSoftkeySuppress(nowMs() + 350); hideWindow(true); } } else if (mode == ViewMode::Options) { mode = ViewMode::List; render(); } else if (mode == ViewMode::Detail) { mode = ViewMode::List; render(); } else if (mode == ViewMode::Edit) { lastTapDigit = -1; if (editField == 0 && !editName.empty()) { editName.pop_back(); render(); } else if (editField == 1 && !editNumber.empty()) { editNumber.pop_back(); render(); } else { mode = (prevModeBeforeEdit == ViewMode::Detail) ? ViewMode::Detail : ViewMode::List; render(); } } else if (mode == ViewMode::PickRecent) { mode = ViewMode::Edit; render(); } else if (mode == ViewMode::ConfirmDelete) { mode = ViewMode::Detail; render(); } } } int timeoutMs = -1; if (visible) { timeoutMs = 200; if (softkeyDeadlineMs > 0) { timeoutMs = static_cast(std::max(5, softkeyDeadlineMs - nowMs())); } if (zeroHeld) { int64_t rem = 420 - (nowMs() - zeroPressMs); timeoutMs = std::min(timeoutMs, static_cast(std::max(5, rem))); } } struct pollfd pfds[2] {}; pfds[0].fd = xfd; pfds[0].events = POLLIN; pfds[1].fd = fifoFd; pfds[1].events = POLLIN; ::poll(pfds, (fifoFd >= 0) ? 2 : 1, timeoutMs); } hideWindow(false); if (fifoFd >= 0) { ::close(fifoFd); } ::unlink(getPluginFifoPath("contacts").c_str()); ::unlink(daemonPidPath.c_str()); pango_font_description_free(descIcon); pango_font_description_free(descSmall); pango_font_description_free(descMain); g_object_unref(layoutIcon); g_object_unref(layoutSmall); g_object_unref(layoutMain); g_object_unref(pangoCtx); XftDrawDestroy(xftDraw); XFreeGC(dpy, gc); XFreePixmap(dpy, pix); XDestroyWindow(dpy, win); XCloseDisplay(dpy); return 0; } int contacts_plugin_init(const DwmHostApi* host) { if (host && host->spawn_plugin) { host->spawn_plugin("contacts", "--preload"); } return 0; } int contacts_on_idle_key(const DwmHostApi* host, KeySym keysym, unsigned int keycode, unsigned int state) { if (!host || !host->is_idle_desktop || !host->is_idle_desktop() || state != 0) { return 0; } if (keysym == XF86XK_Back || keysym == XF86XK_KbdLcdMenu2 || keycode == 166) { if (host->spawn_plugin) { host->spawn_plugin("contacts", nullptr); } return 1; } return 0; } } // namespace extern "C" __attribute__((visibility("default"))) const DwmPlugin dwm_plugin_entry = { DWM_PLUGIN_API_VERSION, "contacts", "Keypad-driven phonebook & contact manager (opens on idle Right Softkey / Names)", contacts_plugin_init, nullptr, nullptr, contacts_on_idle_key, nullptr, runContactsApp };