commit 946d93faa70e663d627e492d708d4eafc5f1b6c8 Author: aibrash Date: Fri Oct 30 13:50:27 2026 +0500 Initial commit: Hyprland 0.57 rice for Fedora 44 Dark minimalist setup for a sysadmin workstation: rounded blur windows with the DMS pink accent, keyboard-driven SSH hub (Mod+S), run-or-raise app binds, Alt+Tab window switching, OCR of screen regions, hotkey cheatsheet, DMS network-editor plugin and the systemd session target for portals. Co-Authored-By: Claude Sonnet 5.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..47f47c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +# generated by DMS / local state +hypr/dms/ +*.bak* +__pycache__/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3af822a --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# hyprland-rising + +Hyprland 0.57 на Fedora 44: тёмный минималистичный райс для рабочей машины сисадмина. +Скруглённые окна с блюром, розовый акцент из темы DMS, всё управляется с клавиатуры. + +## Что внутри + +| Путь | Что это | +|---|---| +| `hypr/hyprland.lua` | основной конфиг (Lua-формат Hyprland 0.57): оформление, анимации, бинды, правила окон | +| `hypr/hypridle.conf` | затемнение (2,5 мин) → блокировка (5 мин) → погасание экрана (10 мин) | +| `hypr/cheatsheet.txt` | справка по хоткеям, открывается на `Mod+F1` (поиск по тексту) | +| `hypr/scripts/ssh-menu.sh`, `ssh_hosts.py` | SSH-меню на `Mod+S` (см. ниже) | +| `hypr/scripts/raise.sh` | «показать, если запущено, иначе запустить» для `Mod+B/T/K` | +| `hypr/scripts/prev-window.sh` | `Alt+Tab` — предыдущее окно | +| `hypr/scripts/ocr.sh` | `Mod+Shift+O` — область экрана → текст в буфер (рус+англ) | +| `hypr/scripts/tmux-broadcast.conf` | отдельный tmux для broadcast-окон SSH-меню | +| `dms/plugins/networkEditor` | плагин DMS: кнопка в панели, открывает `nm-connection-editor` | +| `dms/settings.json` | настройки DMS (панель, тема `pink`, лаунчер) | +| `systemd/user/hyprland-session.target` | нужен порталам (`xdg-desktop-portal`) вне uwsm | +| `kitty/kitty.conf` | терминал | +| `sync.sh` | копирует живой конфиг из `~/.config` в репозиторий | + +Панель, уведомления, лаунчер, блокировка, обои и буфер обмена — это +DankMaterialShell (DMS), Hyprland отвечает только за окна. + +## SSH-меню (`Mod+S`) + +Плавающее окно (fzf в kitty) со всеми хостами из `~/.ssh/config`: +статус доступности, группы цветом, плашка `PROD`, превью с пингом. +`Enter` — подключиться (окно становится обычным), `Alt+T` tmux на сервере, +`Alt+F` SFTP, `Alt+W` веб-интерфейс, `Alt+K` разослать ключ, `Alt+N` новый хост, +`Alt+E` править конфиг, `Alt+G` группа, `Alt+R` PROD, `Tab` отметить, +`Alt+B` broadcast в tmux на отмеченные хосты, `Alt+X` команда на отмеченных хостах. + +Метаданные хоста хранятся комментариями прямо над `Host` в `~/.ssh/config`: + +``` +# group: Proxmox +# env: prod +# url: https://10.0.0.10:8006 +Host proxmox1 + HostName 10.0.0.10 + User root +``` + +Сам `~/.ssh/config` и ключи в репозиторий не входят. Перед любой записью меню делает +бэкап в `~/.local/state/ssh-menu/backups`. Для хостов с `env: prod` перед broadcast +и запуском команды спрашивается подтверждение. + +## Установка + +Hyprland нет в репозиториях Fedora 44, он ставится из COPR: + +```bash +sudo dnf copr enable sachesi/hyprland +sudo dnf install hyprland hyprlock hypridle hyprpaper hyprpicker \ + xdg-desktop-portal-hyprland hyprland-guiutils hyprpolkitagent cliphist playerctl +# инструменты, которые используют скрипты +sudo dnf install fzf tmux kitty jq grim slurp wl-clipboard brightnessctl \ + nautilus keepassxc nm-connection-editor tesseract tesseract-langpack-eng \ + tesseract-langpack-rus ImageMagick jetbrains-mono-fonts +``` + +DMS ставится из COPR `avengemedia/dms` (см. документацию DMS). В автозапуске и на биндах +используются Flatpak-приложения: `com.google.Chrome`, `org.telegram.desktop`, +`org.remmina.Remmina`, `com.nextcloud.desktopclient.nextcloud`. + +```bash +git clone git@git.ibrash.duckdns.org:aibrash/hyprland-rising.git +cd hyprland-rising +cp -r hypr ~/.config/ +mkdir -p ~/.config/dms/plugins ~/.config/systemd/user +cp -r dms/plugins/networkEditor ~/.config/dms/plugins/ +cp systemd/user/hyprland-session.target ~/.config/systemd/user/ +systemctl --user daemon-reload +``` + +Чтобы плагин `networkEditor` заработал, включите его в настройках DMS (Plugins) и добавьте виджет +в панель. Либо скопируйте `dms/settings.json` в `~/.config/dms/` и создайте +`~/.config/dms/plugin_settings.json` с содержимым `{"networkEditor": {"enabled": true}}`, затем `dms restart`. +После этого выберите в GDM сессию **Hyprland** (не `hyprland-uwsm`). + +## Под себя + +* В `hypr/hyprland.lua` имена мониторов (`eDP-1`, `HDMI-A-1`) и масштаб 1.25 — под конкретный ноутбук. +* Раскладка (`us,ru`, переключение Alt+Shift), чувствительность тачпада и мыши — там же, в блоке `input`. +* Блокировку экрана делает DMS через `hypr/scripts/lock.sh` (с запасным вариантом swaylock). + +## Обновление репозитория + +```bash +./sync.sh && git add -A && git commit -m "…" && git push +``` diff --git a/dms/plugins/networkEditor/NetworkEditorWidget.qml b/dms/plugins/networkEditor/NetworkEditorWidget.qml new file mode 100644 index 0000000..a363a34 --- /dev/null +++ b/dms/plugins/networkEditor/NetworkEditorWidget.qml @@ -0,0 +1,31 @@ +import QtQuick +import Quickshell +import qs.Common +import qs.DCommon.Widgets +import qs.Modules.Plugins + +// One icon in the bar: click opens the full NetworkManager editor. +// Under Hyprland an already open editor is focused instead of starting a second one (raise.sh falls back to a plain launch). +PluginComponent { + id: root + + pillClickAction: () => { + Quickshell.execDetached(["sh", "-c", "~/.config/hypr/scripts/raise.sh '^nm-connection-editor$' nm-connection-editor || nm-connection-editor"]); + } + + horizontalBarPill: Component { + DIcon { + name: "settings_ethernet" + color: Theme.surfaceText + size: root.iconSize + } + } + + verticalBarPill: Component { + DIcon { + name: "settings_ethernet" + color: Theme.surfaceText + size: root.iconSize + } + } +} diff --git a/dms/plugins/networkEditor/plugin.json b/dms/plugins/networkEditor/plugin.json new file mode 100644 index 0000000..902b6c0 --- /dev/null +++ b/dms/plugins/networkEditor/plugin.json @@ -0,0 +1,13 @@ +{ + "id": "networkEditor", + "name": "Network Editor", + "description": "Bar button that opens nm-connection-editor (full NetworkManager editor: VPN, WireGuard, IP/DNS, routes)", + "version": "1.0.0", + "author": "aibrash", + "type": "widget", + "capabilities": ["dankbar-widget"], + "component": "./NetworkEditorWidget.qml", + "icon": "settings_ethernet", + "requires": ["nm-connection-editor"], + "permissions": [] +} diff --git a/dms/settings.json b/dms/settings.json new file mode 100644 index 0000000..974be98 --- /dev/null +++ b/dms/settings.json @@ -0,0 +1,778 @@ +{ + "dockConfigs": [ + { + "id": "dock", + "name": "Dock", + "enabled": false, + "screenPreferences": [ + "all" + ], + "showOnLastDisplay": true, + "position": 2, + "mode": "compact", + "taskbarAlign": "center", + "widgetExpansion": "popout", + "iconSize": 40, + "spacing": 4, + "itemSpacing": 4, + "margin": 0, + "bottomGap": 0, + "transparency": 1, + "followInterfaceStyle": true, + "autoHide": false, + "smartAutoHide": false, + "useOverlayLayer": false, + "editOnRightClick": false, + "showOnFullscreen": false, + "openOnOverview": false, + "groupByApp": false, + "separatePinnedAndRunningApps": false, + "restoreSpecialWorkspaceOnClick": false, + "isolateDisplays": false, + "indicatorStyle": "circle", + "borderEnabled": false, + "borderColor": "surfaceText", + "borderOpacity": 1, + "borderThickness": 1, + "launcherEnabled": false, + "launcherLogoMode": "apps", + "launcherLogoCustomPath": "", + "launcherLogoColorOverride": "", + "launcherLogoSizeOffset": 0, + "launcherLogoBrightness": 0.5, + "launcherLogoContrast": 1, + "maxVisibleApps": 0, + "maxVisibleRunningApps": 0, + "showOverflowBadge": true, + "showTrash": false, + "trashFileManager": "default", + "trashCustomCommand": "", + "magnification": false, + "magnificationScale": 130, + "magnificationProfile": "parabolic", + "magnificationExpand": false, + "order": [], + "widgets": [ + { + "id": "dock_launcher", + "widgetId": "dockLauncher", + "enabled": true + }, + { + "id": "dock_apps", + "widgetId": "appsDock", + "enabled": true + }, + { + "id": "dock_trash", + "widgetId": "dockTrash", + "enabled": true + } + ] + } + ], + "barElevationEnabled": false, + "barConfigs": [ + { + "id": "default", + "name": "Main Bar", + "enabled": true, + "position": 0, + "screenPreferences": [ + "all" + ], + "showOnLastDisplay": true, + "leftWidgets": [ + { + "id": "launcherButton", + "enabled": true + }, + { + "id": "workspaceSwitcher", + "enabled": true + }, + { + "id": "focusedWindow", + "enabled": true + } + ], + "centerWidgets": [ + "music", + "clock", + "weather" + ], + "rightWidgets": [ + { + "id": "systemTray", + "enabled": true + }, + { + "id": "vpn", + "enabled": true + }, + { + "id": "networkEditor", + "enabled": true + }, + "keyboard_layout_name", + "clipboard", + "cpuUsage", + "memUsage", + "notificationButton", + { + "id": "battery", + "enabled": true + }, + { + "id": "controlCenterButton", + "enabled": true + } + ], + "spacing": 4, + "innerPadding": 4, + "bottomGap": 0, + "transparency": 1, + "widgetTransparency": 1, + "squareCorners": false, + "noBackground": false, + "gothCornersEnabled": false, + "gothCornerRadiusOverride": false, + "gothCornerRadiusValue": 12, + "borderEnabled": false, + "borderColor": "surfaceText", + "borderOpacity": 1, + "borderThickness": 1, + "fontScale": 1, + "autoHide": false, + "autoHideDelay": 250, + "openOnOverview": false, + "visible": true, + "popupGapsAuto": true, + "popupGapsManual": 4, + "followInterfaceStyle": true, + "widgetFollowInterfaceStyle": true, + "dot": false, + "island": true, + "islandFloating": false, + "islandPlacement": "free", + "islandOuterGap": 0, + "islandAlongOffset": 0, + "islandReserveThickness": 41, + "islandRouteLauncher": "follow", + "islandRouteControlCenter": "follow", + "islandInteractionMode": "hybrid", + "islandChargingPulse": true, + "hoverPopouts": false, + "hoverPopoutDelay": 200, + "islandHomeLayout": [ + { + "id": "workspaces", + "enabled": false + }, + { + "id": "media", + "enabled": true + }, + { + "id": "clock", + "enabled": true + }, + { + "id": "weather", + "enabled": false + }, + { + "id": "status", + "enabled": false + }, + { + "id": "volume", + "enabled": false + }, + { + "id": "brightness", + "enabled": false + }, + { + "id": "notifications", + "enabled": false + }, + { + "id": "privacy", + "enabled": false + }, + { + "id": "tray", + "enabled": false + } + ] + }, + { + "id": "bar1793185825664", + "name": "Bar 2", + "enabled": false, + "position": 1, + "screenPreferences": [ + "all" + ], + "showOnLastDisplay": true, + "leftWidgets": [ + "launcherButton", + "workspaceSwitcher", + "focusedWindow" + ], + "centerWidgets": [ + "music", + "clock", + "weather" + ], + "rightWidgets": [ + "systemTray", + "privacyIndicator", + "vpn", + "keyboard_layout_name", + "clipboard", + "cpuUsage", + "cpuTemp", + "memUsage", + "notificationButton", + "battery", + "controlCenterButton" + ], + "spacing": 4, + "innerPadding": 4, + "bottomGap": 0, + "transparency": 1, + "widgetTransparency": 1, + "squareCorners": false, + "noBackground": false, + "gothCornersEnabled": false, + "gothCornerRadiusOverride": false, + "gothCornerRadiusValue": 12, + "borderEnabled": false, + "borderColor": "surfaceText", + "borderOpacity": 1, + "borderThickness": 1, + "fontScale": 1, + "autoHide": false, + "autoHideDelay": 250, + "openOnOverview": false, + "visible": true, + "popupGapsAuto": true, + "popupGapsManual": 4, + "followInterfaceStyle": true, + "widgetFollowInterfaceStyle": true, + "islandFloating": false, + "islandPlacement": "free", + "islandOuterGap": 0, + "islandAlongOffset": 0, + "islandReserveThickness": 41, + "islandRouteLauncher": "follow", + "islandRouteControlCenter": "follow", + "islandInteractionMode": "hybrid", + "islandChargingPulse": true, + "hoverPopouts": false, + "hoverPopoutDelay": 200, + "hideOnLastDisplay": false + }, + { + "id": "dot1793185921302", + "name": "Dot", + "enabled": false, + "position": 0, + "screenPreferences": [ + "all" + ], + "showOnLastDisplay": true, + "leftWidgets": [ + "launcherButton", + "workspaceSwitcher", + "focusedWindow" + ], + "centerWidgets": [ + "music", + "clock", + "weather" + ], + "rightWidgets": [ + "systemTray", + "privacyIndicator", + "vpn", + "keyboard_layout_name", + "clipboard", + "cpuUsage", + "cpuTemp", + "memUsage", + "notificationButton", + "battery", + "controlCenterButton" + ], + "spacing": 4, + "innerPadding": 4, + "bottomGap": 0, + "transparency": 0.95, + "widgetTransparency": 1, + "squareCorners": false, + "noBackground": false, + "gothCornersEnabled": false, + "gothCornerRadiusOverride": false, + "gothCornerRadiusValue": 12, + "borderEnabled": false, + "borderColor": "surfaceText", + "borderOpacity": 1, + "borderThickness": 1, + "fontScale": 1, + "autoHide": false, + "autoHideDelay": 250, + "openOnOverview": false, + "visible": true, + "popupGapsAuto": true, + "popupGapsManual": 4, + "followInterfaceStyle": false, + "widgetFollowInterfaceStyle": true, + "dot": true, + "island": false, + "islandFloating": false, + "islandPlacement": "free", + "islandOuterGap": 0, + "islandAlongOffset": 0, + "islandReserveThickness": 41, + "islandRouteLauncher": "follow", + "islandRouteControlCenter": "follow", + "islandInteractionMode": "hybrid", + "islandChargingPulse": true, + "hoverPopouts": false, + "hoverPopoutDelay": 200 + } + ], + "configVersion": 42, + "popupTransparency": 0.95, + "fontFamily": "JetBrains Mono Medium", + "audioVisualizerEnabled": true, + "scrollTitleEnabled": true, + "dashOptions": { + "wallpaper": { + "panelColumns": 5 + } + }, + "batteryChargeLimit": 80, + "controlCenterColumns": 9, + "controlCenterWidgets": [ + { + "id": "user", + "enabled": true, + "w": 4, + "h": 1, + "col": 0, + "row": 0 + }, + { + "id": "settings", + "enabled": true, + "w": 1, + "h": 1, + "small": true, + "col": 4, + "row": 0 + }, + { + "id": "lock", + "enabled": true, + "w": 1, + "h": 1, + "small": true, + "col": 5, + "row": 0 + }, + { + "id": "power", + "enabled": true, + "w": 1, + "h": 1, + "small": true, + "col": 6, + "row": 0 + }, + { + "id": "brightnessSlider", + "enabled": true, + "w": 4, + "h": 1, + "col": 0, + "row": 1 + }, + { + "id": "volumeSlider", + "enabled": true, + "w": 4, + "h": 1, + "col": 4, + "row": 1 + }, + { + "id": "wifi", + "enabled": true, + "w": 4, + "h": 1, + "col": 0, + "row": 2 + }, + { + "id": "bluetooth", + "enabled": true, + "w": 4, + "h": 1, + "col": 4, + "row": 2 + }, + { + "id": "audioOutput", + "enabled": true, + "w": 4, + "h": 1, + "col": 0, + "row": 3 + }, + { + "id": "audioInput", + "enabled": true, + "w": 4, + "h": 1, + "col": 4, + "row": 3 + }, + { + "id": "nightMode", + "enabled": true, + "w": 1, + "h": 1, + "col": 7, + "row": 0, + "small": true + }, + { + "id": "battery", + "enabled": true, + "w": 4, + "h": 1, + "col": 0, + "row": 4 + }, + { + "id": "runningApps", + "enabled": true, + "w": 8, + "h": 1, + "small": true, + "col": 0, + "row": 5 + }, + { + "id": "edit", + "enabled": true, + "w": 1, + "h": 1, + "small": true, + "col": 8, + "row": 0 + }, + { + "id": "builtin_vpn", + "enabled": true, + "w": 4, + "h": 1, + "col": 4, + "row": 4 + } + ], + "monoFontFamily": "JetBrains Mono", + "displayFontFamily": "JetBrains Mono", + "fontWeight": 300, + "fontScale": 1, + "currentThemeName": "pink", + "wallpaperFillMode": "Fill", + "connectedFrameBarStyleBackups": {}, + "frameEnabled": false, + "dashCards": [ + { + "id": "clock", + "w": 2, + "h": 1, + "col": 0, + "row": 0 + }, + { + "id": "weather", + "w": 1, + "h": 1, + "col": 2, + "row": 0 + }, + { + "id": "notifications", + "w": 3, + "h": 5, + "col": 3, + "row": 0 + }, + { + "id": "calendar", + "w": 3, + "h": 3, + "col": 0, + "row": 1 + }, + { + "id": "media", + "w": 3, + "h": 1, + "col": 0, + "row": 4 + } + ], + "builtInPluginSettings": { + "dms_qr_generator": { + "trigger": "qrg" + }, + "dms_power": { + "trigger": "pw" + }, + "dms_clipboard_search": { + "trigger": "cb" + }, + "dms_settings_search": { + "trigger": "?" + } + }, + "launcherStyle": "island", + "dankLauncherV2Size": "compact", + "appLauncherGridColumns": 4, + "spotlightSectionViewModes": { + "apps": "grid" + }, + "dankLauncherV2BorderEnabled": false, + "dankLauncherV2BorderThickness": 5, + "dankLauncherV2BorderColor": "surfaceText", + "rememberLastQuery": true, + "bluetoothMprisEnabled": true, + "dashTabs": [ + { + "id": "wallpaper", + "enabled": true + }, + { + "id": "overview", + "enabled": true + }, + { + "id": "media", + "enabled": true + }, + { + "id": "weather", + "enabled": true + }, + { + "id": "wellbeing", + "enabled": true + }, + { + "id": "notifications", + "enabled": false + } + ], + "desktopWidgetInstances": [], + "notificationPopupPosition": -1, + "notificationFocusedMonitor": false, + "notificationOverlayEnabled": true, + "notificationCompactMode": false, + "osdPosition": 5, + "networkPreference": "auto", + "avatarRing": "none", + "lockScreenWidgetInstances": [ + { + "id": "lock_clock", + "widgetType": "desktopClock", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "style": "expressive", + "transparency": 0, + "weight": 0, + "twoTone": true, + "showDate": false, + "showDigitalSeconds": false, + "showAnalogNumbers": false, + "showAnalogSeconds": true, + "autoPosition": true, + "colorMode": "default", + "customColor": "#ffffff" + } + }, + { + "id": "lock_date", + "widgetType": "lockDate", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "format": "short", + "colorMode": "default", + "customColor": "#ffffff" + } + }, + { + "id": "lock_auth", + "widgetType": "lockAuth", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "style": "expressive", + "profileVisibility": "always", + "passwordVisibility": "always" + } + }, + { + "id": "lock_notifications", + "widgetType": "lockNotifications", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "mode": 2 + } + }, + { + "id": "lock_status", + "widgetType": "lockStatus", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "showMediaPlayer": true, + "showWeather": true, + "background": false + } + }, + { + "id": "lock_power", + "widgetType": "lockPower", + "enabled": false, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "shape": "round" + } + } + ], + "greeterWidgetInstances": [ + { + "id": "lock_clock", + "widgetType": "desktopClock", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "style": "expressive", + "transparency": 0, + "weight": 0, + "twoTone": true, + "showDate": false, + "showDigitalSeconds": false, + "showAnalogNumbers": false, + "showAnalogSeconds": true, + "autoPosition": true, + "colorMode": "default", + "customColor": "#ffffff" + } + }, + { + "id": "lock_date", + "widgetType": "lockDate", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "format": "short", + "colorMode": "default", + "customColor": "#ffffff" + } + }, + { + "id": "lock_auth", + "widgetType": "lockAuth", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "style": "expressive", + "profileVisibility": "always", + "passwordVisibility": "always" + } + }, + { + "id": "lock_status", + "widgetType": "lockStatus", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "showMediaPlayer": true, + "showWeather": true, + "background": false + } + }, + { + "id": "lock_power", + "widgetType": "lockPower", + "enabled": false, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true, + "shape": "round" + } + }, + { + "id": "greeter_session", + "widgetType": "greeterSession", + "enabled": true, + "config": { + "displayPreferences": [ + "all" + ], + "syncPositionAcrossScreens": true + } + } + ], + "notepadEdgeGap": 3, + "appDrawerSectionViewModes": { + "apps": "list" + }, + "dmsWindowsFloatingSeeded": [ + "hyprland" + ] +} \ No newline at end of file diff --git a/hypr/cheatsheet.txt b/hypr/cheatsheet.txt new file mode 100644 index 0000000..f183766 --- /dev/null +++ b/hypr/cheatsheet.txt @@ -0,0 +1,77 @@ +ПРОГРАММЫ +Mod+Return Терминал (kitty) +Mod+D Лаунчер приложений (DMS) +Mod+S SSH-меню (см. раздел ниже) +Mod+B Chrome: показать, если открыт (повтор — следующее окно Chrome), иначе запустить +Mod+Shift+B Новое окно Chrome +Mod+T Telegram: показать, если открыт, иначе запустить +Mod+K KeePassXC: показать, если открыт, иначе запустить +Mod+O Файлы (Nautilus) +Mod+` Выдвижной терминал (scratchpad) +Mod+Shift+V История буфера обмена + +ОКНА +Mod+H/J/K/L Фокус влево/вниз/вверх/вправо (также стрелки) +Mod+Shift+H/J/K/L Переместить окно (также стрелки) +Mod+Shift+Q Закрыть окно +Mod+F На весь экран +Mod+Shift+Space Плавающее / тайловое +Mod+Shift+F Плавающее / тайловое +Mod+C Центрировать плавающее окно +Mod+Shift+T Закрепить плавающее окно поверх всех +Mod+E Сменить направление разбиения +Mod+P Pseudo-tiling +Mod+W Собрать окна во вкладки (группа) +Mod+[ / Mod+] Предыдущая / следующая вкладка группы +Mod+R Режим ресайза (H/J/K/L или стрелки, Enter/Esc выход) +Mod+ЛКМ / ПКМ Перетащить / изменить размер окна мышью + +SSH-МЕНЮ (Mod+S) ● доступен ○ нет ◐ через jump +Enter Подключиться (окно становится обычным) +Alt+T SSH + tmux (attach/create "main" на сервере) +Alt+F SFTP в Nautilus +Alt+W Веб-интерфейс хоста в Chrome (первый раз спросит URL, сохранит как # url:) +Alt+Y Скопировать user@host:port +Alt+K Разослать SSH-ключ (ssh-copy-id) на отмеченные хосты / создать новый ключ +Alt+N Новый хост (мастер) → в ~/.ssh/config, бэкап в ~/.local/state/ssh-menu +Alt+E Править ~/.ssh/config в micro на этом хосте +Alt+G Задать группу отмеченным хостам +Alt+R Пометить/снять PROD (красный фон окна и заголовок) +Tab / Alt+A / Alt+Z Отметить хост / все / снять +Alt+B Broadcast: одна tmux-панель на отмеченный хост, ввод идёт во все (Alt+S в окне — вкл/выкл синхронизацию, Alt+Z — зум панели) +Alt+X Выполнить команду на отмеченных хостах параллельно (одинаковые ответы склеиваются) + Для PROD-хостов Alt+B / Alt+X сначала спрашивают подтверждение +Ввод user@host Подключиться к хосту, которого нет в списке +Esc Закрыть меню + +РАБОЧИЕ СТОЛЫ И МОНИТОРЫ +Mod+1..0 Перейти на стол 1..10 (повтор = назад) +Mod+Shift+1..0 Перенести окно на стол 1..10 +Alt+Tab Предыдущее окно (ещё раз — обратно) +Mod+Alt+Tab Следующее окно на этом столе +Mod+Tab Следующий стол +Mod+Shift+Tab Предыдущий стол +Mod+колесо мыши Листать столы +Жест 3 пальца Листать столы +Mod+, / Mod+. Фокус на левый / правый монитор +Mod+Shift+- Убрать окно в scratchpad +Mod+- Показать scratchpad + +СИСТЕМА +Mod+N Центр управления (DMS) +Mod+Shift+N Уведомления +Mod+Shift+X Заблокировать экран +Mod+Shift+E / P Меню питания +Mod+Shift+C Перезагрузить конфиг Hyprland +Mod+F1 Эта справка + +СКРИНШОТЫ +Print Область → файл + буфер +Mod+Shift+S Область → файл + буфер +Shift+Print Весь экран +Ctrl+Print Активное окно +Mod+Ctrl+C Пипетка цвета +Mod+Shift+O OCR: область экрана → текст в буфер (рус+англ) + +МУЛЬТИМЕДИА +Громкость / яркость / плеер: функциональные клавиши (OSD от DMS) diff --git a/hypr/hypridle.conf b/hypr/hypridle.conf new file mode 100644 index 0000000..3472673 --- /dev/null +++ b/hypr/hypridle.conf @@ -0,0 +1,22 @@ +general { + lock_cmd = ~/.config/hypr/scripts/lock.sh + before_sleep_cmd = ~/.config/hypr/scripts/lock.sh + after_sleep_cmd = hyprctl dispatch dpms on +} + +listener { + timeout = 150 + on-timeout = brightnessctl -s set 15% + on-resume = brightnessctl -r +} + +listener { + timeout = 300 + on-timeout = ~/.config/hypr/scripts/lock.sh +} + +listener { + timeout = 600 + on-timeout = hyprctl dispatch dpms off + on-resume = hyprctl dispatch dpms on +} diff --git a/hypr/hyprland.lua b/hypr/hyprland.lua new file mode 100644 index 0000000..a6c305d --- /dev/null +++ b/hypr/hyprland.lua @@ -0,0 +1,285 @@ +-- Hyprland 0.57 (Lua). Clean dark work setup; shell = DankMaterialShell. +-- Sway config stays in ~/.config/sway as a fallback session. + +local terminal = "kitty" +local mod = "SUPER" + +---------------- +---- OUTPUT ---- +---------------- +-- eDP-1 at 1.25 scale is 1536 logical px wide, so HDMI sits right of it. +hl.monitor({ output = "eDP-1", mode = "preferred", position = "0x0", scale = 1.25 }) +hl.monitor({ output = "HDMI-A-1", mode = "preferred", position = "1536x0", scale = 1 }) +hl.monitor({ output = "", mode = "preferred", position = "auto", scale = "auto" }) + +----------------- +---- STARTUP ---- +----------------- +hl.env("XCURSOR_SIZE", "24") +hl.env("HYPRCURSOR_SIZE", "24") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("QT_QPA_PLATFORM", "wayland;xcb") +hl.env("ELECTRON_OZONE_PLATFORM_HINT", "auto") + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP XDG_SESSION_TYPE") + hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY DISPLAY HYPRLAND_INSTANCE_SIGNATURE XDG_CURRENT_DESKTOP XDG_SESSION_TYPE") + -- portals (file pickers, screen sharing, Nautilus) need graphical-session.target; sway had sway-session.target for this + hl.exec_cmd("systemctl --user start hyprland-session.target") + hl.exec_cmd("sh -c 'pgrep -x dms >/dev/null || exec dms run'") + hl.exec_cmd("systemctl --user start hyprpolkitagent") + hl.exec_cmd("hypridle") + hl.exec_cmd("nm-applet --indicator") + hl.exec_cmd("wl-paste --type text --watch cliphist store") + hl.exec_cmd("wl-paste --type image --watch cliphist store") + hl.exec_cmd(terminal .. " --class scratch") + hl.exec_cmd("sh -c 'sleep 5; flatpak info com.nextcloud.desktopclient.nextcloud >/dev/null 2>&1 && ! pgrep -f \"[n]extcloud.*--background\" >/dev/null && flatpak run com.nextcloud.desktopclient.nextcloud --background'") + hl.exec_cmd("sh -c 'sleep 5; flatpak info org.remmina.Remmina >/dev/null 2>&1 && ! pgrep -f \"[R]emmina.* -i\" >/dev/null && flatpak run org.remmina.Remmina -i'") + hl.exec_cmd("sh -c 'sleep 8; command -v geary >/dev/null && ! pgrep -x geary >/dev/null && geary --gapplication-service'") +end) + +----------------------- +---- LOOK AND FEEL ---- +----------------------- +hl.config({ + general = { + gaps_in = 6, + gaps_out = 14, + border_size = 2, + col = { + active_border = { colors = { "rgba(e91e63ff)", "rgba(ff6398ff)", "rgba(f8bbd9ff)", "rgba(e91e63ff)" }, angle = 45 }, + inactive_border = "rgba(3a2a2eaa)", + }, + resize_on_border = true, + allow_tearing = false, + layout = "dwindle", + }, + decoration = { + rounding = 16, + rounding_power = 4, -- squircle corners + active_opacity = 1.0, + inactive_opacity = 0.96, + dim_inactive = true, + dim_strength = 0.06, + shadow = { + enabled = true, + range = 30, + render_power = 3, + color = 0x66e91e63, -- soft pink glow on the focused window + color_inactive = 0x66000000, + }, + blur = { + enabled = true, + size = 10, + passes = 4, + noise = 0.02, + vibrancy = 0.3, + brightness = 1.05, + popups = true, + }, + }, + animations = { enabled = true }, + binds = { workspace_back_and_forth = true }, + dwindle = { preserve_split = true, force_split = 2 }, -- new window always opens to the right/below + xwayland = { force_zero_scaling = true }, -- crisp X11 apps on the 1.25x laptop panel + cursor = { hide_on_key_press = true, inactive_timeout = 5 }, + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + focus_on_activate = true, + key_press_enables_dpms = true, -- wake the screen on any key / mouse move (hypridle turns it off) + mouse_move_enables_dpms = true, + animate_manual_resizes = true, + animate_mouse_windowdragging = true, + }, +}) + +hl.curve("easeOutQuint", { type = "bezier", points = { {0.23, 1}, {0.32, 1} } }) +hl.curve("easeInOutCubic", { type = "bezier", points = { {0.65, 0.05}, {0.36, 1} } }) +hl.curve("quick", { type = "bezier", points = { {0.15, 0}, {0.1, 1} } }) +hl.curve("linear", { type = "bezier", points = { {0, 0}, {1, 1} } }) +hl.curve("snappy", { type = "spring", mass = 1, stiffness = 340, damping = 24 }) +hl.curve("bouncy", { type = "spring", mass = 1, stiffness = 280, damping = 17 }) + +hl.animation({ leaf = "global", enabled = true, speed = 10, bezier = "default" }) +hl.animation({ leaf = "border", enabled = true, speed = 4, bezier = "easeOutQuint" }) +hl.animation({ leaf = "borderangle", enabled = true, speed = 60, bezier = "linear", style = "loop" }) -- slowly rotating gradient border +hl.animation({ leaf = "windows", enabled = true, speed = 3.2, spring = "snappy" }) +hl.animation({ leaf = "windowsIn", enabled = true, speed = 3.0, spring = "bouncy", style = "popin 88%" }) +hl.animation({ leaf = "windowsOut", enabled = true, speed = 1.2, bezier = "quick", style = "popin 88%" }) +hl.animation({ leaf = "fade", enabled = true, speed = 2, bezier = "quick" }) +hl.animation({ leaf = "fadeDim", enabled = true, speed = 2.5, bezier = "quick" }) +hl.animation({ leaf = "layers", enabled = true, speed = 2.6, bezier = "easeOutQuint" }) +hl.animation({ leaf = "layersIn", enabled = true, speed = 2.6, bezier = "easeOutQuint", style = "slide" }) +hl.animation({ leaf = "layersOut", enabled = true, speed = 1.0, bezier = "quick", style = "fade" }) +hl.animation({ leaf = "workspaces", enabled = true, speed = 3.0, bezier = "easeInOutCubic", style = "slide" }) +hl.animation({ leaf = "specialWorkspace", enabled = true, speed = 2.8, bezier = "easeOutQuint", style = "slidevert" }) + +-- No gaps/border/rounding for a single tiled window (sway smart_gaps equivalent). +hl.workspace_rule({ workspace = "w[tv1]", gaps_out = 0, gaps_in = 0 }) +hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 }) +hl.window_rule({ name = "smart-gaps-1", match = { float = false, workspace = "w[tv1]" }, border_size = 0, rounding = 0 }) +hl.window_rule({ name = "smart-gaps-2", match = { float = false, workspace = "f[1]" }, border_size = 0, rounding = 0 }) + +--------------- +---- INPUT ---- +--------------- +hl.config({ + input = { + kb_layout = "us,ru", + kb_options = "grp:alt_shift_toggle,grp_led:scroll", + follow_mouse = 2, -- click to focus, like sway's focus_follows_mouse no + sensitivity = -0.5, + touchpad = { + natural_scroll = true, + tap_to_click = true, + tap_button_map = "lrm", + disable_while_typing = true, + middle_button_emulation = true, + }, + }, +}) +hl.device({ name = "type:touchpad", sensitivity = 0.3 }) +hl.gesture({ fingers = 3, direction = "horizontal", action = "workspace" }) + +--------------------- +---- KEYBINDINGS ---- +--------------------- +local function b(keys, dsp, opts) hl.bind(keys, dsp, opts) end +local function dms(args) return hl.dsp.exec_cmd("dms ipc call " .. args) end + +b(mod .. " + Return", hl.dsp.exec_cmd(terminal)) +b(mod .. " + SHIFT + Q", hl.dsp.window.close()) +b(mod .. " + D", dms("spotlight toggle")) +b(mod .. " + SHIFT + E", dms("powermenu toggle")) +b(mod .. " + SHIFT + P", dms("powermenu toggle")) +b(mod .. " + SHIFT + C", hl.dsp.reload_config()) +b(mod .. " + N", dms("control-center toggle")) +b(mod .. " + SHIFT + N", dms("notifications toggle")) +b(mod .. " + SHIFT + V", dms("clipboard toggle")) +b(mod .. " + SHIFT + X", hl.dsp.exec_cmd("~/.config/hypr/scripts/lock.sh")) +b(mod .. " + O", hl.dsp.exec_cmd("nautilus --new-window")) + +-- focus / move (vim keys + arrows) +local dirs = { h = "left", j = "down", k = "up", l = "right", left = "left", down = "down", up = "up", right = "right" } +for key, d in pairs(dirs) do + b(mod .. " + " .. key, hl.dsp.focus({ direction = d })) + b(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ direction = d })) +end + +-- layout +b(mod .. " + F", hl.dsp.window.fullscreen()) +b(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) +b(mod .. " + SHIFT + F", hl.dsp.window.float({ action = "toggle" })) +b(mod .. " + C", hl.dsp.window.center()) +b(mod .. " + E", hl.dsp.layout("togglesplit")) +b(mod .. " + P", hl.dsp.window.pseudo()) +b(mod .. " + W", hl.dsp.group.toggle()) +b("ALT + TAB", hl.dsp.exec_cmd("~/.config/hypr/scripts/prev-window.sh")) -- previous window (press again to go back) +b(mod .. " + ALT + TAB", hl.dsp.window.cycle_next()) -- next window on this workspace +b(mod .. " + TAB", hl.dsp.focus({ workspace = "e+1" })) +b(mod .. " + SHIFT + TAB", hl.dsp.focus({ workspace = "e-1" })) +b(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) +b(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) + +-- workspaces 1..10 +for i = 1, 10 do + local key = i % 10 + b(mod .. " + " .. key, hl.dsp.focus({ workspace = i })) + b(mod .. " + SHIFT + " .. key, hl.dsp.window.move({ workspace = i })) +end + +-- scratchpad +b(mod .. " + grave", hl.dsp.workspace.toggle_special("scratch")) +b(mod .. " + SHIFT + minus", hl.dsp.window.move({ workspace = "special:scratch" })) +b(mod .. " + minus", hl.dsp.workspace.toggle_special("scratch")) + +-- mouse +b(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +b(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) + +-- resize submap (mod+r, like sway's resize mode) +b(mod .. " + R", hl.dsp.submap("resize")) +hl.define_submap("resize", "reset", function() + b("h", hl.dsp.window.resize({ x = -40, y = 0, relative = true }), { repeating = true }) + b("l", hl.dsp.window.resize({ x = 40, y = 0, relative = true }), { repeating = true }) + b("k", hl.dsp.window.resize({ x = 0, y = -40, relative = true }), { repeating = true }) + b("j", hl.dsp.window.resize({ x = 0, y = 40, relative = true }), { repeating = true }) + b("left", hl.dsp.window.resize({ x = -40, y = 0, relative = true }), { repeating = true }) + b("right", hl.dsp.window.resize({ x = 40, y = 0, relative = true }), { repeating = true }) + b("up", hl.dsp.window.resize({ x = 0, y = -40, relative = true }), { repeating = true }) + b("down", hl.dsp.window.resize({ x = 0, y = 40, relative = true }), { repeating = true }) + b("Return", hl.dsp.submap("reset")) + b("Escape", hl.dsp.submap("reset")) +end) + +-- sysadmin toolbox +b(mod .. " + S", hl.dsp.exec_cmd("kitty --class ssh-menu -o font_size=12 -o env=SSH_MENU=1 --title 'ssh menu' ~/.config/hypr/scripts/ssh-menu.sh")) -- SSH hub (fzf) +local raise = "~/.config/hypr/scripts/raise.sh " +b(mod .. " + T", hl.dsp.exec_cmd(raise .. "'^org.telegram.desktop$' flatpak run org.telegram.desktop")) -- focus if open, else launch +b(mod .. " + B", hl.dsp.exec_cmd(raise .. "'^google-chrome$' flatpak run com.google.Chrome")) -- again = next Chrome window +b(mod .. " + SHIFT + B", hl.dsp.exec_cmd("flatpak run com.google.Chrome --new-window")) -- always a new window +b(mod .. " + F1", hl.dsp.exec_cmd("~/.config/hypr/scripts/cheatsheet.sh")) -- hotkey reference +b(mod .. " + K", hl.dsp.exec_cmd(raise .. "'^org.keepassxc.KeePassXC$' keepassxc")) +b(mod .. " + SHIFT + T", hl.dsp.window.pin()) -- keep a floating window on top +b(mod .. " + bracketright", hl.dsp.group.next()) +b(mod .. " + bracketleft", hl.dsp.group.prev()) + +-- two monitors: laptop <-> external (focus only) +b(mod .. " + comma", hl.dsp.focus({ monitor = "-1" })) +b(mod .. " + period", hl.dsp.focus({ monitor = "+1" })) + +-- screenshots (grim + slurp; saved to ~/Pictures/Screenshots and copied) +local shot = "mkdir -p ~/Pictures/Screenshots; f=~/Pictures/Screenshots/$(date +%F_%H-%M-%S).png; " +b("Print", hl.dsp.exec_cmd(shot .. "grim -g \"$(slurp)\" - | tee $f | wl-copy")) +b(mod .. " + SHIFT + S", hl.dsp.exec_cmd(shot .. "grim -g \"$(slurp)\" - | tee $f | wl-copy")) +b("SHIFT + Print", hl.dsp.exec_cmd(shot .. "grim - | tee $f | wl-copy")) +b("CTRL + Print", hl.dsp.exec_cmd(shot .. "grim -g \"$(hyprctl activewindow -j | jq -r '\"\\(.at[0]),\\(.at[1]) \\(.size[0])x\\(.size[1])\"')\" - | tee $f | wl-copy")) +b(mod .. " + CTRL + C", hl.dsp.exec_cmd("hyprpicker -a")) -- color picker +b(mod .. " + SHIFT + O", hl.dsp.exec_cmd("~/.config/hypr/scripts/ocr.sh")) -- screen region -> text in clipboard + +-- media / brightness / volume via DMS (shows OSD) +b("XF86AudioRaiseVolume", dms("audio increment 5"), { locked = true, repeating = true }) +b("XF86AudioLowerVolume", dms("audio decrement 5"), { locked = true, repeating = true }) +b("XF86AudioMute", dms("audio mute"), { locked = true }) +b("XF86AudioMicMute", dms("audio micmute"), { locked = true }) +b("XF86MonBrightnessUp", dms("brightness increment 5 \"\""), { locked = true, repeating = true }) +b("XF86MonBrightnessDown", dms("brightness decrement 5 \"\""), { locked = true, repeating = true }) +b("XF86AudioPlay", dms("mpris playPause"), { locked = true }) +b("XF86AudioNext", dms("mpris next"), { locked = true }) +b("XF86AudioPrev", dms("mpris previous"), { locked = true }) + +----------------- +---- RULES ------ +----------------- +hl.window_rule({ name = "suppress-maximize", match = { class = ".*" }, suppress_event = "maximize" }) +hl.window_rule({ + name = "xwayland-drag-fix", + match = { class = "^$", title = "^$", xwayland = true, float = true, fullscreen = false, pin = false }, + no_focus = true, +}) +hl.window_rule({ + name = "float-utils", + match = { class = "^(pavucontrol|nm-connection-editor|blueman-manager|org.pulseaudio.pavucontrol)$" }, + float = true, size = "700 500", center = true, +}) +hl.window_rule({ name = "float-mpv", match = { class = "^mpv$" }, float = true }) +hl.window_rule({ name = "idle-inhibit-fs", match = { class = "^(firefox|mpv|google-chrome|org.telegram.desktop)$" }, idle_inhibit = "fullscreen" }) +-- no auto-lock in the middle of an RDP/VNC session +hl.window_rule({ name = "idle-inhibit-rdp", match = { class = "^(org.remmina.Remmina|com.freerdp.FreeRDP|xfreerdp)$" }, idle_inhibit = "focus" }) +-- file dialogs from the portal and Chrome picture-in-picture +hl.window_rule({ name = "float-portal-dialogs", match = { class = "^xdg-desktop-portal-gtk$" }, float = true, size = "900 600", center = true }) +hl.window_rule({ name = "pip", match = { title = "^Picture in picture$" }, float = true, pin = true, size = "480 270", move = "monitor_w-500 monitor_h-290" }) +hl.window_rule({ + name = "scratch-term", + match = { class = "^scratch$" }, + workspace = "special:scratch silent", float = true, size = "1000 560", center = true, +}) + +hl.window_rule({ name = "float-ssh-menu", match = { class = "^ssh-menu$" }, float = true, size = "1400 780", center = true }) +hl.window_rule({ name = "float-keepass", match = { class = "^org.keepassxc.KeePassXC$" }, float = true, size = "1300 860", center = true }) + +-- Blur the DMS surfaces (bar, launcher, popups, notifications). +hl.layer_rule({ name = "dms-blur", match = { namespace = "^dms:.*" }, blur = true, blur_popups = true, ignore_alpha = 0.3 }) +hl.layer_rule({ name = "noanim-slurp", match = { namespace = "^selection$" }, no_anim = true }) diff --git a/hypr/scripts/cheatsheet.sh b/hypr/scripts/cheatsheet.sh new file mode 100755 index 0000000..df3b5d5 --- /dev/null +++ b/hypr/scripts/cheatsheet.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# Searchable hotkey reference; type to filter, Esc to close. +pkill -x fuzzel && exit 0 +fuzzel --dmenu --prompt 'hotkeys ❯ ' --width 70 --lines 24 \ + --font 'JetBrains Mono:size=13' \ + --background 191112f2 --text-color f0dee0ff --prompt-color e91e63ff \ + --selection-color 261d1eff --selection-text-color f0dee0ff \ + --border-color e91e63ff --border-width 2 --border-radius 16 \ + < "$HOME/.config/hypr/cheatsheet.txt" >/dev/null diff --git a/hypr/scripts/lock.sh b/hypr/scripts/lock.sh new file mode 100755 index 0000000..a57669a --- /dev/null +++ b/hypr/scripts/lock.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Lock with DankMaterialShell (matches the shell theme). Fall back to swaylock-effects / swaylock +# when DMS is not running or the IPC call fails. +pgrep -x swaylock >/dev/null && exit 0 +if pgrep -x dms >/dev/null && timeout 3 dms ipc call lock lock >/dev/null 2>&1; then + exit 0 +fi +fx=$HOME/.local/bin/swaylock-effects +if [ -x "$fx" ]; then + exec "$fx" -f -C "$HOME/.config/swaylock/config-effects" +fi +exec swaylock -f diff --git a/hypr/scripts/ocr.sh b/hypr/scripts/ocr.sh new file mode 100755 index 0000000..db0621e --- /dev/null +++ b/hypr/scripts/ocr.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Select a screen region, OCR it (English + Russian) and put the text in the clipboard. +if ! command -v tesseract >/dev/null; then + notify-send -a ocr "OCR" "tesseract is not installed:\nsudo dnf install tesseract tesseract-langpack-eng tesseract-langpack-rus" + exit 1 +fi +geom=$(slurp -b 00000066 -c e91e63ff -w 2) || exit 0 +img() { grim -g "$geom" -; } +if command -v magick >/dev/null; then img() { grim -g "$geom" - | magick png:- -resize 200% -colorspace Gray png:-; }; fi +text=$(img | tesseract stdin stdout -l eng+rus --psm 6 2>/dev/null | sed 's/[[:space:]]*$//') +[ -z "$text" ] && { notify-send -a ocr "OCR" "nothing recognised"; exit 1; } +printf '%s' "$text" | wl-copy +notify-send -a ocr "Copied $(printf '%s\n' "$text" | wc -l) line(s)" "$(printf '%s\n' "$text" | head -2)" diff --git a/hypr/scripts/prev-window.sh b/hypr/scripts/prev-window.sh new file mode 100755 index 0000000..efcfd80 --- /dev/null +++ b/hypr/scripts/prev-window.sh @@ -0,0 +1,8 @@ +#!/bin/bash +# Alt+Tab: jump to the previously focused window (press again to jump back). RAISE_DRY=1 only prints. +target=$(hyprctl clients -j | jq -r ' + [.[] | select(.workspace.name | startswith("special") | not) | select(.focusHistoryID > 0)] + | sort_by(.focusHistoryID) | .[0].address // empty') +[ -z "$target" ] && exit 0 +[ -n "$RAISE_DRY" ] && { echo "focus: $target"; exit 0; } +hyprctl dispatch "hl.dsp.focus({ window = \"address:$target\" })" >/dev/null diff --git a/hypr/scripts/raise.sh b/hypr/scripts/raise.sh new file mode 100755 index 0000000..0fac7ba --- /dev/null +++ b/hypr/scripts/raise.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# raise.sh +# Focus the app if it already has a window (also switches workspace/monitor). Pressing again while +# it is focused cycles through its other windows. If it is not running, launch . +# RAISE_DRY=1 only prints what would happen. +re=$1; shift +act=$(hyprctl activewindow -j 2>/dev/null | jq -r '.address // ""') +target=$(hyprctl clients -j | jq -r --arg re "$re" --arg act "$act" ' + [.[] | select(.class | test($re)) | select(.workspace.name | startswith("special") | not)] + | sort_by(.focusHistoryID) as $w + | if ($w | length) == 0 then "" + elif $w[0].address == $act and ($w | length) > 1 then $w[1].address + else $w[0].address end') +if [ -z "$target" ]; then + [ -n "$RAISE_DRY" ] && { echo "launch: $*"; exit 0; } + exec "$@" +fi +[ -n "$RAISE_DRY" ] && { echo "focus: $target"; exit 0; } +hyprctl dispatch "hl.dsp.focus({ window = \"address:$target\" })" >/dev/null diff --git a/hypr/scripts/ssh-menu.sh b/hypr/scripts/ssh-menu.sh new file mode 100755 index 0000000..b27fbba --- /dev/null +++ b/hypr/scripts/ssh-menu.sh @@ -0,0 +1,214 @@ +#!/bin/bash +# Keyboard-driven SSH hub (fzf). Launched by Mod+S in a floating kitty (class ssh-menu). +# Enter ssh Alt+T ssh+tmux Alt+F sftp Alt+Y copy addr Alt+K push key +# Alt+N new host Alt+E edit Alt+G group Alt+R toggle PROD Tab mark Alt+A/Z all/none +# Host metadata: "# group: X" / "# env: prod" comments above Host lines in ~/.ssh/config. +S=$(readlink -f "$0") +H="python3 -I $(dirname "$S")/ssh_hosts.py" +state=${XDG_STATE_HOME:-$HOME/.local/state}/ssh-menu +mkdir -p "$state"; touch "$state/history" + +c_ok=$'\e[32m'; c_err=$'\e[31m'; c_dim=$'\e[2m'; c_hi=$'\e[38;2;233;30;99m'; c_off=$'\e[0m' +say() { printf '%s\n' "$*"; } +ask() { local v; read -e -r -p "$c_hi$1$c_off ${2:+[$2] }" v; printf '%s' "${v:-$2}"; } +yes() { local v; read -r -p "$c_hi$1$c_off [Y/n] " v; [[ ! $v =~ ^[nNнН] ]]; } +pause(){ read -r -s -n1 -p "${c_dim}press any key${c_off}"; echo; } +fz() { fzf --layout=reverse --height=45% --border=rounded --prompt="$1" --pointer='▌' \ + --color='bg+:#261d1e,fg+:#ffffff,hl:#e91e63,hl+:#ff6398,pointer:#e91e63,prompt:#e91e63,border:#e91e63,info:#8e7a7d' "${@:2}"; } + +# ---------- connect ---------- +run_ssh() { # run_ssh [remote command...] + local t=$1; shift + echo "$t" >> "$state/history" + # become a normal tiled window once we are a session, not a menu + [ "$SSH_MENU" = 1 ] && hyprctl dispatch 'hl.dsp.window.float({ action = "disable" })' >/dev/null 2>&1 + local env; env=$($H env "$t") + if [ "$env" = prod ]; then printf '\e]2;⚠ PROD ssh: %s\a\e]11;#220a0f\a' "$t" + else printf '\e]2;ssh: %s\a' "$t"; fi + ssh "$t" "$@"; local rc=$? + printf '\e]111\a' + if [ $rc -ne 0 ]; then + printf '\n%sssh %s exited with %s%s (press Enter to close)' "$c_err" "$t" "$rc" "$c_off"; read -r _ + fi + exit $rc +} +cmd_connect() { local t=${1:-$2}; [ -n "$t" ] && run_ssh "$t"; } +cmd_tmux() { run_ssh "$1" -t 'command -v tmux >/dev/null && exec tmux new-session -A -s main || exec $SHELL -l'; } + +# ---------- quick actions ---------- +cmd_sftp() { + local a jump url + read -r a jump < <($H addr "$1") + # jump hosts: let gvfs go through ssh config by alias; otherwise explicit user@host:port + if [ -n "$jump" ]; then url="sftp://$1/"; else url="sftp://$a/"; fi + notify-send -a ssh-menu "SFTP" "opening $a …" + # detached, so it survives the menu window closing + setsid -f nautilus --new-window "$url" >/dev/null 2>&1 +} +cmd_copy() { read -r a _ < <($H addr "$1"); printf %s "$a" | wl-copy; notify-send -a ssh-menu "ssh" "copied $a"; } + +# ---------- keys ---------- +pick_key() { + local pubs sel name + mapfile -t pubs < <(ls "$HOME"/.ssh/*.pub 2>/dev/null) + sel=$( { printf '%s\n' "${pubs[@]}"; echo "+ generate new ed25519 key"; } | grep -v '^$' | fz 'key ❯ ' --height=30%) || return 1 + if [[ $sel == "+ generate"* ]]; then + name=$(ask "key file name" "id_ed25519_$(date +%y%m)") + [ -e "$HOME/.ssh/$name" ] && { say "${c_err}$name exists${c_off}" >&2; return 1; } + ssh-keygen -t ed25519 -f "$HOME/.ssh/$name" -C "$USER@$(hostname -s)" >&2 || return 1 + sel="$HOME/.ssh/$name.pub" + fi + printf '%s' "$sel" +} +cmd_keys() { + [ $# -eq 0 ] && return + local pub ok=() bad=() h + pub=$(pick_key) || return + say "${c_hi}key:${c_off} $pub ${c_hi}→${c_off} $*"; echo + for h in "$@"; do + say "${c_hi}── $h${c_off}" + if ssh-copy-id -i "$pub" "$h"; then ok+=("$h"); else bad+=("$h"); fi + done + echo; [ ${#ok[@]} -gt 0 ] && say "${c_ok}✓ ${ok[*]}${c_off}" + [ ${#bad[@]} -gt 0 ] && say "${c_err}✗ ${bad[*]}${c_off}" + pause +} + +# ---------- new host / edit / group ---------- +cmd_new() { + clear; say "${c_hi}── new SSH host ──${c_off} (Enter = default)"; echo + local alias hostname user port jump group env key + while :; do + alias=$(ask "alias (name in the menu)"); [ -z "$alias" ] && return + $H has "$alias" && { say "${c_err}already exists${c_off}"; continue; } + [[ $alias =~ [[:space:]*?!] ]] && { say "${c_err}no spaces or wildcards${c_off}"; continue; } + break + done + hostname=$(ask "HostName / IP" "$alias") + user=$(ask "User" "root") + port=$(ask "Port" "22") + jump=$(ask "ProxyJump (empty = none)") + group=$(ask "group (empty = none)") + yes "mark as PROD (red window)?" && env=prod || env= + key=$(ask "IdentityFile (empty = default)") + echo; say "${c_dim}Host $alias → $user@$hostname:$port ${jump:+via $jump }${group:+[$group] }${env:+PROD}${c_off}" + yes "save to ~/.ssh/config?" || return + $H add "$alias" "$hostname" "$user" "$port" "$jump" "$group" "$env" "$key" || { pause; return; } + say "${c_ok}saved${c_off} (backup in $state/backups)" + yes "push your public key to it now?" && cmd_keys "$alias" + echo "$alias" > "$state/lastnew" +} +cmd_edit() { + local ed=micro; command -v micro >/dev/null || ed=${EDITOR:-nano} + local l; l=$($H line "$1") + if [ "$ed" = micro ]; then micro "+$l" ~/.ssh/config; else "$ed" "+$l" ~/.ssh/config; fi +} +cmd_group() { + [ $# -eq 0 ] && return + local out rc g h + out=$($H groups | fz "group ❯ " --height=40% --print-query \ + --header "pick a group or type a new name (empty = clear)"); rc=$? + [ $rc -gt 1 ] && return # Esc + mapfile -t o <<< "$out" # line 1 = query, line 2 = selection (if any) + g=${o[1]:-${o[0]}} + for h in "$@"; do $H set "$h" group "$g"; done +} +cmd_envtoggle() { for h in "$@"; do $H toggle-prod "$h"; done; } + +# ---------- several hosts ---------- +confirm_prod() { # confirm_prod host... ; asks (default: no) only when PROD hosts are involved + local prod=() h v + for h in "$@"; do [ "$($H env "$h")" = prod ] && prod+=("$h"); done + [ ${#prod[@]} -eq 0 ] && return 0 + say "${c_err}${#prod[@]} of $# hosts are PROD:${c_off} ${prod[*]}" + read -r -p "${c_hi}continue?${c_off} [y/N] " v + [[ $v =~ ^[yYдД] ]] +} +cmd_broadcast() { # one tmux pane per host, keyboard input synced (Alt+S toggles) + [ $# -eq 0 ] && return + confirm_prod "$@" || return + local conf; conf="$(dirname "$S")/tmux-broadcast.conf" + local T=(tmux -L sshmenu -f "$conf") sess="bc-$(date +%H%M%S)" h first=1 + for h in "$@"; do + echo "$h" >> "$state/history" + if [ $first = 1 ]; then "${T[@]}" new-session -d -s "$sess" -x 220 -y 60 "ssh $(printf %q "$h")"; first=0 + else "${T[@]}" split-window -t "$sess" "ssh $(printf %q "$h")"; "${T[@]}" select-layout -t "$sess" tiled; fi + "${T[@]}" select-pane -t "$sess" -T "$h" + done + "${T[@]}" setw -t "$sess" synchronize-panes on + setsid -f kitty --title "broadcast: $# hosts" "${T[@]}" attach -t "$sess" >/dev/null 2>&1 +} +cmd_run() { # run one command on all marked hosts, identical answers merged + [ $# -eq 0 ] && return + local cmd v + set -o history; HISTFILE="$state/cmd_history"; history -r + say "${c_dim}run on: $*${c_off}" + read -e -r -p "${c_hi}\$${c_off} " cmd; [ -z "$cmd" ] && return + history -s "$cmd"; history -w + confirm_prod "$@" || return + clear + $H run "$cmd" "$@" | less -RFX + pause +} +cmd_web() { # open the host's web UI; first time asks for the URL and stores it as "# url:" + local url; url=$($H url "$1") + if [ -z "$url" ]; then + url=$(ask "web UI for $1" "$($H url-default "$1")"); [ -z "$url" ] && return + $H set "$1" url "$url" + fi + setsid -f flatpak run com.google.Chrome "$url" >/dev/null 2>&1 +} + +# ---------- dispatcher ---------- +case "$1" in + connect) shift; cmd_connect "$@"; exit ;; + tmux) shift; cmd_tmux "$@"; exit ;; + sftp) shift; cmd_sftp "$@"; exit ;; + copy) shift; cmd_copy "$@"; exit ;; + keys) shift; cmd_keys "$@"; exit ;; + new) cmd_new; exit ;; + edit) shift; cmd_edit "$@"; exit ;; + group) shift; cmd_group "$@"; exit ;; + envtoggle) shift; cmd_envtoggle "$@"; exit ;; + broadcast) shift; cmd_broadcast "$@"; exit ;; + run) shift; cmd_run "$@"; exit ;; + web) shift; cmd_web "$@"; exit ;; + preview) shift; $H preview "$1"; exit ;; + list) $H list; exit ;; + live) $H live; exit ;; +esac + +# ---------- menu ---------- +# hotkey table: accent key + dim description, 4 columns +key() { printf '\e[1;38;2;248;187;217m%-6s\e[0m\e[38;2;150;135;140m%-15s\e[0m' "$1" "$2"; } +hdr=$( + key Enter connect; key Alt+T tmux; key Alt+F sftp; key Alt+W web; echo + key Alt+Y 'copy addr'; key Alt+N 'new host'; key Alt+E 'edit config'; key Alt+R 'toggle PROD'; echo + key Tab mark; key Alt+A 'mark all'; key Alt+Z unmark; key Alt+G group; echo + key Alt+B broadcast; key Alt+X 'run cmd'; key Alt+K 'push key' +) + +$H list | fzf --ansi --multi --layout=reverse --height=100% --tiebreak=index \ + --style=full:rounded --padding=1,2 --highlight-line \ + --prompt=' ' --pointer='▌' --gutter=' ' --marker='✓' --info=inline-right --no-scrollbar \ + --input-label=' ssh ' --list-label=' hosts ' --header-label=' keys ' --preview-label=' details ' \ + --ghost='type to filter, or user@host to connect to anything' \ + --header "$hdr" \ + --color='bg+:#2a2024,fg+:#ffffff,hl:#e91e63,hl+:#ff6398,pointer:#e91e63,prompt:#e91e63,info:#8e7a7d,marker:#f8bbd9,spinner:#e91e63,ghost:#6b5a5e,gutter:-1' \ + --color='border:#3a2a2e,list-border:#3a2a2e,header-border:#3a2a2e,preview-border:#3a2a2e,input-border:#e91e63' \ + --color='label:#e91e63,input-label:#e91e63,list-label:#8e7a7d,header-label:#8e7a7d,preview-label:#8e7a7d,header:-1' \ + --preview "$S preview {2}" --preview-window='right:32%:wrap' \ + --bind "start:reload($S live)" \ + --bind "enter:become($S connect {2} {q})" \ + --bind "alt-t:become($S tmux {2})" \ + --bind "alt-f:execute-silent($S sftp {2})+abort" \ + --bind "alt-y:execute-silent($S copy {2})+abort" \ + --bind "alt-k:execute($S keys {+2})+reload($S list)" \ + --bind "alt-n:execute($S new)+reload($S list)+transform-query(cat $state/lastnew 2>/dev/null; rm -f $state/lastnew)" \ + --bind "alt-e:execute($S edit {2})+reload($S live)" \ + --bind "alt-g:execute($S group {+2})+reload($S list)" \ + --bind "alt-r:execute-silent($S envtoggle {+2})+reload($S list)" \ + --bind "alt-b:execute($S broadcast {+2})+abort" \ + --bind "alt-x:execute($S run {+2})" \ + --bind "alt-w:execute($S web {2})+reload($S list)+abort" \ + --bind "alt-a:select-all,alt-z:deselect-all" diff --git a/hypr/scripts/ssh_hosts.py b/hypr/scripts/ssh_hosts.py new file mode 100755 index 0000000..5414301 --- /dev/null +++ b/hypr/scripts/ssh_hosts.py @@ -0,0 +1,326 @@ +#!/usr/bin/env python3 +"""Helper for ssh-menu.sh: parse and edit ~/.ssh/config. + +Host metadata lives in comments directly above a `Host` line: + # group: Proxmox + # env: prod + Host pm +""" +import os +import re +import shutil +import socket +import subprocess +import sys +import time +import zlib +from collections import Counter +from concurrent.futures import ThreadPoolExecutor + +HOME = os.path.expanduser("~") +CFG = os.path.join(HOME, ".ssh", "config") +STATE = os.path.join(os.environ.get("XDG_STATE_HOME", HOME + "/.local/state"), "ssh-menu") +HIST = os.path.join(STATE, "history") +CACHE = os.path.join(STATE, "status.tsv") +BACKUPS = os.path.join(STATE, "backups") +os.makedirs(BACKUPS, exist_ok=True) + +G, R, Y, D, B, X = "\x1b[32m", "\x1b[31m", "\x1b[33m", "\x1b[2m", "\x1b[1;31m", "\x1b[0m" + + +def read_lines(): + try: + return open(CFG).read().split("\n") + except FileNotFoundError: + return [] + + +def backup(): + if os.path.exists(CFG): + shutil.copy2(CFG, os.path.join(BACKUPS, "config-" + time.strftime("%Y%m%d-%H%M%S"))) + old = sorted(os.listdir(BACKUPS))[:-20] + for f in old: + os.remove(os.path.join(BACKUPS, f)) + + +def write_lines(lines): + backup() + text = "\n".join(lines) + if not text.endswith("\n"): + text += "\n" + with open(CFG, "w") as f: + f.write(text) + os.chmod(CFG, 0o600) + + +def parse(): + hosts, meta = [], {} + for i, l in enumerate(read_lines()): + m = re.match(r"^\s*#\s*(group|env|url)\s*:\s*(.+?)\s*$", l, re.I) + if m: + meta[m.group(1).lower()] = m.group(2) + continue + m = re.match(r"^\s*host\s+(.+?)\s*$", l, re.I) + if m: + for a in m.group(1).split(): + if not re.search(r"[*?!]", a): + hosts.append(dict(alias=a, group=meta.get("group", ""), env=meta.get("env", ""), url=meta.get("url", ""), line=i + 1)) + meta = {} + elif l.strip() == "" or not l.lstrip().startswith("#"): + meta = {} + return hosts + + +def find(alias): + return next((h for h in parse() if h["alias"] == alias), None) + + +def resolve(alias): + """ssh -G -> dict(user, hostname, port, proxyjump, identity)""" + info = dict(user="", hostname=alias, port="22", proxyjump="", identity="") + try: + out = subprocess.run(["ssh", "-G", alias], capture_output=True, text=True, timeout=3).stdout + except Exception: + return info + ids = [] + for l in out.splitlines(): + k, _, v = l.partition(" ") + if k == "user": info["user"] = v + elif k == "hostname": info["hostname"] = v + elif k == "port": info["port"] = v + elif k == "proxyjump": info["proxyjump"] = v + elif k == "identityfile": ids.append(os.path.expanduser(v)) + info["identity"] = next((p for p in ids if os.path.exists(p)), "") + return info + + +def probe(info): + if info["proxyjump"]: + return "jump" + try: + socket.create_connection((info["hostname"], int(info["port"])), timeout=0.6).close() + return "up" + except Exception: + return "down" + + +def addr(info): + return f'{info["user"]}@{info["hostname"]}:{info["port"]}' + + +def load_cache(): + c = {} + try: + for l in open(CACHE): + a, ad, st = l.rstrip("\n").split("\t") + c[a] = (ad, st) + except Exception: + pass + return c + + +def uses(): + try: + return Counter(open(HIST).read().split()) + except FileNotFoundError: + return Counter() + + +PALETTE = [(248, 187, 217), (125, 207, 255), (158, 206, 106), (224, 175, 104), (187, 154, 247), (115, 218, 202)] + + +def rgb(r, g, b, bold=False): + return f"\x1b[{'1;' if bold else ''}38;2;{r};{g};{b}m" + + +FIXED = {"proxmox": (248, 187, 217), "services": (125, 207, 255), "git": (158, 206, 106), "legacy": (130, 120, 125)} + + +def group_color(name): + if name.lower() in FIXED: + return FIXED[name.lower()] + return PALETTE[zlib.crc32(name.lower().encode()) % len(PALETTE)] + + +def group_cell(name, width): + if not name: + return " " * width + return rgb(*group_color(name)) + f"{name:<{width}}" + X + + +def addr_cell(ad): + """user@host:port with the host highlighted and the rest dimmed""" + m = re.match(r"^(.*)@(.*):(\d+)$", ad or "") + if not m: + return D + (ad or "") + X + u, h, p = m.groups() + return f"{rgb(120, 108, 112)}{u}@{X}{rgb(205, 195, 200)}{h}{X}{rgb(120, 108, 112)}" + ("" if p == "22" else f":{p}") + X + + +def cmd_list(live): + hosts = parse() + cnt = uses() + hosts.sort(key=lambda h: (-cnt.get(h["alias"], 0), h["alias"].lower())) + if live: + def work(h): + i = resolve(h["alias"]) + return h["alias"], addr(i), probe(i) + with ThreadPoolExecutor(32) as ex: + rows = list(ex.map(work, hosts)) + with open(CACHE, "w") as f: + for a, ad, st in rows: + f.write(f"{a}\t{ad}\t{st}\n") + cache = {a: (ad, st) for a, ad, st in rows} + else: + cache = load_cache() + dots = {"up": G + "●" + X, "down": "\x1b[1;31m●" + X, "jump": Y + "●" + X} + for h in hosts: + ad, st = cache.get(h["alias"], ("", "?")) + dot = dots.get(st, D + "·" + X) + prod = rgb(255, 92, 108, bold=True) + "PROD" + X if h["env"].lower() == "prod" else " " + print(f'{dot} {h["alias"]:<24} {group_cell(h["group"], 11)} {prod} {addr_cell(ad)}') + + +def cmd_preview(alias): + lab = lambda t: rgb(120, 108, 112) + f"{t:<7}" + X + val = lambda t: rgb(235, 225, 230) + str(t) + X + h = find(alias) + if not h: + print(f"{alias}\n\n{D}not in ~/.ssh/config:\nwill connect as typed{X}") + return + i = resolve(alias) + st = probe(i) + col = {"up": (120, 235, 90), "down": (255, 92, 108), "jump": (224, 175, 104)}[st] + prod = " " + rgb(255, 92, 108, bold=True) + "PROD" + X if h["env"].lower() == "prod" else "" + print(rgb(*col, bold=True) + "● " + alias + X + prod) + print(f'{D}{addr(i)}{X}\n') + if i["proxyjump"]: + print(lab("via") + val(i["proxyjump"])) + print(lab("key") + val(os.path.basename(i["identity"]) or "default / agent")) + if h["group"]: + print(lab("group") + rgb(*group_color(h["group"])) + h["group"] + X) + if h["url"]: + print(lab("web") + val(h["url"])) + print(lab("used") + val(f'{uses().get(alias, 0)}x')) + if st != "jump": + try: + out = subprocess.run(["ping", "-c1", "-W1", i["hostname"]], capture_output=True, text=True, timeout=2).stdout + m = re.search(r"time=([\d.]+)", out) + print(lab("ping") + (val(m.group(1) + " ms") if m else rgb(255, 92, 108) + "no reply" + X)) + except Exception: + print(lab("ping") + "-") + print(f"\n{D}~/.ssh/config:{h['line']}{X}") + for l in read_lines()[h["line"] - 1:h["line"] + 12]: + if l.strip() == "" or (re.match(r"^\s*host\s", l, re.I) and not l.lower().lstrip().startswith("host " + alias.lower())): + break + print(D + l + X) + + +def host_index(lines, alias): + for i, l in enumerate(lines): + m = re.match(r"^\s*host\s+(.+?)\s*$", l, re.I) + if m and alias in m.group(1).split(): + return i + return -1 + + +def set_meta(alias, key, value): + lines = read_lines() + idx = host_index(lines, alias) + if idx < 0: + sys.exit(f"{alias}: not found") + j = idx + while j > 0 and lines[j - 1].lstrip().startswith("#"): + j -= 1 + block = [l for l in lines[j:idx] if not re.match(rf"^\s*#\s*{key}\s*:", l, re.I)] + if value: + block.append(f"# {key}: {value}") + lines[j:idx] = block + write_lines(lines) + + +def cmd_add(alias, hostname, user, port, jump, group, env, identity): + if find(alias) or re.search(r"[\s*?!]", alias): + sys.exit("bad or duplicate alias") + lines = read_lines() + while lines and lines[-1].strip() == "": + lines.pop() + lines.append("") + if group: lines.append(f"# group: {group}") + if env: lines.append(f"# env: {env}") + lines.append(f"Host {alias}") + lines.append(f" HostName {hostname or alias}") + if user: lines.append(f" User {user}") + if port and port != "22": lines.append(f" Port {port}") + if jump: lines.append(f" ProxyJump {jump}") + if identity: + lines.append(f" IdentityFile {identity}") + lines.append(" IdentitiesOnly yes") + write_lines(lines) + + +def cmd_run(cmd, hosts): + """Run cmd on all hosts in parallel; hosts with identical output are merged into one block.""" + def one(h): + try: + p = subprocess.run(["ssh", "-T", "-o", "BatchMode=yes", "-o", "ConnectTimeout=6", h, cmd], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=60) + return h, p.returncode, (p.stdout + p.stderr).rstrip("\n") + except subprocess.TimeoutExpired: + return h, -1, "timeout" + except Exception as e: + return h, -1, str(e) + with ThreadPoolExecutor(16) as ex: + res = list(ex.map(one, hosts)) + groups, order = {}, [] + for h, rc, out in res: + k = (rc, out) + if k not in groups: + groups[k] = [] + order.append(k) + groups[k].append(h) + print(f"{D}$ {cmd}{X}\n") + for rc, out in order: + hs = groups[(rc, out)] + note = "" if rc == 0 else f" {D}{'ssh error' if rc in (-1, 255) else f'exit {rc}'}{X}" + print(f'{(G if rc == 0 else R)}●{X} {rgb(235, 225, 230, bold=True)}{", ".join(hs)}{X}{note}') + for l in out.splitlines(): + print(" " + l) + print() + + +def url_default(alias): + i, h = resolve(alias), find(alias) + port = "" + if h and h["group"].lower() == "proxmox": + port = ":8007" if alias == "pbs" else ":8006" + return f'https://{i["hostname"]}{port}' + + +def main(): + a = sys.argv[1:] + c = a[0] if a else "list" + if c == "list": cmd_list(False) + elif c == "live": cmd_list(True) + elif c == "preview": cmd_preview(a[1]) + elif c == "addr": + i = resolve(a[1]); print(addr(i), "jump" if i["proxyjump"] else "") + elif c == "env": + h = find(a[1]); print(h["env"] if h else "") + elif c == "line": + h = find(a[1]); print(h["line"] if h else 1) + elif c == "groups": + print("\n".join(sorted({h["group"] for h in parse() if h["group"]}))) + elif c == "url": + h = find(a[1]); print(h["url"] if h else "") + elif c == "url-default": print(url_default(a[1])) + elif c == "run": cmd_run(a[1], a[2:]) + elif c == "has": sys.exit(0 if find(a[1]) else 1) + elif c == "set": set_meta(a[1], a[2], a[3] if len(a) > 3 else "") + elif c == "toggle-prod": + h = find(a[1]) + if h: set_meta(a[1], "env", "" if h["env"].lower() == "prod" else "prod") + elif c == "add": cmd_add(*a[1:9]) + + +main() diff --git a/hypr/scripts/tmux-broadcast.conf b/hypr/scripts/tmux-broadcast.conf new file mode 100644 index 0000000..835d378 --- /dev/null +++ b/hypr/scripts/tmux-broadcast.conf @@ -0,0 +1,16 @@ +# Isolated tmux server for the SSH menu's broadcast windows (tmux -L sshmenu); your ~/.tmux.conf is untouched. +set -g status off +set -g mouse on +set -g escape-time 0 +set -g allow-set-title off +set -g remain-on-exit failed +set -g default-terminal "tmux-256color" +set -ga terminal-overrides ",*:Tc" +set -g pane-border-status top +set -g pane-border-lines single +set -g pane-border-style "fg=#3a2a2e" +set -g pane-active-border-style "fg=#e91e63" +set -g pane-border-format " #{pane_title}#{?pane_synchronized, #[fg=#e91e63]sync#[default],} " +# Alt+S: typing goes to all panes / only this one. Alt+Z: zoom the current pane. +bind -n M-s setw synchronize-panes +bind -n M-z resize-pane -Z diff --git a/kitty/kitty.conf b/kitty/kitty.conf new file mode 100644 index 0000000..9620a50 --- /dev/null +++ b/kitty/kitty.conf @@ -0,0 +1,128 @@ +# ---------- Font ---------- +font_family family="JetBrains Mono" style="Medium" +bold_font family="JetBrains Mono" style="ExtraBold" +italic_font family="JetBrains Mono" style="Medium Italic" +bold_italic_font family="JetBrains Mono" style="ExtraBold Italic" +font_size 13.5 +# Ligatures (-> => != ===) stay on; they only break when the cursor is on them +disable_ligatures cursor +adjust_line_height 106% +# Nerd Font icons are built into kitty and picked up automatically + +# ---------- Window / glass ---------- +background_opacity 0.80 +dynamic_background_opacity yes +background_blur 0 +window_padding_width 12 14 +hide_window_decorations yes +confirm_os_window_close 0 +remember_window_size no +initial_window_width 110c +initial_window_height 32c + +# ---------- Cursor ---------- +cursor_shape beam +cursor_beam_thickness 1.8 +cursor_blink_interval 0.6 ease-in-out +cursor_trail 3 +cursor_trail_decay 0.1 0.35 +cursor_trail_start_threshold 2 + +# ---------- Scrollback ---------- +scrollback_lines 20000 +scrollback_pager_history_size 100 +mouse_hide_wait 2.0 +copy_on_select clipboard +strip_trailing_spaces smart + +# ---------- Tabs ---------- +tab_bar_edge top +tab_bar_style powerline +tab_powerline_style round +tab_bar_min_tabs 2 +tab_title_template "{index}: {title[:24]}" +active_tab_font_style bold +tab_bar_margin_width 8 +tab_bar_margin_height 6 0 + +# ---------- Splits ---------- +enabled_layouts splits:split_axis=horizontal,stack,tall +active_border_color #bb9af7 +inactive_border_color #3b4261 +inactive_text_alpha 0.75 +window_border_width 1pt + +# ---------- URLs ---------- +url_color #7dcfff +url_style curly +detect_urls yes +underline_hyperlinks hover + +# ---------- Bell ---------- +enable_audio_bell no +visual_bell_duration 0.15 ease-out +window_alert_on_bell yes + +# ---------- Colors: Tokyo Night Storm tuned for glass ---------- +foreground #c0caf5 +background #11111b +selection_foreground #11111b +selection_background #bb9af7 +cursor #ff9e64 +cursor_text_color #11111b +url_color #7dcfff + +active_tab_foreground #11111b +active_tab_background #bb9af7 +inactive_tab_foreground #a9b1d6 +inactive_tab_background #1f2335 +tab_bar_background none + +mark1_foreground #11111b +mark1_background #ff9e64 + +color0 #414868 +color8 #565f89 +color1 #f7768e +color9 #ff899d +color2 #9ece6a +color10 #b9f27c +color3 #e0af68 +color11 #ffc777 +color4 #7aa2f7 +color12 #8db0ff +color5 #bb9af7 +color13 #c9a9ff +color6 #7dcfff +color14 #a4daff +color7 #a9b1d6 +color15 #c0caf5 + +# ---------- Shortcuts ---------- +# Plain Ctrl+C / Ctrl+V: Ctrl+C copies when text is selected, otherwise it is the usual interrupt (SIGINT) +map ctrl+c copy_or_interrupt +map ctrl+v paste_from_clipboard +map ctrl+shift+t new_tab_with_cwd +map ctrl+shift+enter launch --location=vsplit --cwd=current +map ctrl+alt+enter launch --location=hsplit --cwd=current +# Tabs: Ctrl+Shift+Left/Right (kitty default). Splits: Ctrl+Alt+arrows +map ctrl+shift+left previous_tab +map ctrl+shift+right next_tab +map ctrl+alt+left neighboring_window left +map ctrl+alt+right neighboring_window right +map ctrl+alt+up neighboring_window up +map ctrl+alt+down neighboring_window down +map ctrl+shift+z toggle_layout stack +map ctrl+shift+equal change_font_size current +1.0 +map ctrl+shift+minus change_font_size current -1.0 +map ctrl+shift+0 change_font_size current 0 +map ctrl+shift+f show_scrollback +map ctrl+shift+o kitten hints --type path --program - +map ctrl+shift+e kitten hints --type url +map ctrl+shift+u kitten unicode_input +map ctrl+shift+backspace set_background_opacity +0.05 +map ctrl+shift+delete set_background_opacity -0.05 + + + +term xterm-256color diff --git a/sync.sh b/sync.sh new file mode 100755 index 0000000..14c6ce5 --- /dev/null +++ b/sync.sh @@ -0,0 +1,13 @@ +#!/bin/bash +# Copy the live config (~/.config) into this repo. Run it, then `git add -A && git commit`. +# Only what belongs to the rice is copied; machine-generated files (hypr/dms/, DMS colour caches) are skipped. +set -e +cd "$(dirname "$0")" +C=${XDG_CONFIG_HOME:-$HOME/.config} +mkdir -p hypr dms/plugins systemd/user kitty +rsync -a --delete --exclude 'dms/' --exclude 'hyprlock.conf' --exclude 'hyprpaper.conf' --exclude 'scripts/osd.sh' "$C/hypr/" hypr/ +rsync -a --delete "$C/dms/plugins/networkEditor/" dms/plugins/networkEditor/ +cp -f "$C/dms/settings.json" dms/settings.json +cp -f "$C/systemd/user/hyprland-session.target" systemd/user/ +cp -f "$C/kitty/kitty.conf" kitty/kitty.conf +git status --short diff --git a/systemd/user/hyprland-session.target b/systemd/user/hyprland-session.target new file mode 100644 index 0000000..0e6bfa0 --- /dev/null +++ b/systemd/user/hyprland-session.target @@ -0,0 +1,6 @@ +[Unit] +Description=Hyprland session +Documentation=man:systemd.special(7) +BindsTo=graphical-session.target +Wants=graphical-session-pre.target +After=graphical-session-pre.target