- das-shell 0.1.0: the Das two-line bash prompt by default (replaces the PS1 of Void's skel .bashrc once), dprompt (Textual prompt builder with presets and live preview) and dbanner (SSH login banner editor: canvas tools, fields with values, widgets, das-banner service for cached ones, Das OS logo and tryzub pictures). Python tests in make test. - das-server 0.3.0 depends on das-shell; the server installer mentions dprompt and dbanner; the desktop image includes it. - run-qemu.sh forwards the guest's SSH to localhost:2222. - Hardware fixes: Ventoy menu icon, boot menu titles "Das OS <version>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,7 @@ t() {
|
||||
s_config) echo "Налаштування системи..." ;;
|
||||
s_post) echo "Локалі, знімки, initramfs (кілька хвилин)..." ;;
|
||||
s_boot) echo "Встановлення завантажувача..." ;;
|
||||
finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev." ;;
|
||||
finished) echo "Das OS Server встановлено. Вийміть інсталяційний носій і перезавантажте сервер.\n\nВхід: %s, далі sudo. Служби: dsv. Користувачі: duser. Порти: dfw. Налаштування: dsys. Оновлення: das-update. Профіль розробника: dprofile install dev. Запрошення терміналу: dprompt. Банер при вході: dbanner." ;;
|
||||
reboot) echo "Перезавантажити зараз?" ;;
|
||||
failed) echo "Помилка встановлення. Журнал: %s" ;;
|
||||
root) echo "Інсталятор треба запускати від root (sudo das-server-install)." ;;
|
||||
@@ -105,7 +105,7 @@ t() {
|
||||
s_config) echo "Configuring the system..." ;;
|
||||
s_post) echo "Locales, snapshots, initramfs (a few minutes)..." ;;
|
||||
s_boot) echo "Installing the boot loader..." ;;
|
||||
finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev." ;;
|
||||
finished) echo "Das OS Server is installed. Remove the installation medium and reboot.\n\nLog in as %s, then use sudo. Services: dsv. Users: duser. Ports: dfw. Settings: dsys. Updates: das-update. Developer profile: dprofile install dev. Terminal prompt: dprompt. Login banner: dbanner." ;;
|
||||
reboot) echo "Reboot now?" ;;
|
||||
failed) echo "Installation failed. Log: %s" ;;
|
||||
root) echo "The installer must run as root (sudo das-server-install)." ;;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Template file for 'das-server-installer'
|
||||
pkgname=das-server-installer
|
||||
version=0.3.0
|
||||
version=0.4.0
|
||||
revision=1
|
||||
depends="bash dialog das-install-common btrfs-progs dosfstools gptfdisk parted
|
||||
rsync util-linux grub grub-x86_64-efi efibootmgr"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Template file for 'das-server'
|
||||
pkgname=das-server
|
||||
version=0.2.0
|
||||
version=0.3.0
|
||||
revision=1
|
||||
depends="openssh chrony dhcpcd NetworkManager docker docker-compose nftables
|
||||
fail2ban socklog-void python3 curl lazydocker lazygit mc tmux htop
|
||||
das-snapshots das-update das-helpers xtools-minimal"
|
||||
das-snapshots das-update das-helpers das-shell xtools-minimal"
|
||||
short_desc="Das OS server: packages and secure defaults for SSH, firewall and Docker"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Das
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Das OS bash prompt: ~/.config/das/prompt.sh, else /etc/das/prompt.sh (both
|
||||
# written by dprompt), else the stock one.
|
||||
# shellcheck shell=bash
|
||||
|
||||
if [[ $- == *i* && -r /usr/lib/das/prompt-lib.sh ]]; then
|
||||
. /usr/lib/das/prompt-lib.sh
|
||||
for __das_f in "${XDG_CONFIG_HOME:-$HOME/.config}/das/prompt.sh" /etc/das/prompt.sh /usr/share/das/prompt/default.sh; do
|
||||
if [[ -r $__das_f ]]; then
|
||||
. "$__das_f"
|
||||
break
|
||||
fi
|
||||
done
|
||||
unset __das_f
|
||||
fi
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/python3
|
||||
"""dbanner of Das OS; the code is in /usr/lib/das/python/das_shell."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/das/python")
|
||||
|
||||
from das_shell.cli_banner import main # noqa: E402
|
||||
|
||||
sys.exit(main())
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/usr/bin/python3
|
||||
"""dprompt of Das OS; the code is in /usr/lib/das/python/das_shell."""
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, "/usr/lib/das/python")
|
||||
|
||||
from das_shell.cli_prompt import main # noqa: E402
|
||||
|
||||
sys.exit(main())
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
# Keeps the cached widgets of the login banner (dbanner) fresh
|
||||
exec 2>&1
|
||||
exec /usr/bin/dbanner refresh --loop
|
||||
@@ -0,0 +1,22 @@
|
||||
# bash completion for dbanner
|
||||
# shellcheck shell=bash
|
||||
_dbanner() {
|
||||
local cur=${COMP_WORDS[COMP_CWORD]}
|
||||
if ((COMP_CWORD == 1)); then
|
||||
mapfile -t COMPREPLY < <(compgen -W "list show apply off on refresh help" -- "$cur")
|
||||
return
|
||||
fi
|
||||
case ${COMP_WORDS[1]} in
|
||||
show | apply)
|
||||
if [[ $cur == -* ]]; then
|
||||
mapfile -t COMPREPLY < <(compgen -W "--no-color" -- "$cur")
|
||||
else
|
||||
local names
|
||||
names=$(ls /usr/share/das/banner/presets "${XDG_CONFIG_HOME:-$HOME/.config}/das/banner" 2>/dev/null | sed -n 's/\.json$//p')
|
||||
mapfile -t COMPREPLY < <(compgen -W "$names" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
|
||||
fi
|
||||
;;
|
||||
refresh) mapfile -t COMPREPLY < <(compgen -W "--force" -- "$cur") ;;
|
||||
esac
|
||||
}
|
||||
complete -F _dbanner dbanner
|
||||
@@ -0,0 +1,22 @@
|
||||
# bash completion for dprompt
|
||||
# shellcheck shell=bash
|
||||
_dprompt() {
|
||||
local cur=${COMP_WORDS[COMP_CWORD]}
|
||||
if ((COMP_CWORD == 1)); then
|
||||
mapfile -t COMPREPLY < <(compgen -W "list show apply reset help" -- "$cur")
|
||||
return
|
||||
fi
|
||||
case ${COMP_WORDS[1]} in
|
||||
show | apply)
|
||||
if [[ $cur == -* ]]; then
|
||||
mapfile -t COMPREPLY < <(compgen -W "--system --script" -- "$cur")
|
||||
else
|
||||
local presets
|
||||
presets=$(ls /usr/share/das/prompt/presets 2>/dev/null | sed 's/\.json$//')
|
||||
mapfile -t COMPREPLY < <(compgen -W "$presets" -- "$cur"; compgen -f -X '!*.json' -- "$cur")
|
||||
fi
|
||||
;;
|
||||
reset) mapfile -t COMPREPLY < <(compgen -W "--system" -- "$cur") ;;
|
||||
esac
|
||||
}
|
||||
complete -F _dprompt dprompt
|
||||
@@ -0,0 +1,5 @@
|
||||
# Das OS login banner, see dbanner. It decides itself whether to show up:
|
||||
# SSH logins only (unless "local" is set), not after "dbanner off" or with ~/.hushlogin.
|
||||
case $- in
|
||||
*i*) [ -x /usr/bin/dbanner ] && /usr/bin/dbanner show --login ;;
|
||||
esac
|
||||
@@ -0,0 +1,53 @@
|
||||
# Functions behind the Das OS bash prompt (see dprompt). Sourced by
|
||||
# /etc/bash/bashrc.d/das-prompt.sh and by the prompt.sh files dprompt writes.
|
||||
# shellcheck shell=bash
|
||||
|
||||
# Colors when the terminal has at least 8 of them
|
||||
__das_has_color() {
|
||||
case $TERM in
|
||||
*color* | xterm* | rxvt* | screen* | tmux* | linux | alacritty | foot* | kitty*) return 0 ;;
|
||||
esac
|
||||
[[ $(tput colors 2>/dev/null) -ge 8 ]]
|
||||
}
|
||||
|
||||
# Sets __das_git to the branch (or short commit) and "*" when there are changes
|
||||
__das_git_info() {
|
||||
command -v git >/dev/null || return 1
|
||||
local branch
|
||||
branch=$(git symbolic-ref --short -q HEAD 2>/dev/null || git rev-parse --short -q HEAD 2>/dev/null) || return 1
|
||||
[[ -n $branch ]] || return 1
|
||||
if [[ -n $(git status --porcelain -uno 2>/dev/null | head -c1) ]]; then
|
||||
branch+="*"
|
||||
fi
|
||||
__das_git=$branch
|
||||
}
|
||||
|
||||
# Sets __das_jobs to the number of background jobs; fails when there are none
|
||||
__das_jobs_count() {
|
||||
local count='\j'
|
||||
__das_jobs=${count@P}
|
||||
((__das_jobs > 0))
|
||||
}
|
||||
|
||||
# Runs first in PROMPT_COMMAND: keeps the exit status for the prompt and fills
|
||||
# the variables of its conditional parts
|
||||
__das_prompt_hook() {
|
||||
__das_rc=$?
|
||||
# Void's /etc/skel/.bashrc sets its own PS1 after /etc/bash/bashrc.d: put
|
||||
# the Das OS one back, once, unless the user has a PS1 of their own
|
||||
if [[ -n ${__das_ps1+set} ]]; then
|
||||
[[ $PS1 == '[\u@\h \W]\$ ' ]] && PS1=$__das_ps1
|
||||
unset __das_ps1
|
||||
fi
|
||||
declare -F __das_prompt_dynamic >/dev/null && __das_prompt_dynamic
|
||||
return "$__das_rc"
|
||||
}
|
||||
|
||||
__das_prompt_install() {
|
||||
__das_ps1=$PS1
|
||||
local cmd
|
||||
for cmd in "${PROMPT_COMMAND[@]}"; do
|
||||
[[ $cmd == __das_prompt_hook ]] && return 0
|
||||
done
|
||||
PROMPT_COMMAND=(__das_prompt_hook "${PROMPT_COMMAND[@]}")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Das OS shell tools: dprompt (bash prompt builder) and dbanner (login banner editor)."""
|
||||
|
||||
import os
|
||||
|
||||
VERSION = "0.1.0"
|
||||
|
||||
# Installed layout; DAS_SHELL_DATA points the tools at the source tree for tests
|
||||
DATA_DIR = os.environ.get("DAS_SHELL_DATA", "/usr/share/das")
|
||||
LIB_DIR = os.environ.get("DAS_SHELL_LIB", "/usr/lib/das")
|
||||
ETC_DIR = os.environ.get("DAS_SHELL_ETC", "/etc/das")
|
||||
CACHE_DIR = os.environ.get("DAS_SHELL_CACHE", "/var/cache/das/banner")
|
||||
|
||||
|
||||
def config_dir():
|
||||
"""The user's Das OS settings directory (~/.config/das)."""
|
||||
base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
||||
return os.path.join(base, "das")
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Login banner model of dbanner: a grid of styled characters, fields and widgets.
|
||||
|
||||
A design is JSON:
|
||||
{"name": "...", "title": {"uk": ..., "en": ...}, "local": false,
|
||||
"vars": {"url": "https://..."},
|
||||
"styles": {"a": {"fg": "blue", "bg": null, "bold": false}, ...},
|
||||
"lines": ["####", ...], "style_map": ["aaaa", ...],
|
||||
"fields": [{"x": 4, "y": 1, "width": 20, "align": "left", "text": "{ip}", "style": "b"}],
|
||||
"widgets_position": "below", "widgets": [{"type": "builtin", "name": "disks"}, ...]}
|
||||
|
||||
style_map has one style letter per character of lines; a space is the
|
||||
terminal's own style. This module only needs the standard library: the
|
||||
banner is printed at every SSH login.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import unicodedata
|
||||
|
||||
from . import DATA_DIR, ETC_DIR, colors, config_dir
|
||||
from .i18n import UK, title
|
||||
|
||||
STYLE_KEYS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
ALIGNS = ("left", "center", "right")
|
||||
SYSTEM_FILE = os.path.join(ETC_DIR, "banner.json")
|
||||
OFF_FILE = os.path.join(ETC_DIR, "banner.off")
|
||||
|
||||
|
||||
def char_width(ch):
|
||||
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||||
|
||||
|
||||
class Grid:
|
||||
"""Characters with a style key each; the canvas of the editor."""
|
||||
|
||||
def __init__(self, width=40, height=8, styles=None):
|
||||
self.width, self.height = width, height
|
||||
self.cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
||||
self.styles = styles if styles is not None else {}
|
||||
|
||||
# --- conversion -------------------------------------------------------
|
||||
@classmethod
|
||||
def from_design(cls, design):
|
||||
lines = design.get("lines") or []
|
||||
smap = design.get("style_map") or []
|
||||
width = max([len(line) for line in lines] + [1])
|
||||
grid = cls(width, max(len(lines), 1), copy.deepcopy(design.get("styles") or {}))
|
||||
for y, line in enumerate(lines):
|
||||
keys = smap[y] if y < len(smap) else ""
|
||||
for x, ch in enumerate(line):
|
||||
key = keys[x] if x < len(keys) else " "
|
||||
grid.cells[y][x] = [ch, key if key in grid.styles else " "]
|
||||
return grid
|
||||
|
||||
def to_design(self, design):
|
||||
"""Stores the grid into a copy of the design, dropping unused styles."""
|
||||
d = copy.deepcopy(design)
|
||||
lines, smap = [], []
|
||||
for row in self.cells:
|
||||
line = "".join(c[0] for c in row)
|
||||
keys = "".join(c[1] for c in row)
|
||||
# Trailing blank cells carry nothing
|
||||
n = len(line)
|
||||
while n and line[n - 1] == " " and keys[n - 1] == " ":
|
||||
n -= 1
|
||||
lines.append(line[:n])
|
||||
smap.append(keys[:n])
|
||||
while lines and not lines[-1]:
|
||||
lines.pop()
|
||||
smap.pop()
|
||||
used = {k for keys in smap for k in keys} | {f.get("style") for f in d.get("fields", [])}
|
||||
d["styles"] = {k: v for k, v in self.styles.items() if k in used}
|
||||
d["lines"], d["style_map"] = lines, smap
|
||||
d["canvas"] = {"width": self.width, "height": self.height}
|
||||
return d
|
||||
|
||||
def copy(self):
|
||||
g = Grid(self.width, self.height, copy.deepcopy(self.styles))
|
||||
g.cells = [[list(c) for c in row] for row in self.cells]
|
||||
return g
|
||||
|
||||
# --- styles -------------------------------------------------------------
|
||||
def style_key(self, fg=None, bg=None, bold=False):
|
||||
"""The key of a style, added when it is new."""
|
||||
if not fg and not bg and not bold:
|
||||
return " "
|
||||
want = {"fg": fg, "bg": bg, "bold": bool(bold)}
|
||||
for key, st in self.styles.items():
|
||||
if {"fg": st.get("fg"), "bg": st.get("bg"), "bold": bool(st.get("bold"))} == want:
|
||||
return key
|
||||
for key in STYLE_KEYS:
|
||||
if key not in self.styles:
|
||||
self.styles[key] = want
|
||||
return key
|
||||
raise ValueError("too many styles")
|
||||
|
||||
def style(self, key):
|
||||
st = self.styles.get(key) or {}
|
||||
return st.get("fg"), st.get("bg"), bool(st.get("bold"))
|
||||
|
||||
# --- drawing --------------------------------------------------------------
|
||||
def inside(self, x, y):
|
||||
return 0 <= x < self.width and 0 <= y < self.height
|
||||
|
||||
def get(self, x, y):
|
||||
return self.cells[y][x] if self.inside(x, y) else [" ", " "]
|
||||
|
||||
def put(self, x, y, ch, key):
|
||||
if self.inside(x, y):
|
||||
self.cells[y][x] = [ch, key]
|
||||
|
||||
def resize(self, width, height):
|
||||
width, height = max(1, width), max(1, height)
|
||||
cells = [[[" ", " "] for _ in range(width)] for _ in range(height)]
|
||||
for y in range(min(height, self.height)):
|
||||
for x in range(min(width, self.width)):
|
||||
cells[y][x] = self.cells[y][x]
|
||||
self.width, self.height, self.cells = width, height, cells
|
||||
|
||||
def text(self, x, y, text, key):
|
||||
for i, ch in enumerate(text):
|
||||
self.put(x + i, y, ch, key)
|
||||
|
||||
def line(self, x0, y0, x1, y1, ch, key):
|
||||
"""A straight line; horizontal and vertical ones use ch as given."""
|
||||
dx, dy = abs(x1 - x0), -abs(y1 - y0)
|
||||
sx, sy = (1 if x0 < x1 else -1), (1 if y0 < y1 else -1)
|
||||
err = dx + dy
|
||||
while True:
|
||||
self.put(x0, y0, ch, key)
|
||||
if x0 == x1 and y0 == y1:
|
||||
return
|
||||
e2 = 2 * err
|
||||
if e2 >= dy:
|
||||
err += dy
|
||||
x0 += sx
|
||||
if e2 <= dx:
|
||||
err += dx
|
||||
y0 += sy
|
||||
|
||||
def box(self, x0, y0, x1, y1, kind, key):
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
tl, tr, bl, br, h, v = BOXES[kind]
|
||||
if x0 == x1 or y0 == y1:
|
||||
self.line(x0, y0, x1, y1, v if x0 == x1 else h, key)
|
||||
return
|
||||
for x in range(x0 + 1, x1):
|
||||
self.put(x, y0, h, key)
|
||||
self.put(x, y1, h, key)
|
||||
for y in range(y0 + 1, y1):
|
||||
self.put(x0, y, v, key)
|
||||
self.put(x1, y, v, key)
|
||||
self.put(x0, y0, tl, key)
|
||||
self.put(x1, y0, tr, key)
|
||||
self.put(x0, y1, bl, key)
|
||||
self.put(x1, y1, br, key)
|
||||
|
||||
def fill(self, x0, y0, x1, y1, ch, key):
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
for y in range(y0, y1 + 1):
|
||||
for x in range(x0, x1 + 1):
|
||||
self.put(x, y, ch, key)
|
||||
|
||||
def region(self, x0, y0, x1, y1):
|
||||
"""A copy of a rectangle as a new grid with the same styles."""
|
||||
x0, x1 = sorted((x0, x1))
|
||||
y0, y1 = sorted((y0, y1))
|
||||
g = Grid(x1 - x0 + 1, y1 - y0 + 1, copy.deepcopy(self.styles))
|
||||
for y in range(g.height):
|
||||
for x in range(g.width):
|
||||
g.cells[y][x] = list(self.get(x0 + x, y0 + y))
|
||||
return g
|
||||
|
||||
def stamp(self, x, y, other, transparent=True):
|
||||
"""Draws another grid here; its styles are merged into ours."""
|
||||
keymap = {" ": " "}
|
||||
for key in {c[1] for row in other.cells for c in row}:
|
||||
if key != " ":
|
||||
fg, bg, bold = other.style(key)
|
||||
keymap[key] = self.style_key(fg, bg, bold)
|
||||
for dy, row in enumerate(other.cells):
|
||||
for dx, (ch, key) in enumerate(row):
|
||||
if transparent and ch == " " and key == " ":
|
||||
continue
|
||||
self.put(x + dx, y + dy, ch, keymap.get(key, " "))
|
||||
|
||||
|
||||
BOXES = {
|
||||
"single": "┌┐└┘─│",
|
||||
"double": "╔╗╚╝═║",
|
||||
"rounded": "╭╮╰╯─│",
|
||||
"heavy": "┏┓┗┛━┃",
|
||||
"ascii": "++++-|",
|
||||
"hash": "######",
|
||||
"block": "██████",
|
||||
}
|
||||
|
||||
|
||||
# --- designs ------------------------------------------------------------------
|
||||
|
||||
def normalize(design):
|
||||
d = copy.deepcopy(design)
|
||||
d.setdefault("name", "custom")
|
||||
d.setdefault("local", False)
|
||||
d.setdefault("vars", {})
|
||||
d.setdefault("styles", {})
|
||||
d.setdefault("lines", [])
|
||||
d.setdefault("style_map", [])
|
||||
d.setdefault("fields", [])
|
||||
d.setdefault("widgets", [])
|
||||
if d.get("widgets_position") not in ("below", "right"):
|
||||
d["widgets_position"] = "below"
|
||||
for f in d["fields"]:
|
||||
f.setdefault("align", "left")
|
||||
f.setdefault("style", " ")
|
||||
f.setdefault("width", len(f.get("text", "")))
|
||||
return d
|
||||
|
||||
|
||||
def preset_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "presets")
|
||||
|
||||
|
||||
def art_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "art")
|
||||
|
||||
|
||||
def drafts_dir():
|
||||
return os.path.join(config_dir(), "banner")
|
||||
|
||||
|
||||
def _json_names(directory):
|
||||
try:
|
||||
return sorted(f[:-5] for f in os.listdir(directory) if f.endswith(".json"))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def list_presets():
|
||||
out = []
|
||||
for name in sorted(_json_names(preset_dir()), key=lambda n: (n != "das-classic", n)):
|
||||
try:
|
||||
out.append((name, title(load_file(os.path.join(preset_dir(), name + ".json")).get("title"), name)))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def list_drafts():
|
||||
return [(n, os.path.join(drafts_dir(), n + ".json")) for n in _json_names(drafts_dir())]
|
||||
|
||||
|
||||
def list_art():
|
||||
"""(name, title, design) of the ready-made pictures and the user's own."""
|
||||
out = []
|
||||
for directory in (art_dir(), os.path.join(config_dir(), "banner-art")):
|
||||
for name in _json_names(directory):
|
||||
try:
|
||||
d = load_file(os.path.join(directory, name + ".json"))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.append((name, title(d.get("title"), name), d))
|
||||
return out
|
||||
|
||||
|
||||
def load_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return normalize(json.load(f))
|
||||
|
||||
|
||||
def load(spec):
|
||||
"""A preset name, a draft name or a path to a design file."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return load_file(spec)
|
||||
for directory in (preset_dir(), drafts_dir()):
|
||||
path = os.path.join(directory, spec + ".json")
|
||||
if os.path.exists(path):
|
||||
return load_file(path)
|
||||
raise FileNotFoundError(spec)
|
||||
|
||||
|
||||
def system_design():
|
||||
"""The banner shown at login: /etc/das/banner.json or the das-classic preset."""
|
||||
try:
|
||||
return load_file(SYSTEM_FILE)
|
||||
except (OSError, ValueError):
|
||||
return load_file(os.path.join(preset_dir(), "das-classic.json"))
|
||||
|
||||
|
||||
def dumps(design):
|
||||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
def save(design, path):
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(dumps(design))
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
# --- rendering ------------------------------------------------------------------
|
||||
|
||||
_VAR = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
||||
_LANG = re.compile(r"\[\[([^|\]]*)\|([^\]]*)\]\]")
|
||||
FIGLET_FONTS = ("standard", "small", "big", "slant", "smslant", "mini", "banner", "block",
|
||||
"shadow", "smshadow", "lean", "script", "digital")
|
||||
|
||||
|
||||
def expand(text, values):
|
||||
"""[[Ukrainian|English]] in the user's language, {name} replaced by its
|
||||
value; unknown names stay as they are."""
|
||||
text = _LANG.sub(lambda m: m.group(1) if UK else m.group(2), text)
|
||||
|
||||
def repl(m):
|
||||
value = values.get(m.group(1))
|
||||
return m.group(0) if value is None else str(value)
|
||||
return _VAR.sub(repl, text)
|
||||
|
||||
|
||||
def figlet(text, font):
|
||||
"""Big letters by figlet, or the text itself when figlet is missing."""
|
||||
try:
|
||||
out = subprocess.run(["figlet", "-f", font, "-w", "1000", text], capture_output=True,
|
||||
text=True, timeout=2).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
out = ""
|
||||
lines = out.rstrip("\n").split("\n") if out.strip() else [text]
|
||||
while lines and not lines[-1].strip():
|
||||
lines.pop()
|
||||
return lines
|
||||
|
||||
|
||||
def fit(text, width, align):
|
||||
if len(text) > width:
|
||||
return text[:max(width - 1, 0)] + "…" if width else ""
|
||||
pad = width - len(text)
|
||||
if align == "right":
|
||||
return " " * pad + text
|
||||
if align == "center":
|
||||
return " " * (pad // 2) + text + " " * (pad - pad // 2)
|
||||
return text + " " * pad
|
||||
|
||||
|
||||
def field_lines(f, values):
|
||||
"""The lines a field draws: one, or several for big letters."""
|
||||
# An empty value leaves no dangling separator: "{host} · {url}" -> "host"
|
||||
text = expand(f.get("text", ""), values).strip(" ·|")
|
||||
width, align = int(f.get("width", 0)), f.get("align", "left")
|
||||
if f.get("font"):
|
||||
big = figlet(text, f["font"])
|
||||
if max(len(line) for line in big) <= width:
|
||||
# Align the block as a whole, so the letters stay together
|
||||
block = max(len(line) for line in big)
|
||||
return [fit(fit(line, block, "left"), width, align) for line in big]
|
||||
return [fit(text, width, align)]
|
||||
return [fit(text, width, align)]
|
||||
|
||||
|
||||
def draw_fields(grid, design, values):
|
||||
for f in design.get("fields", []):
|
||||
key = f.get("style", " ")
|
||||
x, y = int(f.get("x", 0)), int(f.get("y", 0))
|
||||
for i, line in enumerate(field_lines(f, values)):
|
||||
# Spaces of a field do not cover the picture under it
|
||||
for j, ch in enumerate(line):
|
||||
if ch != " " or not f.get("transparent", True):
|
||||
grid.put(x + j, y + i, ch, key if key in grid.styles else " ")
|
||||
|
||||
|
||||
def grid_lines(grid, color=True):
|
||||
"""The grid as terminal lines, trailing blanks removed."""
|
||||
out = []
|
||||
for row in grid.cells:
|
||||
n = len(row)
|
||||
while n and row[n - 1][0] == " " and (row[n - 1][1] == " " or not grid.style(row[n - 1][1])[1]):
|
||||
n -= 1
|
||||
line, current = [], " "
|
||||
for ch, key in row[:n]:
|
||||
if color and key != current:
|
||||
line.append(colors.sgr(*grid.style(key)) if key != " " else colors.RESET)
|
||||
current = key
|
||||
line.append(ch)
|
||||
if color and current != " ":
|
||||
line.append(colors.RESET)
|
||||
out.append("".join(line))
|
||||
while out and not out[-1].strip():
|
||||
out.pop()
|
||||
return out
|
||||
|
||||
|
||||
def visible_len(text):
|
||||
return sum(char_width(c) for c in re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", text))
|
||||
|
||||
|
||||
def render(design, values, widget_blocks, color=True):
|
||||
"""The whole banner as text: the picture with fields, then the widgets."""
|
||||
design = normalize(design)
|
||||
grid = Grid.from_design(design)
|
||||
if design.get("canvas"):
|
||||
grid.resize(max(grid.width, design["canvas"].get("width", 0)), grid.height)
|
||||
draw_fields(grid, design, values)
|
||||
art = grid_lines(grid, color)
|
||||
blocks = [b for b in widget_blocks if b]
|
||||
if not color:
|
||||
blocks = [[re.sub(r"\x1b\[[0-9;?]*[A-Za-z]", "", line) for line in b] for b in blocks]
|
||||
widget_lines = [line for b in blocks for line in b]
|
||||
if design["widgets_position"] == "right" and art:
|
||||
width = max(visible_len(line) for line in art) + 3
|
||||
out = []
|
||||
for i in range(max(len(art), len(widget_lines))):
|
||||
left = art[i] if i < len(art) else ""
|
||||
right = widget_lines[i] if i < len(widget_lines) else ""
|
||||
out.append((left + " " * (width - visible_len(left)) + right) if right else left)
|
||||
return "\n".join(out) + "\n"
|
||||
out = list(art)
|
||||
if widget_lines:
|
||||
if out:
|
||||
out.append("")
|
||||
out += widget_lines
|
||||
return "\n".join(out) + "\n"
|
||||
@@ -0,0 +1,778 @@
|
||||
"""dbanner editor: a list of banners with a preview, and the drawing editor."""
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
|
||||
from rich.text import Text
|
||||
from textual import work
|
||||
from textual.app import App
|
||||
from textual.binding import Binding
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen, Screen
|
||||
from textual.widgets import (Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, RadioButton,
|
||||
RadioSet, Select, Static, TabbedContent, TabPane)
|
||||
|
||||
from . import banner, widgets
|
||||
from .canvas import TOOLS, Canvas
|
||||
from .i18n import t, title
|
||||
from .tui_common import AskDialog, Swatches, run_as_root
|
||||
|
||||
TOOL_NAMES = {
|
||||
"pen": ("Олівець", "Pen"), "eraser": ("Гумка", "Eraser"), "line": ("Лінія", "Line"),
|
||||
"box": ("Рамка", "Box"), "fill": ("Заливка", "Fill"), "text": ("Текст", "Text"),
|
||||
"bigtext": ("Великі літери", "Big letters"), "field": ("Поле зі змінною", "Field with a value"),
|
||||
"art": ("Малюнок", "Picture"), "select": ("Виділення", "Select"),
|
||||
}
|
||||
TOOL_HELP = {
|
||||
"pen": ("Клацніть і ведіть мишею; або Enter і стрілки.", "Click and drag; or Enter and the arrows."),
|
||||
"eraser": ("Стирає клітинки під мишею.", "Clears the cells under the mouse."),
|
||||
"line": ("Тягніть від початку до кінця.", "Drag from start to end."),
|
||||
"box": ("Тягніть з кута в кут; вигляд рамки — нижче.", "Drag corner to corner; the box style is below."),
|
||||
"fill": ("Тягніть прямокутник: заповнить символом.", "Drag a rectangle: fills it with the symbol."),
|
||||
"text": ("Клацніть і друкуйте; Enter — новий рядок, Esc — кінець.",
|
||||
"Click and type; Enter for a new line, Esc to stop."),
|
||||
"bigtext": ("Клацніть, куди поставити великі літери (figlet).", "Click where the big letters go (figlet)."),
|
||||
"field": ("Тягніть по рядку: місце для {ip}, {hostname}… Клацання по полю — змінити.",
|
||||
"Drag along a row: room for {ip}, {hostname}… Click a field to change it."),
|
||||
"art": ("Клацніть, куди вставити логотип Das OS, тризуб чи ваш фрагмент.",
|
||||
"Click where to put the Das OS logo, the tryzub or your piece."),
|
||||
"select": ("Тягніть: Ctrl+C копіювати, Ctrl+X вирізати, Ctrl+V вставити біля курсора, Del — стерти.",
|
||||
"Drag: Ctrl+C copy, Ctrl+X cut, Ctrl+V paste at the cursor, Del clears."),
|
||||
}
|
||||
PALETTE = [
|
||||
"█▓▒░▀▄▌▐■□▪▫",
|
||||
"─│┌┐└┘├┤┬┴┼",
|
||||
"═║╔╗╚╝╠╣╦╩╬",
|
||||
"━┃┏┓┗┛╭╮╰╯╱╲",
|
||||
"★☆●○◆◇►◄▲▼♥♦",
|
||||
"#*+=-_|/\\<>~",
|
||||
"·•°…✓✗☀☁⚡✦❖⬢",
|
||||
]
|
||||
BOX_KINDS = [("single", "┌─┐"), ("double", "╔═╗"), ("rounded", "╭─╮"), ("heavy", "┏━┓"),
|
||||
("ascii", "+-+"), ("hash", "###"), ("block", "███")]
|
||||
|
||||
|
||||
def ansi_text(text):
|
||||
return Text.from_ansi(text, no_wrap=True)
|
||||
|
||||
|
||||
def unique_draft_name(base):
|
||||
taken = {n for n, _ in banner.list_drafts()} | {n for n, _ in banner.list_presets()}
|
||||
base = re.sub(r"[^\w.-]+", "-", base).strip("-") or "banner"
|
||||
name, n = base, 1
|
||||
while name in taken:
|
||||
n += 1
|
||||
name = f"{base}-{n}"
|
||||
return name
|
||||
|
||||
|
||||
# --- dialogs ------------------------------------------------------------------
|
||||
|
||||
class ArtDialog(ModalScreen):
|
||||
"""Choose a ready-made picture; dismisses with its design or None."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
ArtDialog { align: center middle; }
|
||||
ArtDialog > Horizontal { width: 90%; height: 80%; border: round $accent; background: $surface; }
|
||||
ArtDialog ListView { width: 40; }
|
||||
ArtDialog #art_preview { padding: 1 2; }
|
||||
"""
|
||||
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.items = banner.list_art()
|
||||
|
||||
def compose(self):
|
||||
with Horizontal():
|
||||
yield ListView(*[ListItem(Label(Text(title_))) for _, title_, _ in self.items], id="art_list")
|
||||
with Vertical():
|
||||
yield VerticalScroll(Static(id="art_preview"))
|
||||
yield Button(t("Вставити", "Insert"), variant="primary", id="ok")
|
||||
|
||||
def on_list_view_highlighted(self, event):
|
||||
i = event.list_view.index
|
||||
if i is not None and i < len(self.items):
|
||||
d = self.items[i][2]
|
||||
self.query_one("#art_preview", Static).update(
|
||||
ansi_text("\n".join(banner.grid_lines(banner.Grid.from_design(d)))))
|
||||
|
||||
def on_list_view_selected(self, event):
|
||||
self._ok()
|
||||
|
||||
def on_button_pressed(self, event):
|
||||
self._ok()
|
||||
|
||||
def _ok(self):
|
||||
i = self.query_one("#art_list", ListView).index
|
||||
self.dismiss(self.items[i][2] if i is not None and i < len(self.items) else None)
|
||||
|
||||
def action_cancel(self):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
def field_dialog(f, new):
|
||||
variables = ", ".join("{" + k + "}" for k in widgets.PROVIDERS)
|
||||
fields = [
|
||||
("text", t(f"Текст зі змінними: {variables}, ваші змінні; [[укр|eng]] — двомовний підпис",
|
||||
f"Text with values: {variables}, your variables; [[ukr|eng]] for two languages"),
|
||||
f.get("text", "{hostname}")),
|
||||
("width", t("Ширина", "Width"), str(f.get("width", 20))),
|
||||
("align", t("Вирівнювання", "Alignment"),
|
||||
[(t("ліворуч", "left"), "left"), (t("по центру", "center"), "center"), (t("праворуч", "right"), "right")],
|
||||
f.get("align", "left")),
|
||||
("font", t("Великі літери (figlet)", "Big letters (figlet)"),
|
||||
[(t("ні", "no"), "")] + [(n, n) for n in banner.FIGLET_FONTS], f.get("font", "")),
|
||||
]
|
||||
if not new:
|
||||
fields.append(("delete", t("Видалити поле?", "Delete the field?"),
|
||||
[(t("ні", "no"), "no"), (t("так, видалити", "yes, delete"), "yes")], "no"))
|
||||
return AskDialog(t("Нове поле" if new else "Поле", "New field" if new else "Field"), fields)
|
||||
|
||||
|
||||
class Palette(Static):
|
||||
"""Symbols to click; posts Picked with the symbol."""
|
||||
|
||||
class Picked(Message):
|
||||
def __init__(self, char):
|
||||
super().__init__()
|
||||
self.char = char
|
||||
|
||||
def on_click(self, event):
|
||||
row, col = event.y, event.x // 2
|
||||
if 0 <= row < len(PALETTE) and 0 <= col < len(PALETTE[row]) and event.x % 2 == 0:
|
||||
self.post_message(self.Picked(PALETTE[row][col]))
|
||||
|
||||
|
||||
# --- the editor -----------------------------------------------------------------
|
||||
|
||||
class EditorScreen(Screen):
|
||||
CSS = """
|
||||
#tools { width: 40; border-right: solid $primary; padding: 0 1; }
|
||||
#tools Label.h { text-style: bold; color: $accent; margin-top: 1; }
|
||||
#help { color: $text-muted; height: auto; }
|
||||
#palette { height: auto; }
|
||||
#status { height: 1; color: $text-muted; padding: 0 1; }
|
||||
RadioSet { width: 100%; }
|
||||
.buttons { height: auto; }
|
||||
.buttons Button { margin-right: 1; min-width: 5; }
|
||||
.row { height: auto; }
|
||||
.row Input { width: 1fr; }
|
||||
#fields_list, #widget_list { height: 1fr; min-height: 6; }
|
||||
#preview_box { padding: 0 1; }
|
||||
#widget_out { height: auto; max-height: 12; border: round $primary; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("ctrl+s", "save", t("Зберегти", "Save")),
|
||||
Binding("ctrl+z", "undo", t("Скасувати дію", "Undo")),
|
||||
Binding("ctrl+y", "redo", t("Повторити", "Redo")),
|
||||
Binding("ctrl+r", "resize", t("Розмір", "Size")),
|
||||
Binding("ctrl+c", "copy", t("Копіювати", "Copy"), show=False),
|
||||
Binding("ctrl+x", "cut", t("Вирізати", "Cut"), show=False),
|
||||
Binding("ctrl+v", "paste", t("Вставити", "Paste"), show=False),
|
||||
Binding("f5", "preview", t("Перегляд", "Preview")),
|
||||
Binding("ctrl+a", "apply", t("Застосувати", "Apply")),
|
||||
Binding("escape", "back", t("Назад", "Back")),
|
||||
]
|
||||
|
||||
def __init__(self, design, draft_name):
|
||||
super().__init__()
|
||||
self.design = banner.normalize(design)
|
||||
self.name_ = draft_name
|
||||
self.grid = banner.Grid.from_design(self.design)
|
||||
canvas = self.design.get("canvas") or {}
|
||||
self.grid.resize(max(self.grid.width, canvas.get("width", 0), 60),
|
||||
max(self.grid.height, canvas.get("height", 0), 10))
|
||||
self.fields = self.design["fields"]
|
||||
self.undo_stack, self.redo_stack = [], []
|
||||
self.dirty = False
|
||||
|
||||
# --- layout ------------------------------------------------------------
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
with VerticalScroll(id="tools"):
|
||||
yield Label(Text(t("Інструмент", "Tool")), classes="h")
|
||||
with RadioSet(id="tool"):
|
||||
for i, tool in enumerate(TOOLS):
|
||||
yield RadioButton(t(*TOOL_NAMES[tool]), value=i == 0)
|
||||
yield Static(Text(t(*TOOL_HELP["pen"])), id="help")
|
||||
yield Label(Text(t("Символ", "Symbol")), classes="h")
|
||||
yield Palette(id="palette")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input("█", max_length=1, id="char")
|
||||
yield Label(Text(t("Вигляд рамки", "Box style")), classes="h")
|
||||
yield Select([(f"{sample} {kind}", kind) for kind, sample in BOX_KINDS], value="single",
|
||||
allow_blank=False, id="box_kind")
|
||||
yield Label(Text(t("Колір символу", "Symbol color")), classes="h")
|
||||
yield Swatches("cyan", id="fg")
|
||||
yield Label(Text(t("Колір тла", "Background")), classes="h")
|
||||
yield Swatches(None, id="bg")
|
||||
yield Checkbox(t("Жирний", "Bold"), id="bold")
|
||||
with TabbedContent(id="tabs"):
|
||||
with TabPane(t("Малюнок", "Picture"), id="tab_draw"):
|
||||
yield Canvas(self.grid, self.fields, before=self.snapshot, id="canvas")
|
||||
yield Static(id="status")
|
||||
with TabPane(t("Поля", "Fields"), id="tab_fields"):
|
||||
yield Static(Text(t("Поля показують змінні: ім'я, IP, URL… Додаються інструментом "
|
||||
"«Поле зі змінною» на полотні.",
|
||||
"Fields show values: name, IP, URL… Add them with the \"Field with "
|
||||
"a value\" tool on the canvas.")))
|
||||
yield ListView(id="fields_list")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button(t("Змінити", "Edit"), id="field_edit")
|
||||
yield Button(t("Видалити", "Delete"), id="field_del", variant="error")
|
||||
with TabPane(t("Віджети", "Widgets"), id="tab_widgets"):
|
||||
yield Select([(t("під малюнком", "below the picture"), "below"),
|
||||
(t("праворуч від малюнка", "right of the picture"), "right")],
|
||||
value=self.design["widgets_position"], allow_blank=False, id="position")
|
||||
yield ListView(id="widget_list")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("↑", id="w_up")
|
||||
yield Button("↓", id="w_down")
|
||||
yield Button(t("Вимк/увімк", "Off/on"), id="w_toggle")
|
||||
yield Button(t("Налаштувати", "Settings"), id="w_edit")
|
||||
yield Button(t("Запустити", "Run"), id="w_run")
|
||||
yield Button(t("Видалити", "Delete"), id="w_del", variant="error")
|
||||
yield Select([(f"{name_} — {title_}", name_) for name_, title_, _ in widgets.available()],
|
||||
prompt=t("+ готовий віджет", "+ ready-made widget"), id="w_add")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("своя команда, напр. df -h /data", "own command, e.g. df -h /data"),
|
||||
id="w_cmd")
|
||||
yield Button(t("+ команда", "+ command"), id="w_add_cmd")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("файл з виводом скрипта, напр. ~/scripts/out/hdd",
|
||||
"file with a script's output, e.g. ~/scripts/out/hdd"), id="w_file")
|
||||
yield Button(t("+ файл", "+ file"), id="w_add_file")
|
||||
yield VerticalScroll(Static(id="widget_out_text"), id="widget_out")
|
||||
with TabPane(t("Змінні", "Variables"), id="tab_vars"):
|
||||
yield Static(Text(t("Свої змінні для полів: {url}, {title}, {city} (місто для погоди)…",
|
||||
"Your own values for fields: {url}, {title}, {city} (weather city)…")))
|
||||
yield VerticalScroll(id="vars_box")
|
||||
with Horizontal(classes="row"):
|
||||
yield Input(placeholder=t("назва", "name"), id="var_name")
|
||||
yield Input(placeholder=t("значення", "value"), id="var_value")
|
||||
yield Button(t("Додати", "Add"), id="var_add")
|
||||
yield Static(Text("\n".join(f"{{{k}}} — {t(*v)}" for k, v in widgets.VAR_HELP.items()),
|
||||
style="dim"))
|
||||
yield Checkbox(t("Показувати й при вході в консоль (не лише SSH)",
|
||||
"Also show on console logins (not only SSH)"),
|
||||
bool(self.design.get("local")), id="local")
|
||||
with TabPane(t("Перегляд", "Preview"), id="tab_preview"):
|
||||
yield VerticalScroll(Static(id="preview"), id="preview_box")
|
||||
yield Footer()
|
||||
|
||||
async def on_mount(self):
|
||||
self.sub_title = self.name_
|
||||
self.render_palette()
|
||||
self.refresh_fields()
|
||||
self.refresh_widgets()
|
||||
await self.refresh_vars()
|
||||
self.update_status()
|
||||
self.query_one(Canvas).focus()
|
||||
|
||||
# --- tools panel ------------------------------------------------------------
|
||||
@property
|
||||
def canvas(self):
|
||||
return self.query_one(Canvas)
|
||||
|
||||
def render_palette(self):
|
||||
text = Text()
|
||||
for row in PALETTE:
|
||||
for ch in row:
|
||||
style = "reverse bold" if ch == self.canvas.char else ""
|
||||
text.append(ch, style=style)
|
||||
text.append(" ")
|
||||
text.append("\n")
|
||||
self.query_one("#palette", Static).update(text)
|
||||
|
||||
def on_palette_picked(self, event):
|
||||
self.set_char(event.char)
|
||||
|
||||
def set_char(self, ch):
|
||||
self.canvas.char = ch
|
||||
inp = self.query_one("#char", Input)
|
||||
if inp.value != ch:
|
||||
inp.value = ch
|
||||
self.render_palette()
|
||||
self.update_status()
|
||||
|
||||
def on_radio_set_changed(self, event: RadioSet.Changed):
|
||||
tool = TOOLS[event.index]
|
||||
self.canvas.tool = tool
|
||||
self.canvas.anchor = self.canvas.text_origin = None
|
||||
self.query_one("#help", Static).update(Text(t(*TOOL_HELP[tool])))
|
||||
self.update_status()
|
||||
self.canvas.focus()
|
||||
|
||||
def on_swatches_picked(self, event: Swatches.Picked):
|
||||
if event.swatches.id == "fg":
|
||||
self.canvas.fg = event.color
|
||||
else:
|
||||
self.canvas.bg = event.color
|
||||
self.update_status()
|
||||
|
||||
def update_status(self):
|
||||
c = self.canvas
|
||||
x, y = c.cursor
|
||||
style = " ".join(filter(None, ["bold" if c.bold else "", c.fg or "default", ("on " + c.bg) if c.bg else ""]))
|
||||
text = Text.assemble(
|
||||
f" {t(*TOOL_NAMES[c.tool])} │ ", (c.char * 3, style), f" │ x={x} y={y} │ "
|
||||
f"{self.grid.width}×{self.grid.height} │ {t('поля', 'fields')}: {len(self.fields)}")
|
||||
self.query_one("#status", Static).update(text)
|
||||
|
||||
def snapshot(self):
|
||||
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
del self.undo_stack[:-100]
|
||||
self.redo_stack.clear()
|
||||
self.dirty = True
|
||||
|
||||
def on_canvas_changed(self, event):
|
||||
self.update_status()
|
||||
|
||||
def on_canvas_request(self, event: Canvas.Request):
|
||||
if event.tool == "art":
|
||||
def put(d):
|
||||
if d:
|
||||
self.snapshot()
|
||||
self.grid.stamp(event.x, event.y, banner.Grid.from_design(d))
|
||||
self.canvas.refresh()
|
||||
self.app.push_screen(ArtDialog(), put)
|
||||
elif event.tool == "bigtext":
|
||||
def put_big(result):
|
||||
if not result or not result["text"]:
|
||||
return
|
||||
lines = banner.figlet(result["text"], result["font"])
|
||||
self.snapshot()
|
||||
key = self.canvas.key_now()
|
||||
for i, line in enumerate(lines):
|
||||
for j, ch in enumerate(line):
|
||||
if ch != " ":
|
||||
self.grid.put(event.x + j, event.y + i, ch, key)
|
||||
self.canvas.refresh()
|
||||
self.app.push_screen(AskDialog(t("Великі літери", "Big letters"), [
|
||||
("text", t("Текст", "Text"), "Das OS"),
|
||||
("font", t("Шрифт figlet", "figlet font"), [(n, n) for n in banner.FIGLET_FONTS], "standard")]),
|
||||
put_big)
|
||||
elif event.tool == "field":
|
||||
self.edit_field(event.field_index, event.x, event.y, event.width)
|
||||
|
||||
def edit_field(self, index, x=0, y=0, width=20):
|
||||
new = index is None
|
||||
f = {"x": x, "y": y, "width": max(width, 4) if width > 1 else 20, "text": "{hostname}"} if new \
|
||||
else self.fields[index]
|
||||
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
self.snapshot()
|
||||
if result.get("delete") == "yes":
|
||||
del self.fields[index]
|
||||
else:
|
||||
target = dict(f) if new else f
|
||||
target.update(text=result["text"], align=result["align"],
|
||||
width=int(result["width"]) if result["width"].isdigit() else 20,
|
||||
style=self.canvas.key_now() if new else f.get("style", " "))
|
||||
if result["font"]:
|
||||
target["font"] = result["font"]
|
||||
else:
|
||||
target.pop("font", None)
|
||||
if new:
|
||||
self.fields.append(target)
|
||||
self.canvas.refresh()
|
||||
self.refresh_fields()
|
||||
self.update_status()
|
||||
self.app.push_screen(field_dialog(f, new), done)
|
||||
|
||||
# --- inputs ----------------------------------------------------------------------
|
||||
def on_input_changed(self, event: Input.Changed):
|
||||
if event.input.id == "char" and event.value:
|
||||
self.set_char(event.value[0])
|
||||
elif (event.input.id or "").startswith("var_v_"):
|
||||
self.design["vars"][event.input.id[6:]] = event.value
|
||||
self.dirty = True
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed):
|
||||
if event.checkbox.id == "bold":
|
||||
self.canvas.bold = event.value
|
||||
self.update_status()
|
||||
elif event.checkbox.id == "local":
|
||||
self.design["local"] = event.value
|
||||
self.dirty = True
|
||||
|
||||
def on_select_changed(self, event: Select.Changed):
|
||||
sid = event.select.id
|
||||
if sid == "box_kind":
|
||||
self.canvas.box_kind = event.value
|
||||
elif sid == "position":
|
||||
self.design["widgets_position"] = event.value
|
||||
self.dirty = True
|
||||
elif sid == "w_add" and event.value is not Select.NULL:
|
||||
self.design["widgets"].append({"type": "builtin", "name": event.value})
|
||||
event.select.clear()
|
||||
self.refresh_widgets(len(self.design["widgets"]) - 1)
|
||||
|
||||
# --- fields tab ---------------------------------------------------------------------
|
||||
def refresh_fields(self):
|
||||
lv = self.query_one("#fields_list", ListView)
|
||||
lv.clear()
|
||||
for f in self.fields:
|
||||
font = f" [{f['font']}]" if f.get("font") else ""
|
||||
lv.append(ListItem(Label(Text(f"({f['x']},{f['y']}) ×{f.get('width')} {f.get('align')}{font}: "
|
||||
f"{f.get('text', '')}"))))
|
||||
|
||||
# --- widgets tab --------------------------------------------------------------------
|
||||
def refresh_widgets(self, index=None):
|
||||
lv = self.query_one("#widget_list", ListView)
|
||||
lv.clear()
|
||||
for w in self.design["widgets"]:
|
||||
mark = "☐" if w.get("enabled") is False else "☑"
|
||||
mode = widgets.mode_of(w)
|
||||
info = t("при вході", "at login") if mode == "live" else \
|
||||
t(f"кеш, кожні {widgets.interval_of(w)} с", f"cached, every {widgets.interval_of(w)} s")
|
||||
label = Text.assemble(f"{mark} ", widgets.describe(w), (f" ({info})", "dim"))
|
||||
if w.get("title"):
|
||||
label.append(f" «{w['title']}»", style="italic")
|
||||
lv.append(ListItem(Label(label)))
|
||||
if index is not None:
|
||||
lv.index = index
|
||||
|
||||
def widget_index(self):
|
||||
i = self.query_one("#widget_list", ListView).index
|
||||
return i if i is not None and i < len(self.design["widgets"]) else None
|
||||
|
||||
@work(thread=True, exclusive=True, group="widget_run")
|
||||
def run_widget(self, w):
|
||||
lines = widgets.run(w, widgets.Values(self.design.get("vars")), self.design.get("vars"),
|
||||
timeout=float(w.get("timeout") or 30))
|
||||
out = "\n".join(lines) if lines else t("(порожньо: немає даних, помилка або потрібен root)",
|
||||
"(empty: no data, an error, or it needs root)")
|
||||
self.app.call_from_thread(self.query_one("#widget_out_text", Static).update, ansi_text(out))
|
||||
|
||||
def edit_widget(self, i):
|
||||
w = self.design["widgets"][i]
|
||||
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
w["title"] = result["title"]
|
||||
for key in ("interval", "timeout"):
|
||||
if result[key].isdigit():
|
||||
w[key] = int(result[key])
|
||||
else:
|
||||
w.pop(key, None)
|
||||
if result["mode"]:
|
||||
w["mode"] = result["mode"]
|
||||
else:
|
||||
w.pop("mode", None)
|
||||
if w.get("type") == "command":
|
||||
w["cmd"] = result.get("cmd", w.get("cmd", ""))
|
||||
self.dirty = True
|
||||
self.refresh_widgets(i)
|
||||
fields = [("title", t("Заголовок над виводом (необов'язково)", "Heading above the output (optional)"),
|
||||
w.get("title", "")),
|
||||
("mode", t("Коли виконувати", "When to run"),
|
||||
[(t("як задумано віджетом", "as the widget intends"), ""),
|
||||
(t("при кожному вході (швидкі)", "at every login (quick ones)"), "live"),
|
||||
(t("службою das-banner, з кешем (повільні, від root)", "by the das-banner service, cached "
|
||||
"(slow ones, as root)"), "cached")], w.get("mode", "")),
|
||||
("interval", t("Оновлювати кеш кожні … секунд", "Refresh the cache every … seconds"),
|
||||
str(w.get("interval", ""))),
|
||||
("timeout", t("Тайм-аут, секунд", "Timeout, seconds"), str(w.get("timeout", "")))]
|
||||
if w.get("type") == "command":
|
||||
fields.insert(0, ("cmd", t("Команда", "Command"), w.get("cmd", "")))
|
||||
self.app.push_screen(AskDialog(widgets.describe(w), fields), done)
|
||||
|
||||
# --- variables tab -------------------------------------------------------------------
|
||||
async def refresh_vars(self):
|
||||
box = self.query_one("#vars_box", VerticalScroll)
|
||||
await box.remove_children()
|
||||
rows = []
|
||||
for key, value in self.design["vars"].items():
|
||||
rows.append(Horizontal(Label(Text(f"{{{key}}}"), classes="var_label"),
|
||||
Input(str(value), id="var_v_" + key),
|
||||
Button("✕", id="var_del_" + key), classes="row"))
|
||||
await box.mount(*rows)
|
||||
|
||||
# --- buttons -----------------------------------------------------------------------------
|
||||
async def on_button_pressed(self, event: Button.Pressed):
|
||||
bid = event.button.id or ""
|
||||
ws = self.design["widgets"]
|
||||
i = self.widget_index()
|
||||
if bid == "field_edit":
|
||||
n = self.query_one("#fields_list", ListView).index
|
||||
if n is not None and n < len(self.fields):
|
||||
self.edit_field(n)
|
||||
elif bid == "field_del":
|
||||
n = self.query_one("#fields_list", ListView).index
|
||||
if n is not None and n < len(self.fields):
|
||||
self.snapshot()
|
||||
del self.fields[n]
|
||||
self.refresh_fields()
|
||||
self.canvas.refresh()
|
||||
elif bid == "w_add_cmd":
|
||||
cmd = self.query_one("#w_cmd", Input).value.strip()
|
||||
if cmd:
|
||||
ws.append({"type": "command", "cmd": cmd})
|
||||
self.query_one("#w_cmd", Input).value = ""
|
||||
self.refresh_widgets(len(ws) - 1)
|
||||
elif bid == "w_add_file":
|
||||
path = self.query_one("#w_file", Input).value.strip()
|
||||
if path:
|
||||
ws.append({"type": "file", "path": path})
|
||||
self.query_one("#w_file", Input).value = ""
|
||||
self.refresh_widgets(len(ws) - 1)
|
||||
elif bid.startswith("var_del_"):
|
||||
self.design["vars"].pop(bid[8:], None)
|
||||
await self.refresh_vars()
|
||||
elif bid == "var_add":
|
||||
name = re.sub(r"\W", "", self.query_one("#var_name", Input).value)
|
||||
if name:
|
||||
self.design["vars"][name] = self.query_one("#var_value", Input).value
|
||||
self.query_one("#var_name", Input).value = self.query_one("#var_value", Input).value = ""
|
||||
await self.refresh_vars()
|
||||
elif i is not None:
|
||||
if bid == "w_up" and i > 0:
|
||||
ws[i - 1], ws[i] = ws[i], ws[i - 1]
|
||||
self.refresh_widgets(i - 1)
|
||||
elif bid == "w_down" and i + 1 < len(ws):
|
||||
ws[i + 1], ws[i] = ws[i], ws[i + 1]
|
||||
self.refresh_widgets(i + 1)
|
||||
elif bid == "w_toggle":
|
||||
ws[i]["enabled"] = ws[i].get("enabled") is False
|
||||
self.refresh_widgets(i)
|
||||
elif bid == "w_del":
|
||||
del ws[i]
|
||||
self.refresh_widgets(min(i, len(ws) - 1) if ws else None)
|
||||
elif bid == "w_edit":
|
||||
self.edit_widget(i)
|
||||
elif bid == "w_run":
|
||||
self.query_one("#widget_out_text", Static).update(Text(t("Виконується…", "Running…")))
|
||||
self.run_widget(ws[i])
|
||||
return
|
||||
self.dirty = True
|
||||
|
||||
# --- result ------------------------------------------------------------------------------
|
||||
def current_design(self):
|
||||
return self.grid.to_design(dict(self.design, fields=self.fields, name=self.name_))
|
||||
|
||||
def draft_path(self):
|
||||
return os.path.join(banner.drafts_dir(), self.name_ + ".json")
|
||||
|
||||
def action_save(self):
|
||||
banner.save(self.current_design(), self.draft_path())
|
||||
self.dirty = False
|
||||
self.notify(t(f"Збережено: {self.draft_path()}", f"Saved: {self.draft_path()}"))
|
||||
return True
|
||||
|
||||
def action_undo(self):
|
||||
if self.undo_stack:
|
||||
self.redo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
self._restore(*self.undo_stack.pop())
|
||||
|
||||
def action_redo(self):
|
||||
if self.redo_stack:
|
||||
self.undo_stack.append((self.grid.copy(), copy.deepcopy(self.fields)))
|
||||
self._restore(*self.redo_stack.pop())
|
||||
|
||||
def _restore(self, grid, fields):
|
||||
self.grid.cells, self.grid.styles = grid.cells, grid.styles
|
||||
self.grid.width, self.grid.height = grid.width, grid.height
|
||||
self.fields[:] = fields
|
||||
self.canvas.set_grid(self.grid, self.fields)
|
||||
self.refresh_fields()
|
||||
self.update_status()
|
||||
|
||||
def action_copy(self):
|
||||
if self.canvas.copy_selection():
|
||||
self.notify(t("Скопійовано", "Copied"))
|
||||
|
||||
def action_cut(self):
|
||||
self.canvas.copy_selection(cut=True)
|
||||
|
||||
def action_paste(self):
|
||||
self.canvas.paste()
|
||||
|
||||
def action_resize(self):
|
||||
def done(result):
|
||||
if result and result["w"].isdigit() and result["h"].isdigit():
|
||||
self.snapshot()
|
||||
self.grid.resize(min(int(result["w"]), 300), min(int(result["h"]), 100))
|
||||
self.canvas.set_grid(self.grid, self.fields)
|
||||
self.update_status()
|
||||
self.app.push_screen(AskDialog(t("Розмір полотна", "Canvas size"), [
|
||||
("w", t("Ширина, символів", "Width, characters"), str(self.grid.width)),
|
||||
("h", t("Висота, рядків", "Height, lines"), str(self.grid.height))]), done)
|
||||
|
||||
def action_preview(self):
|
||||
self.query_one("#tabs", TabbedContent).active = "tab_preview"
|
||||
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.build_preview(self.current_design())
|
||||
|
||||
def on_tabbed_content_tab_activated(self, event):
|
||||
if event.pane.id == "tab_preview":
|
||||
self.query_one("#preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.build_preview(self.current_design())
|
||||
elif event.pane.id == "tab_fields":
|
||||
self.refresh_fields()
|
||||
|
||||
@work(thread=True, exclusive=True, group="preview")
|
||||
def build_preview(self, design):
|
||||
values = widgets.Values(design.get("vars"))
|
||||
blocks = widgets.blocks(design, values, use_cache=False)
|
||||
text = banner.render(design, values, blocks)
|
||||
self.app.call_from_thread(self.query_one("#preview", Static).update, ansi_text(text))
|
||||
|
||||
def action_apply(self):
|
||||
self.action_save()
|
||||
if run_as_root(self.app, ["dbanner", "apply", self.draft_path()]):
|
||||
self.notify(t("Банер застосовано: його видно при вході по SSH.",
|
||||
"The banner is applied: it shows at SSH login."), timeout=6)
|
||||
|
||||
def action_back(self):
|
||||
if self.canvas.anchor is not None or self.canvas.selection or self.canvas.text_origin:
|
||||
self.canvas.anchor = self.canvas.selection = self.canvas.text_origin = None
|
||||
self.canvas.refresh()
|
||||
return
|
||||
if not self.dirty:
|
||||
self.dismiss(None)
|
||||
return
|
||||
|
||||
def done(result):
|
||||
if result is None:
|
||||
return
|
||||
if result["save"] == "yes":
|
||||
self.action_save()
|
||||
self.dismiss(None)
|
||||
self.app.push_screen(AskDialog(t("Є незбережені зміни", "There are unsaved changes"), [
|
||||
("save", t("Зберегти чернетку перед виходом?", "Save the draft before leaving?"),
|
||||
[(t("так, зберегти", "yes, save"), "yes"), (t("ні, відкинути", "no, discard"), "no")], "yes")]), done)
|
||||
|
||||
|
||||
# --- start screen ------------------------------------------------------------------
|
||||
|
||||
class StartScreen(Screen):
|
||||
CSS = """
|
||||
#list { width: 46; border-right: solid $primary; }
|
||||
#start_preview { padding: 0 1; }
|
||||
.buttons { height: 3; dock: bottom; }
|
||||
.buttons Button { margin-right: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
Binding("enter", "edit", t("Редагувати", "Edit"), show=False),
|
||||
Binding("e", "edit", t("Редагувати", "Edit")),
|
||||
Binding("n", "new", t("Новий", "New")),
|
||||
Binding("a", "apply", t("Застосувати", "Apply")),
|
||||
Binding("delete", "delete", t("Видалити чернетку", "Delete draft")),
|
||||
Binding("q", "app.quit", t("Вийти", "Quit")),
|
||||
]
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal():
|
||||
yield ListView(id="list")
|
||||
yield VerticalScroll(Static(id="start_preview"))
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button(t("Редагувати копію", "Edit a copy"), id="edit", variant="primary")
|
||||
yield Button(t("Новий", "New"), id="new")
|
||||
yield Button(t("Застосувати", "Apply"), id="apply", variant="success")
|
||||
yield Button(t("Видалити чернетку", "Delete draft"), id="delete", variant="error")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.reload()
|
||||
|
||||
def on_screen_resume(self):
|
||||
self.reload()
|
||||
|
||||
def reload(self):
|
||||
# (kind, name, path or None, label)
|
||||
self.entries = []
|
||||
if os.path.exists(banner.SYSTEM_FILE):
|
||||
self.entries.append(("system", "system", banner.SYSTEM_FILE,
|
||||
t("● Поточний банер системи", "● The system's current banner")))
|
||||
for name, path in banner.list_drafts():
|
||||
self.entries.append(("draft", name, path, t("✎ Чернетка: ", "✎ Draft: ") + name))
|
||||
for name, title_ in banner.list_presets():
|
||||
self.entries.append(("preset", name, os.path.join(banner.preset_dir(), name + ".json"), title_))
|
||||
lv = self.query_one("#list", ListView)
|
||||
index = lv.index or 0
|
||||
lv.clear()
|
||||
for kind, name, _, label in self.entries:
|
||||
lv.append(ListItem(Label(Text(label, style="bold" if kind != "preset" else ""))))
|
||||
lv.index = min(index, len(self.entries) - 1) if self.entries else None
|
||||
|
||||
def selected(self):
|
||||
i = self.query_one("#list", ListView).index
|
||||
return self.entries[i] if i is not None and i < len(self.entries) else None
|
||||
|
||||
def on_list_view_highlighted(self, event):
|
||||
entry = self.selected()
|
||||
if entry:
|
||||
self.query_one("#start_preview", Static).update(Text(t("Збираю дані…", "Collecting data…")))
|
||||
self.show_preview(entry[2])
|
||||
|
||||
def on_list_view_selected(self, event):
|
||||
self.action_edit()
|
||||
|
||||
@work(thread=True, exclusive=True, group="start_preview")
|
||||
def show_preview(self, path):
|
||||
try:
|
||||
design = banner.load_file(path)
|
||||
values = widgets.Values(design.get("vars"))
|
||||
text = banner.render(design, values, widgets.blocks(design, values, use_cache=True))
|
||||
except (OSError, ValueError) as e:
|
||||
text = str(e)
|
||||
self.app.call_from_thread(self.query_one("#start_preview", Static).update, ansi_text(text))
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
getattr(self, "action_" + event.button.id)()
|
||||
|
||||
def action_edit(self):
|
||||
entry = self.selected()
|
||||
if not entry:
|
||||
return
|
||||
kind, name, path, _ = entry
|
||||
design = banner.load_file(path)
|
||||
draft = name if kind == "draft" else unique_draft_name(design.get("name", name) if kind == "system"
|
||||
else name + "-my")
|
||||
self.app.push_screen(EditorScreen(design, draft))
|
||||
|
||||
def action_new(self):
|
||||
def done(result):
|
||||
if result:
|
||||
design = {"name": result["name"], "canvas": {"width": 60, "height": 10}}
|
||||
self.app.push_screen(EditorScreen(design, unique_draft_name(result["name"])))
|
||||
self.app.push_screen(AskDialog(t("Новий банер", "New banner"),
|
||||
[("name", t("Назва чернетки", "Draft name"), "my-banner")]), done)
|
||||
|
||||
def action_apply(self):
|
||||
entry = self.selected()
|
||||
if entry and entry[0] != "system":
|
||||
if run_as_root(self.app, ["dbanner", "apply", entry[2]]):
|
||||
self.notify(t("Банер застосовано.", "The banner is applied."))
|
||||
self.reload()
|
||||
|
||||
def action_delete(self):
|
||||
entry = self.selected()
|
||||
if entry and entry[0] == "draft":
|
||||
os.remove(entry[2])
|
||||
self.notify(t(f"Видалено {entry[1]}", f"Deleted {entry[1]}"))
|
||||
self.reload()
|
||||
|
||||
|
||||
class BannerApp(App):
|
||||
TITLE = "dbanner"
|
||||
SUB_TITLE = t("вітальний банер при вході по SSH", "SSH login banner")
|
||||
|
||||
def __init__(self):
|
||||
# The terminal's own 16 colors: the picture looks as it will at login
|
||||
super().__init__(ansi_color=True)
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(StartScreen())
|
||||
|
||||
|
||||
__all__ = ["BannerApp", "title"]
|
||||
@@ -0,0 +1,315 @@
|
||||
"""The drawing canvas of the dbanner editor: a Grid shown cell by cell, drawn with
|
||||
the mouse or the keyboard."""
|
||||
|
||||
from rich.segment import Segment
|
||||
from rich.style import Style
|
||||
from textual import events
|
||||
from textual.geometry import Size
|
||||
from textual.message import Message
|
||||
from textual.reactive import reactive
|
||||
from textual.scroll_view import ScrollView
|
||||
from textual.strip import Strip
|
||||
|
||||
from . import banner
|
||||
|
||||
TOOLS = ("pen", "eraser", "line", "box", "fill", "text", "bigtext", "field", "art", "select")
|
||||
DRAG_TOOLS = ("line", "box", "fill", "select", "field")
|
||||
|
||||
|
||||
def rich_cell_style(grid, key):
|
||||
fg, bg, bold = grid.style(key)
|
||||
return Style(color=fg, bgcolor=bg, bold=bold)
|
||||
|
||||
|
||||
class Canvas(ScrollView, can_focus=True):
|
||||
"""Shows a grid with its fields. before() is called right before every
|
||||
change (for undo; a message would arrive after the change)."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Canvas { background: $background; }
|
||||
Canvas:focus { border: none; }
|
||||
"""
|
||||
|
||||
cursor = reactive((0, 0), always_update=True)
|
||||
|
||||
class Changed(Message):
|
||||
pass
|
||||
|
||||
class Request(Message):
|
||||
"""A tool needs a dialog: big text, field, picture."""
|
||||
|
||||
def __init__(self, tool, x, y, width=0, field_index=None):
|
||||
super().__init__()
|
||||
self.tool, self.x, self.y, self.width, self.field_index = tool, x, y, width, field_index
|
||||
|
||||
def __init__(self, grid, fields, before=None, **kw):
|
||||
super().__init__(**kw)
|
||||
self.grid, self.fields = grid, fields
|
||||
self.before = before
|
||||
self.tool = "pen"
|
||||
self.char = "█"
|
||||
self.fg, self.bg, self.bold = "cyan", None, False
|
||||
self.box_kind = "single"
|
||||
self.anchor = None # start of a drag or of a keyboard shape
|
||||
self.last = None # last point of the pen
|
||||
self.selection = None # (x0, y0, x1, y1)
|
||||
self.clipboard = None
|
||||
self.text_origin = None
|
||||
|
||||
# --- geometry ------------------------------------------------------------
|
||||
def set_grid(self, grid, fields):
|
||||
self.grid, self.fields = grid, fields
|
||||
self.virtual_size = Size(grid.width + 1, grid.height + 1)
|
||||
self.refresh()
|
||||
|
||||
def on_mount(self):
|
||||
self.virtual_size = Size(self.grid.width + 1, self.grid.height + 1)
|
||||
|
||||
def key_now(self):
|
||||
return self.grid.style_key(self.fg, self.bg, self.bold)
|
||||
|
||||
def _cell_at(self, event):
|
||||
offset = event.get_content_offset(self)
|
||||
if offset is None:
|
||||
return None
|
||||
x, y = offset.x + int(self.scroll_offset.x), offset.y + int(self.scroll_offset.y)
|
||||
return (min(max(x, 0), self.grid.width - 1), min(max(y, 0), self.grid.height - 1))
|
||||
|
||||
# --- rendering --------------------------------------------------------------
|
||||
def _shape_preview(self):
|
||||
"""Cells the current drag would change: {(x, y): (char, key)}."""
|
||||
if self.anchor is None or self.tool not in DRAG_TOOLS or self.tool in ("select", "field"):
|
||||
return {}
|
||||
g = banner.Grid(self.grid.width, self.grid.height, self.grid.styles)
|
||||
x0, y0 = self.anchor
|
||||
x1, y1 = self.cursor
|
||||
key = self.key_now()
|
||||
if self.tool == "line":
|
||||
g.line(x0, y0, x1, y1, self.char, key)
|
||||
elif self.tool == "box":
|
||||
g.box(x0, y0, x1, y1, self.box_kind, key)
|
||||
else:
|
||||
g.fill(x0, y0, x1, y1, self.char, key)
|
||||
return {(x, y): c for y, row in enumerate(g.cells) for x, c in enumerate(row) if c != [" ", " "]}
|
||||
|
||||
def _field_cells(self):
|
||||
cells = {}
|
||||
for n, f in enumerate(self.fields):
|
||||
label = banner.fit(f.get("text", ""), int(f.get("width", 1)), f.get("align", "left"))
|
||||
for i, ch in enumerate(label):
|
||||
cells[(int(f["x"]) + i, int(f["y"]))] = (ch, f.get("style", " "), n)
|
||||
return cells
|
||||
|
||||
def render_line(self, y):
|
||||
sx, sy = self.scroll_offset
|
||||
row = y + int(sy)
|
||||
width = self.size.width
|
||||
if row > self.grid.height:
|
||||
return Strip.blank(width)
|
||||
if row == self.grid.height:
|
||||
# The bottom edge shows where the canvas ends
|
||||
edge = "▔" * self.grid.width + "◢"
|
||||
return Strip([Segment(edge[int(sx):], Style(color="grey35"))]).crop(0, width)
|
||||
preview = self._shape_preview()
|
||||
fields = self._field_cells()
|
||||
sel = None
|
||||
if self.selection:
|
||||
x0, y0, x1, y1 = self.selection
|
||||
sel = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
|
||||
if self.tool in ("select", "field") and self.anchor is not None:
|
||||
(ax, ay), (cx, cy) = self.anchor, self.cursor
|
||||
if self.tool == "field":
|
||||
cy = ay
|
||||
sel = (min(ax, cx), min(ay, cy), max(ax, cx), max(ay, cy))
|
||||
segs = []
|
||||
for x in range(int(sx), self.grid.width):
|
||||
ch, key = preview.get((x, row)) or self.grid.get(x, row)
|
||||
style = rich_cell_style(self.grid, key)
|
||||
if (x, row) in fields:
|
||||
ch, fkey, _ = fields[(x, row)]
|
||||
style = rich_cell_style(self.grid, fkey) + Style(underline=True, italic=True)
|
||||
if sel and sel[0] <= x <= sel[2] and sel[1] <= row <= sel[3]:
|
||||
style += Style(reverse=True)
|
||||
if (x, row) == tuple(self.cursor) and self.has_focus:
|
||||
style += Style(bgcolor="grey50" if ch == " " else None, reverse=ch != " ", blink=False)
|
||||
segs.append(Segment(ch, style))
|
||||
segs.append(Segment("▕", Style(color="grey35")))
|
||||
return Strip(segs).crop(0, width)
|
||||
|
||||
def watch_cursor(self, old, new):
|
||||
self.refresh()
|
||||
self.post_message(self.Changed())
|
||||
|
||||
# --- editing helpers ------------------------------------------------------------
|
||||
def _change(self):
|
||||
if self.before:
|
||||
self.before()
|
||||
|
||||
def _done(self):
|
||||
self.refresh()
|
||||
self.post_message(self.Changed())
|
||||
|
||||
def field_at(self, x, y):
|
||||
for n, f in enumerate(self.fields):
|
||||
if int(f["y"]) == y and int(f["x"]) <= x < int(f["x"]) + int(f.get("width", 1)):
|
||||
return n
|
||||
return None
|
||||
|
||||
def press(self, x, y):
|
||||
"""The mouse button or Enter at a cell."""
|
||||
tool = self.tool
|
||||
if tool in ("pen", "eraser"):
|
||||
self._change()
|
||||
ch, key = (self.char, self.key_now()) if tool == "pen" else (" ", " ")
|
||||
self.grid.put(x, y, ch, key)
|
||||
self.last = (x, y)
|
||||
self._done()
|
||||
elif tool == "field" and self.field_at(x, y) is not None:
|
||||
self.post_message(self.Request("field", x, y, field_index=self.field_at(x, y)))
|
||||
elif tool in DRAG_TOOLS:
|
||||
self.anchor = (x, y)
|
||||
if tool == "select":
|
||||
self.selection = None
|
||||
elif tool == "text":
|
||||
self.text_origin = (x, y)
|
||||
elif tool in ("bigtext", "art"):
|
||||
self.post_message(self.Request(tool, x, y))
|
||||
self.cursor = (x, y)
|
||||
|
||||
def drag(self, x, y):
|
||||
if self.tool in ("pen", "eraser") and self.last is not None:
|
||||
ch, key = (self.char, self.key_now()) if self.tool == "pen" else (" ", " ")
|
||||
self.grid.line(self.last[0], self.last[1], x, y, ch, key)
|
||||
self.last = (x, y)
|
||||
self.refresh()
|
||||
self.cursor = (x, y)
|
||||
|
||||
def release(self, x, y):
|
||||
"""Ends a drag: draws the shape."""
|
||||
self.last = None
|
||||
if self.anchor is None or self.tool not in DRAG_TOOLS:
|
||||
if self.tool in ("pen", "eraser"):
|
||||
self.post_message(self.Changed())
|
||||
return
|
||||
(x0, y0), self.anchor = self.anchor, None
|
||||
key = self.key_now()
|
||||
if self.tool == "select":
|
||||
self.selection = (x0, y0, x, y)
|
||||
elif self.tool == "field":
|
||||
x0, x1 = sorted((x0, x))
|
||||
self.post_message(self.Request("field", x0, y0, width=x1 - x0 + 1))
|
||||
else:
|
||||
self._change()
|
||||
if self.tool == "line":
|
||||
self.grid.line(x0, y0, x, y, self.char, key)
|
||||
elif self.tool == "box":
|
||||
self.grid.box(x0, y0, x, y, self.box_kind, key)
|
||||
elif self.tool == "fill":
|
||||
self.grid.fill(x0, y0, x, y, self.char, key)
|
||||
self._done()
|
||||
self.refresh()
|
||||
|
||||
# --- mouse --------------------------------------------------------------------------
|
||||
def on_mouse_down(self, event: events.MouseDown):
|
||||
cell = self._cell_at(event)
|
||||
if cell and event.button == 1:
|
||||
self.focus()
|
||||
self.capture_mouse()
|
||||
self.press(*cell)
|
||||
|
||||
def on_mouse_move(self, event: events.MouseMove):
|
||||
if event.button == 1:
|
||||
cell = self._cell_at(event)
|
||||
if cell:
|
||||
self.drag(*cell)
|
||||
|
||||
def on_mouse_up(self, event: events.MouseUp):
|
||||
self.release_mouse()
|
||||
cell = self._cell_at(event) or self.cursor
|
||||
self.release(*cell)
|
||||
|
||||
# --- keyboard -----------------------------------------------------------------------
|
||||
def on_key(self, event: events.Key):
|
||||
x, y = self.cursor
|
||||
moves = {"left": (-1, 0), "right": (1, 0), "up": (0, -1), "down": (0, 1)}
|
||||
if event.key in moves:
|
||||
dx, dy = moves[event.key]
|
||||
nx = min(max(x + dx, 0), self.grid.width - 1)
|
||||
ny = min(max(y + dy, 0), self.grid.height - 1)
|
||||
self.cursor = (nx, ny)
|
||||
if self.tool in ("pen", "eraser") and self.last is not None:
|
||||
self.drag(nx, ny)
|
||||
self.scroll_to_region_cell(nx, ny)
|
||||
event.stop()
|
||||
elif self.tool == "text" and self.text_origin is not None and event.key not in ("escape", "tab"):
|
||||
self._type(event)
|
||||
elif event.key in ("enter", "space"):
|
||||
self._keyboard_press(x, y)
|
||||
event.stop()
|
||||
elif event.key in ("delete", "backspace") and self.selection:
|
||||
self._change()
|
||||
x0, y0, x1, y1 = self.selection
|
||||
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
||||
self._done()
|
||||
event.stop()
|
||||
elif event.key == "escape":
|
||||
self.anchor = self.last = self.selection = self.text_origin = None
|
||||
self.refresh()
|
||||
|
||||
def _keyboard_press(self, x, y):
|
||||
if self.tool in DRAG_TOOLS and self.anchor is not None:
|
||||
self.release(x, y)
|
||||
elif self.tool in ("pen", "eraser"):
|
||||
if self.last is None:
|
||||
self.press(x, y) # Enter starts drawing as the cursor moves
|
||||
else:
|
||||
self.last = None
|
||||
self.post_message(self.Changed())
|
||||
else:
|
||||
self.press(x, y)
|
||||
|
||||
def _type(self, event):
|
||||
x, y = self.cursor
|
||||
if event.key == "enter":
|
||||
ox, _ = self.text_origin
|
||||
self.cursor = (ox, min(y + 1, self.grid.height - 1))
|
||||
elif event.key == "backspace":
|
||||
if x > 0:
|
||||
self._change()
|
||||
self.grid.put(x - 1, y, " ", " ")
|
||||
self.cursor = (x - 1, y)
|
||||
self._done()
|
||||
elif event.character and event.is_printable:
|
||||
self._change()
|
||||
self.grid.put(x, y, event.character, self.key_now())
|
||||
self.cursor = (min(x + 1, self.grid.width - 1), y)
|
||||
self._done()
|
||||
else:
|
||||
return
|
||||
event.stop()
|
||||
|
||||
def scroll_to_region_cell(self, x, y):
|
||||
from textual.geometry import Region
|
||||
self.scroll_to_region(Region(x, y, 1, 1), animate=False)
|
||||
|
||||
# --- clipboard ------------------------------------------------------------------------
|
||||
def copy_selection(self, cut=False):
|
||||
if not self.selection:
|
||||
return False
|
||||
x0, y0, x1, y1 = self.selection
|
||||
self.clipboard = self.grid.region(x0, y0, x1, y1)
|
||||
if cut:
|
||||
self._change()
|
||||
self.grid.fill(x0, y0, x1, y1, " ", " ")
|
||||
self._done()
|
||||
return True
|
||||
|
||||
def paste(self):
|
||||
if self.clipboard is None:
|
||||
return False
|
||||
self._change()
|
||||
x, y = self.cursor
|
||||
self.grid.stamp(x, y, self.clipboard, transparent=False)
|
||||
self.selection = (x, y, x + self.clipboard.width - 1, y + self.clipboard.height - 1)
|
||||
self._done()
|
||||
return True
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Shared bits of the dprompt and dbanner command lines."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
from .i18n import t
|
||||
|
||||
|
||||
def die(prog, uk, en, code=1):
|
||||
print(f"{prog}: {t(uk, en)}", file=sys.stderr)
|
||||
sys.exit(code)
|
||||
|
||||
|
||||
def need_root(argv):
|
||||
"""Runs the command again through sudo with the same arguments."""
|
||||
if os.geteuid() == 0:
|
||||
return
|
||||
script = os.path.abspath(sys.argv[0])
|
||||
try:
|
||||
os.execvp("sudo", ["sudo", sys.executable, script] + argv)
|
||||
except OSError as e:
|
||||
print(f"sudo: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def resolve(spec):
|
||||
"""A file argument as an absolute path (sudo starts elsewhere); names stay names."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return os.path.abspath(spec)
|
||||
return spec
|
||||
|
||||
|
||||
def run_tui(prog, loader):
|
||||
try:
|
||||
app = loader()
|
||||
except ImportError as e:
|
||||
die(prog, f"немає бібліотеки Textual ({e}); встановіть python3-textual",
|
||||
f"the Textual library is missing ({e}); install python3-textual")
|
||||
app.run()
|
||||
return 0
|
||||
|
||||
|
||||
def write_file(path, text, mode=0o644):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
os.chmod(tmp, mode)
|
||||
os.replace(tmp, path)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""dbanner: the banner shown at SSH login. Without arguments it opens the editor."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from . import banner, widgets
|
||||
from .cli import die, need_root, resolve, run_tui
|
||||
from .i18n import t
|
||||
|
||||
PROG = "dbanner"
|
||||
SERVICE = "/etc/sv/das-banner"
|
||||
SERVICE_LINK = "/var/service/das-banner"
|
||||
|
||||
|
||||
def text(design, use_cache, color):
|
||||
values = widgets.Values(design.get("vars"))
|
||||
blocks = widgets.blocks(design, values, use_cache=use_cache)
|
||||
return banner.render(design, values, blocks, color=color)
|
||||
|
||||
|
||||
def wants_login_banner(design):
|
||||
if os.path.exists(banner.OFF_FILE) or os.path.exists(os.path.expanduser("~/.hushlogin")):
|
||||
return False
|
||||
if not sys.stdout.isatty():
|
||||
return False
|
||||
return bool(os.environ.get("SSH_TTY")) or bool(design.get("local"))
|
||||
|
||||
|
||||
def has_cached(design):
|
||||
return any(w.get("enabled") is not False and widgets.mode_of(w) == "cached"
|
||||
for w in design.get("widgets", []))
|
||||
|
||||
|
||||
def set_service(enabled):
|
||||
"""Enables the das-banner service (runit) that keeps cached widgets fresh."""
|
||||
if not os.path.isdir(os.path.dirname(SERVICE_LINK)) or not os.path.isdir(SERVICE):
|
||||
return False
|
||||
if enabled and not os.path.lexists(SERVICE_LINK):
|
||||
os.symlink(SERVICE, SERVICE_LINK)
|
||||
elif not enabled and os.path.lexists(SERVICE_LINK):
|
||||
os.remove(SERVICE_LINK)
|
||||
return True
|
||||
|
||||
|
||||
def apply(design):
|
||||
design = banner.normalize(design)
|
||||
banner.save(design, banner.SYSTEM_FILE)
|
||||
os.chmod(banner.SYSTEM_FILE, 0o644)
|
||||
cached = has_cached(design)
|
||||
service = set_service(cached)
|
||||
if service and cached:
|
||||
# The service fills the cache in a moment; this makes the first login see it too
|
||||
subprocess.Popen([sys.executable, os.path.abspath(sys.argv[0]), "refresh"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
|
||||
return cached and service
|
||||
|
||||
|
||||
USAGE = t("""Використання: dbanner [команда]
|
||||
(без команди) редактор банера з готовими варіантами
|
||||
list готові банери, ваші чернетки й малюнки
|
||||
show [НАЗВА|ФАЙЛ] показати банер (типово — системний) зі свіжими даними
|
||||
--no-color без кольорів
|
||||
apply НАЗВА|ФАЙЛ зробити банером при вході по SSH (/etc/das/banner.json)
|
||||
off | on вимкнути або ввімкнути банер
|
||||
refresh [--force] оновити віджети з кешем (робить служба das-banner)
|
||||
Банер показується при вході по SSH; "local": true у файлі — і при вході в консоль.
|
||||
Окремий користувач вимикає його файлом ~/.hushlogin.""",
|
||||
"""Usage: dbanner [command]
|
||||
(no command) banner editor with ready-made designs
|
||||
list ready-made banners, your drafts and pictures
|
||||
show [NAME|FILE] show a banner (default: the system one) with fresh data
|
||||
--no-color without colors
|
||||
apply NAME|FILE make it the SSH login banner (/etc/das/banner.json)
|
||||
off | on turn the banner off or on
|
||||
refresh [--force] update the cached widgets (the das-banner service does it)
|
||||
The banner is shown at SSH logins; "local": true in the file shows it on the
|
||||
console too. A user turns it off for themselves with ~/.hushlogin.""")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if not argv:
|
||||
from . import banner_app
|
||||
return run_tui(PROG, banner_app.BannerApp)
|
||||
cmd, args = argv[0], argv[1:]
|
||||
flags = {a for a in args if a.startswith("--")}
|
||||
args = [a for a in args if not a.startswith("--")]
|
||||
try:
|
||||
if cmd in ("-h", "--help", "help"):
|
||||
print(USAGE)
|
||||
elif cmd == "list":
|
||||
print(t("Готові:", "Ready-made:"))
|
||||
for name, title in banner.list_presets():
|
||||
print(f" {name:<14} {title}")
|
||||
drafts = banner.list_drafts()
|
||||
if drafts:
|
||||
print(t("Чернетки:", "Drafts:"))
|
||||
for name, path in drafts:
|
||||
print(f" {name:<14} {path}")
|
||||
print(t("Малюнки (вставляються в редакторі):", "Pictures (inserted in the editor):"))
|
||||
for name, title, _ in banner.list_art():
|
||||
print(f" {name:<14} {title}")
|
||||
elif cmd == "show":
|
||||
if "--login" in flags:
|
||||
design = banner.system_design()
|
||||
if wants_login_banner(design):
|
||||
sys.stdout.write(text(design, use_cache=True, color=True))
|
||||
sys.stdout.flush()
|
||||
# A hung widget thread must not hold the login
|
||||
os._exit(0)
|
||||
design = banner.load(args[0]) if args else banner.system_design()
|
||||
color = "--no-color" not in flags and sys.stdout.isatty() or "--color" in flags
|
||||
sys.stdout.write(text(design, use_cache=not args, color=color))
|
||||
elif cmd == "apply":
|
||||
if not args:
|
||||
die(PROG, "вкажіть назву або файл", "name a design or a file")
|
||||
spec = resolve(args[0])
|
||||
design = banner.load(spec)
|
||||
need_root(["apply", spec])
|
||||
cached = apply(design)
|
||||
print(t("Банер записано в /etc/das/banner.json, його видно при наступному вході по SSH.",
|
||||
"The banner is written to /etc/das/banner.json; it shows at the next SSH login."))
|
||||
if cached:
|
||||
print(t("Віджети з кешем оновлює служба das-banner (увімкнено).",
|
||||
"Cached widgets are updated by the das-banner service (enabled)."))
|
||||
elif cmd in ("off", "on"):
|
||||
need_root([cmd])
|
||||
if cmd == "off":
|
||||
open(banner.OFF_FILE, "w").close()
|
||||
elif os.path.exists(banner.OFF_FILE):
|
||||
os.remove(banner.OFF_FILE)
|
||||
print(t("Банер вимкнено." if cmd == "off" else "Банер увімкнено.",
|
||||
"The banner is off." if cmd == "off" else "The banner is on."))
|
||||
elif cmd == "refresh":
|
||||
if "--loop" in flags:
|
||||
while True:
|
||||
widgets.refresh(banner.system_design())
|
||||
time.sleep(60)
|
||||
widgets.refresh(banner.system_design(), force="--force" in flags)
|
||||
else:
|
||||
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
|
||||
except FileNotFoundError as e:
|
||||
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
|
||||
except PermissionError as e:
|
||||
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,132 @@
|
||||
"""dprompt: builds the bash prompt. Without arguments it opens the editor."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
from . import ETC_DIR, config_dir, prompt
|
||||
from .cli import die, need_root, resolve, run_tui, write_file
|
||||
from .i18n import t
|
||||
|
||||
PROG = "dprompt"
|
||||
|
||||
|
||||
def targets(system):
|
||||
"""(prompt.sh, design json) of the user or of the whole system."""
|
||||
base = ETC_DIR if system else config_dir()
|
||||
return os.path.join(base, "prompt.sh"), os.path.join(base, "prompt.json")
|
||||
|
||||
|
||||
def current():
|
||||
"""The design in effect for this user: their own, the system one or the stock one."""
|
||||
for system in (False, True):
|
||||
path = targets(system)[1]
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
return prompt.load_file(path), path
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return prompt.load_preset("das"), None
|
||||
|
||||
|
||||
def apply(design, system):
|
||||
sh, js = targets(system)
|
||||
design = prompt.normalize(design)
|
||||
write_file(js, prompt.dumps(design))
|
||||
write_file(sh, prompt.script(design))
|
||||
return sh
|
||||
|
||||
|
||||
def reset(system):
|
||||
for path in targets(system):
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
USAGE = t("""Використання: dprompt [команда]
|
||||
(без команди) редактор запрошення з переглядом
|
||||
list готові запрошення
|
||||
show [НАЗВА|ФАЙЛ] як виглядає запрошення (типово — поточне)
|
||||
--script код для bash замість вигляду
|
||||
apply НАЗВА|ФАЙЛ застосувати для себе (~/.config/das/prompt.sh)
|
||||
--system для всіх користувачів (/etc/das/prompt.sh, через sudo)
|
||||
reset [--system] повернути стандартне запрошення
|
||||
Нове запрошення діє в нових вікнах терміналу або після exec bash.""",
|
||||
"""Usage: dprompt [command]
|
||||
(no command) prompt editor with a live preview
|
||||
list ready-made prompts
|
||||
show [NAME|FILE] what the prompt looks like (default: the current one)
|
||||
--script the bash code instead
|
||||
apply NAME|FILE use it yourself (~/.config/das/prompt.sh)
|
||||
--system for every user (/etc/das/prompt.sh, through sudo)
|
||||
reset [--system] back to the stock prompt
|
||||
A new prompt works in new terminal windows or after exec bash.""")
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
argv = sys.argv[1:] if argv is None else argv
|
||||
if not argv:
|
||||
from . import prompt_app
|
||||
return run_tui(PROG, prompt_app.PromptApp)
|
||||
cmd, args = argv[0], argv[1:]
|
||||
system = "--system" in args
|
||||
args = [a for a in args if a != "--system"]
|
||||
try:
|
||||
if cmd in ("-h", "--help", "help"):
|
||||
print(USAGE)
|
||||
elif cmd == "list":
|
||||
for name, title in prompt.list_presets():
|
||||
print(f"{name:<12} {title}")
|
||||
elif cmd == "show":
|
||||
script = "--script" in args
|
||||
args = [a for a in args if a != "--script"]
|
||||
design = prompt.load(args[0]) if args else current()[0]
|
||||
if script:
|
||||
sys.stdout.write(prompt.script(design))
|
||||
else:
|
||||
for state in (prompt.sample_state(), prompt.sample_state(rc=1, git="main*", venv=".venv"),
|
||||
prompt.sample_state(root=True)):
|
||||
print(_ansi(design, state))
|
||||
print()
|
||||
elif cmd == "apply":
|
||||
if not args:
|
||||
die(PROG, "вкажіть назву або файл", "name a preset or a file")
|
||||
spec = resolve(args[0])
|
||||
if system:
|
||||
need_root(["apply", spec, "--system"])
|
||||
path = apply(prompt.load(spec), system)
|
||||
print(t(f"Запрошення записано в {path}. Відкрийте новий термінал або виконайте: exec bash",
|
||||
f"The prompt is written to {path}. Open a new terminal or run: exec bash"))
|
||||
elif cmd == "reset":
|
||||
if system:
|
||||
need_root(["reset", "--system"])
|
||||
reset(system)
|
||||
print(t("Повернуто стандартне запрошення (діє в новому терміналі).",
|
||||
"The stock prompt is back (in new terminals)."))
|
||||
else:
|
||||
die(PROG, f"невідома команда: {cmd}\n{USAGE}", f"unknown command: {cmd}\n{USAGE}", 2)
|
||||
except FileNotFoundError as e:
|
||||
die(PROG, f"не знайдено: {e.filename or e}", f"not found: {e.filename or e}")
|
||||
except (ValueError, json.JSONDecodeError) as e:
|
||||
die(PROG, f"помилка у файлі: {e}", f"bad file: {e}")
|
||||
except PermissionError as e:
|
||||
die(PROG, f"немає доступу: {e.filename}", f"permission denied: {e.filename}")
|
||||
return 0
|
||||
|
||||
|
||||
def _ansi(design, state):
|
||||
from . import colors
|
||||
out = []
|
||||
for text, style in prompt.preview(design, state):
|
||||
if style:
|
||||
words = style.split()
|
||||
out.append(colors.sgr(words[-1] if words[-1] != "default" else None, None, "bold" in words))
|
||||
else:
|
||||
out.append(colors.RESET)
|
||||
out.append(text)
|
||||
return "".join(out) + colors.RESET
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Terminal colors shared by the prompt and the banner: 16 ANSI names or #rrggbb."""
|
||||
|
||||
import re
|
||||
|
||||
BASIC = ["black", "red", "green", "yellow", "blue", "magenta", "cyan", "white"]
|
||||
NAMES = BASIC + ["bright_" + c for c in BASIC]
|
||||
|
||||
_HEX = re.compile(r"^#[0-9a-fA-F]{6}$")
|
||||
|
||||
|
||||
def valid(color):
|
||||
return color is None or color in NAMES or bool(_HEX.match(str(color)))
|
||||
|
||||
|
||||
def _code(color, background):
|
||||
if color in BASIC:
|
||||
return str((40 if background else 30) + BASIC.index(color))
|
||||
if color in NAMES:
|
||||
return str((100 if background else 90) + BASIC.index(color[7:]))
|
||||
if color and _HEX.match(color):
|
||||
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
|
||||
return f"{48 if background else 38};2;{r};{g};{b}"
|
||||
return None
|
||||
|
||||
|
||||
def sgr_params(fg=None, bg=None, bold=False):
|
||||
"""SGR parameters ("0;1;36") that reset the style first, then set it."""
|
||||
params = ["0"]
|
||||
if bold:
|
||||
params.append("1")
|
||||
for color, background in ((fg, False), (bg, True)):
|
||||
code = _code(color, background)
|
||||
if code:
|
||||
params.append(code)
|
||||
return ";".join(params)
|
||||
|
||||
|
||||
def sgr(fg=None, bg=None, bold=False):
|
||||
return f"\033[{sgr_params(fg, bg, bold)}m"
|
||||
|
||||
|
||||
RESET = "\033[0m"
|
||||
|
||||
|
||||
def rich_style(fg=None, bg=None, bold=False):
|
||||
"""The same style as a Rich style string ("bold bright_cyan on blue")."""
|
||||
parts = []
|
||||
if bold:
|
||||
parts.append("bold")
|
||||
parts.append(fg or "default")
|
||||
if bg:
|
||||
parts += ["on", bg]
|
||||
return " ".join(parts)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Ukrainian or English messages, chosen like the shell helpers do (LC_ALL, LC_MESSAGES, LANG)."""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def _ukrainian():
|
||||
for var in ("LC_ALL", "LC_MESSAGES", "LANG"):
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
return value.startswith("uk")
|
||||
return False
|
||||
|
||||
|
||||
UK = _ukrainian()
|
||||
|
||||
|
||||
def t(uk, en):
|
||||
"""The message in the user's language."""
|
||||
return uk if UK else en
|
||||
|
||||
|
||||
def title(obj, default=""):
|
||||
"""A {"uk": ..., "en": ...} title (or a plain string) in the user's language."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get("uk" if UK else "en") or obj.get("en") or default
|
||||
return obj or default
|
||||
@@ -0,0 +1,426 @@
|
||||
"""Bash prompt model of dprompt: segments in a frame, turned into PS1 code or a preview.
|
||||
|
||||
A design is JSON:
|
||||
{"name": "das", "frame": "das", "frame_fg": "red", "open": "[", "close": "]",
|
||||
"sep": "─", "segments": [{"type": "status"}, {"type": "userhost"}, ...]}
|
||||
|
||||
Static segments become PS1 escapes (\\u, \\w, \\D{...}). Conditional ones (exit
|
||||
status, git branch, venv, jobs, ssh, command output) are computed by a function
|
||||
that the PROMPT_COMMAND hook of /usr/lib/das/prompt-lib.sh calls before each
|
||||
prompt; PS1 only reads their variables, so branch names are never evaluated as code.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import DATA_DIR, colors
|
||||
from .i18n import t, title
|
||||
|
||||
FRAMES = ("das", "plain")
|
||||
|
||||
# type: (Ukrainian name, English name, default options)
|
||||
SEGMENTS = {
|
||||
"status": ("Код помилки", "Exit status", {"fg": "white", "mode": "mark", "mark": "✗"}),
|
||||
"userhost": ("Користувач@хост", "User@host",
|
||||
{"fg": "bright_white", "at_fg": "yellow", "at_bold": True,
|
||||
"host_fg": "cyan", "host_bold": True, "root_fg": "red", "root_bold": True}),
|
||||
"user": ("Користувач", "User", {"fg": "bright_white", "root_fg": "red", "root_bold": True}),
|
||||
"host": ("Хост", "Host", {"fg": "cyan", "bold": True, "full": False}),
|
||||
"cwd": ("Каталог", "Directory", {"fg": "bright_green", "mode": "full", "depth": 3}),
|
||||
"date": ("Дата", "Date", {"fg": "white", "format": "%Y-%m-%d"}),
|
||||
"time": ("Час", "Time", {"fg": "white", "seconds": False}),
|
||||
"git": ("Гілка git", "Git branch", {"fg": "bright_magenta"}),
|
||||
"venv": ("Python venv", "Python venv", {"fg": "bright_blue"}),
|
||||
"jobs": ("Фонові задачі", "Background jobs", {"fg": "yellow", "prefix": "jobs:"}),
|
||||
"ssh": ("Ознака SSH", "SSH mark", {"fg": "bright_red", "bold": True, "text": "ssh"}),
|
||||
"text": ("Текст", "Text", {"fg": None, "text": ""}),
|
||||
"cmd": ("Вивід команди", "Command output", {"fg": "white", "cmd": ""}),
|
||||
"newline": ("Новий рядок", "New line", {}),
|
||||
"symbol": ("Символ $/#", "Symbol $/#", {"fg": "yellow", "char": ""}),
|
||||
}
|
||||
|
||||
# Glyphs missing from the Linux console font
|
||||
CONSOLE_GLYPHS = {"╼": ">", "✗": "x", "❯": ">", "➜": ">", "λ": ">", "⚙": "*", "●": "*"}
|
||||
|
||||
VOID_PS1 = "[\\u@\\h \\W]\\$ "
|
||||
|
||||
|
||||
def segment_name(seg_type):
|
||||
uk, en, _ = SEGMENTS[seg_type]
|
||||
return t(uk, en)
|
||||
|
||||
|
||||
def new_segment(seg_type):
|
||||
seg = {"type": seg_type}
|
||||
seg.update(copy.deepcopy(SEGMENTS[seg_type][2]))
|
||||
return seg
|
||||
|
||||
|
||||
def normalize(design):
|
||||
"""Fills in the defaults, so older or hand-written files keep working."""
|
||||
d = copy.deepcopy(design)
|
||||
d.setdefault("name", "custom")
|
||||
d.setdefault("frame", "das")
|
||||
if d["frame"] not in FRAMES:
|
||||
d["frame"] = "das"
|
||||
d.setdefault("frame_fg", "red")
|
||||
d.setdefault("open", "[" if d["frame"] == "das" else "")
|
||||
d.setdefault("close", "]" if d["frame"] == "das" else "")
|
||||
d.setdefault("sep", "─" if d["frame"] == "das" else " ")
|
||||
segs = []
|
||||
for seg in d.get("segments", []):
|
||||
if seg.get("type") not in SEGMENTS:
|
||||
continue
|
||||
full = new_segment(seg["type"])
|
||||
full.update(seg)
|
||||
segs.append(full)
|
||||
d["segments"] = segs
|
||||
return d
|
||||
|
||||
|
||||
# --- presets and files ------------------------------------------------------
|
||||
|
||||
def preset_dir():
|
||||
return os.path.join(DATA_DIR, "prompt", "presets")
|
||||
|
||||
|
||||
def list_presets():
|
||||
"""(name, title) of the shipped presets, "das" first."""
|
||||
out = []
|
||||
try:
|
||||
names = sorted(f[:-5] for f in os.listdir(preset_dir()) if f.endswith(".json"))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in sorted(names, key=lambda n: (n != "das", n)):
|
||||
try:
|
||||
out.append((name, title(load_preset(name).get("title"), name)))
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def load_preset(name):
|
||||
return load_file(os.path.join(preset_dir(), name + ".json"))
|
||||
|
||||
|
||||
def load_file(path):
|
||||
with open(path, encoding="utf-8") as f:
|
||||
return normalize(json.load(f))
|
||||
|
||||
|
||||
def load(spec):
|
||||
"""A preset name or a path to a design file."""
|
||||
if os.sep in spec or spec.endswith(".json"):
|
||||
return load_file(spec)
|
||||
return load_preset(spec)
|
||||
|
||||
|
||||
def dumps(design):
|
||||
return json.dumps(design, ensure_ascii=False, indent=2) + "\n"
|
||||
|
||||
|
||||
# --- pieces -------------------------------------------------------------------
|
||||
# Lit: literal text; Esc: a PS1 escape such as \u; Var: a variable the hook sets;
|
||||
# Dyn: pieces shown only when a condition holds. Style is (fg, bold) or None.
|
||||
|
||||
class Lit:
|
||||
def __init__(self, text, style=None):
|
||||
self.text, self.style = text, style
|
||||
|
||||
|
||||
class Esc:
|
||||
def __init__(self, code, style=None):
|
||||
self.code, self.style = code, style
|
||||
|
||||
|
||||
class Var:
|
||||
def __init__(self, name, style=None):
|
||||
self.name, self.style = name, style
|
||||
|
||||
|
||||
class Dyn:
|
||||
def __init__(self, cond, pieces, extra=None):
|
||||
self.cond, self.pieces, self.extra = cond, pieces, extra or {}
|
||||
|
||||
|
||||
def _st(seg, prefix=""):
|
||||
return (seg.get(prefix + "fg"), bool(seg.get(prefix + "bold")))
|
||||
|
||||
|
||||
def _content(seg, index, root):
|
||||
"""(pieces, condition or None, bracketed?) for one segment."""
|
||||
kind = seg["type"]
|
||||
style = _st(seg)
|
||||
if kind == "status":
|
||||
inner = [Var("rc", style)] if seg.get("mode") == "code" else [Lit(seg.get("mark") or "✗", style)]
|
||||
return inner, "error", True
|
||||
if kind == "userhost":
|
||||
user = _st(seg, "root_") if root else style
|
||||
return [Esc("\\u", user), Lit("@", _st(seg, "at_")), Esc("\\h", _st(seg, "host_"))], None, True
|
||||
if kind == "user":
|
||||
return [Esc("\\u", _st(seg, "root_") if root else style)], None, True
|
||||
if kind == "host":
|
||||
return [Esc("\\H" if seg.get("full") else "\\h", style)], None, True
|
||||
if kind == "cwd":
|
||||
return [Esc("\\W" if seg.get("mode") == "base" else "\\w", style)], None, True
|
||||
if kind == "date":
|
||||
return [Esc("\\D{%s}" % (seg.get("format") or "%Y-%m-%d"), style)], None, True
|
||||
if kind == "time":
|
||||
return [Esc("\\t" if seg.get("seconds") else "\\A", style)], None, True
|
||||
if kind == "git":
|
||||
return [Var("git", style)], "git", True
|
||||
if kind == "venv":
|
||||
return [Var("venv", style)], "venv", True
|
||||
if kind == "jobs":
|
||||
return [Lit(seg.get("prefix") or "", style), Var("jobs", style)], "jobs", True
|
||||
if kind == "ssh":
|
||||
return [Lit(seg.get("text") or "ssh", style)], "ssh", True
|
||||
if kind == "text":
|
||||
return [Lit(seg.get("text") or "", style)], None, bool(seg.get("bracket", False))
|
||||
if kind == "cmd":
|
||||
return [Var(f"cmd{index}", style)], f"cmd{index}", True
|
||||
if kind == "symbol":
|
||||
char = seg.get("char") or ""
|
||||
return [Lit(char, style) if char else Esc("\\$", style)], None, False
|
||||
raise ValueError(kind)
|
||||
|
||||
|
||||
def pieces(design, root=False):
|
||||
"""The prompt as a flat list of pieces."""
|
||||
d = normalize(design)
|
||||
frame = (d["frame_fg"], False)
|
||||
lines = [[]]
|
||||
for i, seg in enumerate(d["segments"]):
|
||||
if seg["type"] == "newline":
|
||||
lines.append([])
|
||||
else:
|
||||
lines[-1].append((i, seg))
|
||||
out = []
|
||||
for n, items in enumerate(lines):
|
||||
if n:
|
||||
out.append(Lit("\n"))
|
||||
if d["frame"] == "das" and len(lines) > 1:
|
||||
prefix = "┌─" if n == 0 else ("└─╼ " if n == len(lines) - 1 else "├─")
|
||||
out.append(Lit(prefix, frame))
|
||||
first_static = next((k for k, (i, s) in enumerate(items)
|
||||
if _content(s, i, root)[1] is None), len(items))
|
||||
for k, (i, seg) in enumerate(items):
|
||||
content, cond, bracket = _content(seg, i, root)
|
||||
is_symbol = seg["type"] == "symbol"
|
||||
group = []
|
||||
if bracket and d["open"]:
|
||||
group.append(Lit(d["open"], frame))
|
||||
group += content
|
||||
if bracket and d["close"]:
|
||||
group.append(Lit(d["close"], frame))
|
||||
sep = Lit(" " if is_symbol else d["sep"], frame) if d["sep"] else None
|
||||
if cond is not None:
|
||||
# A hidden segment must take its separator with it
|
||||
if k < first_static:
|
||||
group = group + ([sep] if sep and k + 1 < len(items) else [])
|
||||
elif sep:
|
||||
group = [sep] + group
|
||||
out.append(Dyn(cond, group, {"cmd": seg.get("cmd", "")}))
|
||||
else:
|
||||
if k > first_static and sep:
|
||||
out.append(sep)
|
||||
out += group
|
||||
out.append(Lit(" "))
|
||||
return _merge(out)
|
||||
|
||||
|
||||
def _merge(items):
|
||||
"""Joins neighbouring literals of the same style."""
|
||||
out = []
|
||||
for p in items:
|
||||
if isinstance(p, Lit) and out and isinstance(out[-1], Lit) and out[-1].style == p.style:
|
||||
out[-1] = Lit(out[-1].text + p.text, p.style)
|
||||
else:
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
# --- bash code ----------------------------------------------------------------
|
||||
|
||||
def _ps1_text(text):
|
||||
return text.replace("\\", "\\\\").replace("$", "\\044").replace("`", "\\140")
|
||||
|
||||
|
||||
def _sq(text):
|
||||
return "'" + text.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _ansi_q(text):
|
||||
return text.replace("\\", "\\\\").replace("'", "\\'")
|
||||
|
||||
|
||||
class _Bash:
|
||||
def __init__(self, color, console):
|
||||
self.color, self.console = color, console
|
||||
|
||||
def glyphs(self, text):
|
||||
if self.console:
|
||||
for a, b in CONSOLE_GLYPHS.items():
|
||||
text = text.replace(a, b)
|
||||
return text
|
||||
|
||||
def ps1(self, items):
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and not isinstance(p, Dyn) and style is not None and style != current:
|
||||
out.append("\\[\\e[%sm\\]" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append(_ps1_text(self.glyphs(p.text)))
|
||||
elif isinstance(p, Esc):
|
||||
out.append(p.code)
|
||||
elif isinstance(p, Dyn):
|
||||
out.append("${__das_p_%s}" % p.cond)
|
||||
current = None # the dynamic part leaves its own color behind
|
||||
if self.color:
|
||||
out.append("\\[\\e[0m\\]")
|
||||
return "".join(out)
|
||||
|
||||
def value(self, items):
|
||||
"""A bash word with the dynamic part: $'...' literals and "$var"s."""
|
||||
out, current = [], None
|
||||
for p in items:
|
||||
style = getattr(p, "style", None)
|
||||
if self.color and style is not None and style != current:
|
||||
out.append("$'\\001\\e[%sm\\002'" % colors.sgr_params(style[0], None, style[1]))
|
||||
current = style
|
||||
if isinstance(p, Lit):
|
||||
out.append("$'%s'" % _ansi_q(self.glyphs(p.text)))
|
||||
elif isinstance(p, Var):
|
||||
out.append('"$__das_%s"' % p.name)
|
||||
return "".join(out) or "''"
|
||||
|
||||
def dynamic(self, items):
|
||||
lines = []
|
||||
for p in items:
|
||||
if not isinstance(p, Dyn):
|
||||
continue
|
||||
var, val = "__das_p_" + p.cond, self.value(p.pieces)
|
||||
test = {
|
||||
"error": "[[ $__das_rc != 0 ]]",
|
||||
"git": "__das_git_info",
|
||||
"venv": "[[ -n ${VIRTUAL_ENV:-} ]] && __das_venv=${VIRTUAL_ENV##*/}",
|
||||
"jobs": "__das_jobs_count",
|
||||
"ssh": "[[ -n ${SSH_CONNECTION:-} ]]",
|
||||
}.get(p.cond)
|
||||
if test is None: # command output
|
||||
lines.append(f" __das_{p.cond}=$({{ {p.extra['cmd']}; }} 2>/dev/null)")
|
||||
test = f"[[ -n $__das_{p.cond} ]]"
|
||||
lines.append(f" {var}=")
|
||||
lines.append(f" {test} && {var}={val}")
|
||||
return lines
|
||||
|
||||
|
||||
def script(design):
|
||||
"""The prompt.sh that /etc/bash/bashrc.d/das-prompt.sh sources."""
|
||||
d = normalize(design)
|
||||
out = [
|
||||
f"# Das OS bash prompt \"{d['name']}\", made by dprompt: run dprompt to change it.",
|
||||
"# shellcheck shell=bash disable=SC2016",
|
||||
]
|
||||
cwd = next((s for s in d["segments"] if s["type"] == "cwd"), None)
|
||||
if cwd and cwd.get("mode") == "short":
|
||||
out.append(f"PROMPT_DIRTRIM={int(cwd.get('depth') or 3)}")
|
||||
else:
|
||||
out.append("unset PROMPT_DIRTRIM")
|
||||
if any(s["type"] == "venv" for s in d["segments"]):
|
||||
out.append("VIRTUAL_ENV_DISABLE_PROMPT=1")
|
||||
variants = [("[[ $TERM == linux ]]", True, True),
|
||||
("__das_has_color", True, False),
|
||||
(None, False, False)]
|
||||
for n, (test, color, console) in enumerate(variants):
|
||||
b = _Bash(color, console)
|
||||
user, root = pieces(d, False), pieces(d, True)
|
||||
head = "if" if n == 0 else ("elif" if test else "else")
|
||||
out.append(f"{head} {test}; then" if test else "else")
|
||||
body = b.dynamic(user)
|
||||
out.append(" __das_prompt_dynamic() {")
|
||||
out += [" " + line for line in body] or [" :"]
|
||||
out.append(" }")
|
||||
out.append(" if ((EUID == 0)); then")
|
||||
out.append(" PS1=" + _sq(b.ps1(root)))
|
||||
out.append(" else")
|
||||
out.append(" PS1=" + _sq(b.ps1(user)))
|
||||
out.append(" fi")
|
||||
out.append("fi")
|
||||
out.append("case $TERM in xterm* | rxvt* | alacritty | foot*) PS1='\\[\\e]0;\\u@\\h: \\w\\a\\]'$PS1 ;; esac")
|
||||
out.append("__das_prompt_install")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
|
||||
# --- preview ----------------------------------------------------------------------
|
||||
|
||||
def sample_state(**over):
|
||||
now = datetime.datetime.now()
|
||||
state = {"user": "das", "host": "dasos", "fqdn": "dasos.lan", "home": "/home/das",
|
||||
"cwd": "/home/das/Projects/web", "rc": 0, "git": "", "venv": "", "jobs": 0,
|
||||
"ssh": False, "root": False, "cmd": "…", "now": now}
|
||||
state.update(over)
|
||||
if state["root"]:
|
||||
state["user"] = "root"
|
||||
return state
|
||||
|
||||
|
||||
def _esc_value(code, s):
|
||||
if code == "\\u":
|
||||
return s["user"]
|
||||
if code == "\\h":
|
||||
return s["host"]
|
||||
if code == "\\H":
|
||||
return s["fqdn"]
|
||||
if code in ("\\w", "\\W"):
|
||||
home = "/root" if s["root"] else s["home"]
|
||||
cwd = s["cwd"] if not s["root"] else "/etc/sv"
|
||||
short = "~" + cwd[len(home):] if cwd.startswith(home) else cwd
|
||||
return short if code == "\\w" else (os.path.basename(cwd) or "/")
|
||||
if code.startswith("\\D{"):
|
||||
return s["now"].strftime(code[3:-1])
|
||||
if code == "\\A":
|
||||
return s["now"].strftime("%H:%M")
|
||||
if code == "\\t":
|
||||
return s["now"].strftime("%H:%M:%S")
|
||||
if code == "\\$":
|
||||
return "#" if s["root"] else "$"
|
||||
return code
|
||||
|
||||
|
||||
def _cond_holds(cond, s):
|
||||
return {"error": s["rc"] != 0, "git": bool(s["git"]), "venv": bool(s["venv"]),
|
||||
"jobs": s["jobs"] > 0, "ssh": s["ssh"]}.get(cond, bool(s["cmd"]))
|
||||
|
||||
|
||||
def _var_value(name, s):
|
||||
return {"rc": str(s["rc"]), "git": s["git"], "venv": s["venv"],
|
||||
"jobs": str(s["jobs"])}.get(name, s["cmd"])
|
||||
|
||||
|
||||
def preview(design, state):
|
||||
"""[(text, rich style or "")] of the prompt as the user would see it."""
|
||||
out = []
|
||||
|
||||
def walk(items):
|
||||
for p in items:
|
||||
style = colors.rich_style(p.style[0], None, p.style[1]) if getattr(p, "style", None) else ""
|
||||
if isinstance(p, Lit):
|
||||
out.append((p.text, style))
|
||||
elif isinstance(p, Esc):
|
||||
out.append((_esc_value(p.code, state), style))
|
||||
elif isinstance(p, Var):
|
||||
out.append((_var_value(p.name, state), style))
|
||||
elif isinstance(p, Dyn) and _cond_holds(p.cond, state):
|
||||
walk(p.pieces)
|
||||
|
||||
walk(pieces(design, state["root"]))
|
||||
return out
|
||||
|
||||
|
||||
def preview_text(design, state):
|
||||
return "".join(text for text, _ in preview(design, state))
|
||||
@@ -0,0 +1,293 @@
|
||||
"""dprompt editor: segments on the left, their settings on the right, a live preview below."""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
from rich.text import Text
|
||||
from textual.app import App
|
||||
from textual.containers import Horizontal, Vertical, VerticalScroll
|
||||
from textual.widgets import Button, Checkbox, Footer, Header, Input, Label, ListItem, ListView, Select, Static
|
||||
|
||||
from . import prompt
|
||||
from .cli_prompt import apply as apply_design, current
|
||||
from .i18n import t
|
||||
from .tui_common import NONE, AskDialog, color_options, color_value, run_as_root, to_select
|
||||
|
||||
# Settings of each segment type: (key, kind, Ukrainian label, English label)
|
||||
COLOR_KEYS = [("fg", "color", "Колір", "Color"), ("bold", "bool", "Жирний", "Bold")]
|
||||
OPTIONS = {
|
||||
"status": COLOR_KEYS + [("mode", "choice:mark=знак ✗|mark=mark ✗;code=код виходу|code=exit code", "Показ", "Shows"),
|
||||
("mark", "text", "Знак", "Mark")],
|
||||
"userhost": [("fg", "color", "Колір користувача", "User color"), ("bold", "bool", "Жирний", "Bold"),
|
||||
("at_fg", "color", "Колір @", "@ color"), ("at_bold", "bool", "@ жирне", "Bold @"),
|
||||
("host_fg", "color", "Колір хоста", "Host color"), ("host_bold", "bool", "Хост жирний", "Bold host"),
|
||||
("root_fg", "color", "Колір для root", "Color for root"), ("root_bold", "bool", "root жирний", "Bold root")],
|
||||
"user": COLOR_KEYS + [("root_fg", "color", "Колір для root", "Color for root"),
|
||||
("root_bold", "bool", "root жирний", "Bold root")],
|
||||
"host": COLOR_KEYS + [("full", "bool", "Повне ім'я (\\H)", "Full name (\\H)")],
|
||||
"cwd": COLOR_KEYS + [("mode", "choice:full=повний шлях|full=full path;base=лише остання тека|base=last folder only;"
|
||||
"short=останні N тек|short=last N folders", "Показ", "Shows"),
|
||||
("depth", "text", "N для «останні N тек»", "N for \"last N folders\"")],
|
||||
"date": COLOR_KEYS + [("format", "text", "Формат (strftime)", "Format (strftime)")],
|
||||
"time": COLOR_KEYS + [("seconds", "bool", "Із секундами", "With seconds")],
|
||||
"git": COLOR_KEYS,
|
||||
"venv": COLOR_KEYS,
|
||||
"jobs": COLOR_KEYS + [("prefix", "text", "Підпис перед числом", "Label before the number")],
|
||||
"ssh": COLOR_KEYS + [("text", "text", "Текст", "Text")],
|
||||
"text": COLOR_KEYS + [("text", "text", "Текст", "Text"), ("bracket", "bool", "У дужках", "In brackets")],
|
||||
"cmd": COLOR_KEYS + [("cmd", "text", "Команда (виконується перед кожним запрошенням)",
|
||||
"Command (runs before every prompt)")],
|
||||
"newline": [],
|
||||
"symbol": COLOR_KEYS + [("char", "text", "Символ (порожньо — $ або # для root)", "Symbol (empty: $, or # for root)")],
|
||||
}
|
||||
|
||||
STATES = [
|
||||
(("Звичайне", "Normal"), {}),
|
||||
(("Після помилки, git, venv, задачі, SSH", "After an error, git, venv, jobs, SSH"),
|
||||
{"rc": 1, "git": "main*", "venv": ".venv", "jobs": 1, "ssh": True, "cmd": "42"}),
|
||||
(("root", "root"), {"root": True}),
|
||||
]
|
||||
|
||||
|
||||
def _choices(kind):
|
||||
out = []
|
||||
for item in kind[7:].split(";"):
|
||||
uk, en = item.split("|")
|
||||
value, uk_label = uk.split("=")
|
||||
out.append((t(uk_label, en.split("=")[1]), value))
|
||||
return out
|
||||
|
||||
|
||||
class PromptApp(App):
|
||||
TITLE = "dprompt"
|
||||
SUB_TITLE = t("конструктор запрошення bash", "bash prompt builder")
|
||||
CSS = """
|
||||
#main { height: 1fr; }
|
||||
#left { width: 40; border-right: solid $primary; padding: 0 1; }
|
||||
#segments { height: 1fr; }
|
||||
#props { padding: 0 2; }
|
||||
#props Label { margin-top: 1; color: $text-muted; }
|
||||
#preview { height: auto; max-height: 14; border-top: solid $primary; padding: 0 1; }
|
||||
.buttons { height: auto; }
|
||||
.buttons Button { min-width: 5; margin-right: 1; }
|
||||
.h { text-style: bold; color: $accent; margin-top: 1; }
|
||||
"""
|
||||
BINDINGS = [
|
||||
("ctrl+s", "apply_me", t("Застосувати для себе", "Apply for me")),
|
||||
("ctrl+g", "apply_all", t("Для всіх (sudo)", "For everyone (sudo)")),
|
||||
("ctrl+e", "export", t("Зберегти файл", "Save file")),
|
||||
("ctrl+q", "quit", t("Вийти", "Quit")),
|
||||
]
|
||||
|
||||
def __init__(self, design=None):
|
||||
# The terminal's own 16 colors, as the prompt will use them
|
||||
super().__init__(ansi_color=True)
|
||||
self.design, self.source = (design, None) if design else current()
|
||||
self.design = prompt.normalize(self.design)
|
||||
self.props_lock = asyncio.Lock()
|
||||
|
||||
# --- layout ------------------------------------------------------------
|
||||
def compose(self):
|
||||
yield Header()
|
||||
with Horizontal(id="main"):
|
||||
with Vertical(id="left"):
|
||||
yield Label(Text(t("Готові запрошення", "Ready-made prompts")), classes="h")
|
||||
yield Select([(title, name) for name, title in prompt.list_presets()],
|
||||
prompt=t("вибрати…", "choose…"), id="preset")
|
||||
yield Label(Text(t("Сегменти (по порядку)", "Segments (in order)")), classes="h")
|
||||
yield ListView(id="segments")
|
||||
yield Select([(prompt.segment_name(k), k) for k in prompt.SEGMENTS],
|
||||
prompt=t("+ додати сегмент", "+ add a segment"), id="add")
|
||||
with Horizontal(classes="buttons"):
|
||||
yield Button("↑", id="up")
|
||||
yield Button("↓", id="down")
|
||||
yield Button(t("Видалити", "Remove"), id="remove", variant="error")
|
||||
yield VerticalScroll(id="props")
|
||||
yield Static(id="preview")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self):
|
||||
self.refresh_segments(0)
|
||||
|
||||
# --- segments list -------------------------------------------------------
|
||||
def refresh_segments(self, index=None):
|
||||
lv = self.query_one("#segments", ListView)
|
||||
lv.clear()
|
||||
lv.append(ListItem(Label(Text("⚙ " + t("Рамка й кольори", "Frame and colors"), style="bold"))))
|
||||
for seg in self.design["segments"]:
|
||||
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
|
||||
lv.append(ListItem(Label(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))))
|
||||
if index is not None:
|
||||
lv.index = index
|
||||
self.call_after_refresh(self.show_props)
|
||||
self.update_preview()
|
||||
|
||||
def selected(self):
|
||||
"""Index of the chosen segment, or None for the frame row."""
|
||||
i = self.query_one("#segments", ListView).index
|
||||
return None if not i else i - 1
|
||||
|
||||
async def on_list_view_highlighted(self, event):
|
||||
await self.show_props()
|
||||
|
||||
# --- properties ----------------------------------------------------------
|
||||
async def show_props(self):
|
||||
async with self.props_lock:
|
||||
await self._show_props()
|
||||
|
||||
async def _show_props(self):
|
||||
box = self.query_one("#props", VerticalScroll)
|
||||
await box.remove_children()
|
||||
i = self.selected()
|
||||
widgets = []
|
||||
if i is None:
|
||||
d = self.design
|
||||
widgets += [Label(Text(t("Вигляд рамки", "Frame"))),
|
||||
Select([(t("Das: ┌─[…]─[…] / └─╼", "Das: ┌─[…]─[…] / └─╼"), "das"),
|
||||
(t("Простий рядок", "Plain line"), "plain")],
|
||||
value=d["frame"], allow_blank=False, id="p_frame"),
|
||||
Label(Text(t("Колір рамки й дужок", "Frame and bracket color"))),
|
||||
Select(color_options(), value=to_select(d["frame_fg"]), allow_blank=False, id="p_frame_fg"),
|
||||
Label(Text(t("Ліва дужка", "Left bracket"))), Input(d["open"], id="p_open"),
|
||||
Label(Text(t("Права дужка", "Right bracket"))), Input(d["close"], id="p_close"),
|
||||
Label(Text(t("Між сегментами", "Between segments"))), Input(d["sep"], id="p_sep")]
|
||||
else:
|
||||
seg = self.design["segments"][i]
|
||||
widgets.append(Label(Text(prompt.segment_name(seg["type"]), style="bold")))
|
||||
if not OPTIONS[seg["type"]]:
|
||||
widgets.append(Label(Text(t("Без налаштувань.", "Nothing to set."))))
|
||||
for key, kind, uk, en in OPTIONS[seg["type"]]:
|
||||
wid = "s_" + key
|
||||
if kind == "bool":
|
||||
widgets.append(Checkbox(t(uk, en), bool(seg.get(key)), id=wid))
|
||||
continue
|
||||
widgets.append(Label(Text(t(uk, en))))
|
||||
if kind == "color":
|
||||
widgets.append(Select(color_options(), value=to_select(seg.get(key)), allow_blank=False, id=wid))
|
||||
elif kind.startswith("choice:"):
|
||||
widgets.append(Select(_choices(kind), value=seg.get(key), allow_blank=False, id=wid))
|
||||
else:
|
||||
widgets.append(Input(str(seg.get(key) if seg.get(key) is not None else ""), id=wid))
|
||||
for w in widgets:
|
||||
# Events of these widgets belong to this segment, whatever is selected later
|
||||
w.das_target = i
|
||||
await box.mount(*widgets)
|
||||
|
||||
def _set(self, control, value):
|
||||
if not hasattr(control, "das_target") or not control.is_attached:
|
||||
return
|
||||
i, wid = control.das_target, control.id
|
||||
key = wid[2:]
|
||||
if wid.startswith("p_"):
|
||||
if key == "frame" and value != self.design["frame"]:
|
||||
# Sensible brackets for the new frame
|
||||
self.design.update({"das": {"open": "[", "close": "]", "sep": "─"},
|
||||
"plain": {"open": "", "close": "", "sep": " "}}[value])
|
||||
self.design["frame"] = value
|
||||
self.call_later(self.show_props)
|
||||
else:
|
||||
self.design[key] = value
|
||||
elif i is not None and i < len(self.design["segments"]):
|
||||
seg = self.design["segments"][i]
|
||||
if key == "depth":
|
||||
value = int(value) if str(value).isdigit() else 3
|
||||
seg[key] = value
|
||||
if key in ("text", "cmd", "char"):
|
||||
self.refresh_label(i)
|
||||
self.update_preview()
|
||||
|
||||
def refresh_label(self, i):
|
||||
seg = self.design["segments"][i]
|
||||
item = self.query_one("#segments", ListView).children[i + 1]
|
||||
extra = seg.get("text") or seg.get("cmd") or seg.get("char") or ""
|
||||
item.query_one(Label).update(Text(prompt.segment_name(seg["type"]) + (f" «{extra}»" if extra else "")))
|
||||
|
||||
def on_select_changed(self, event: Select.Changed):
|
||||
wid = event.select.id or ""
|
||||
if wid == "preset":
|
||||
if event.value is not Select.NULL:
|
||||
self.design = prompt.load_preset(event.value)
|
||||
self.refresh_segments(0)
|
||||
elif wid == "add":
|
||||
if event.value is not Select.NULL:
|
||||
i = self.selected()
|
||||
pos = len(self.design["segments"]) if i is None else i + 1
|
||||
self.design["segments"].insert(pos, prompt.new_segment(event.value))
|
||||
event.select.clear()
|
||||
self.refresh_segments(pos + 1)
|
||||
elif wid.startswith(("p_", "s_")):
|
||||
value = event.value
|
||||
if wid.endswith("fg"):
|
||||
value = color_value(value)
|
||||
self._set(event.select, value)
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed):
|
||||
self._set(event.checkbox, event.value)
|
||||
|
||||
def on_input_changed(self, event: Input.Changed):
|
||||
if (event.input.id or "").startswith(("p_", "s_")):
|
||||
self._set(event.input, event.value)
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
i = self.selected()
|
||||
segs = self.design["segments"]
|
||||
if i is None:
|
||||
return
|
||||
if event.button.id == "remove":
|
||||
del segs[i]
|
||||
self.refresh_segments(min(i + 1, len(segs)))
|
||||
elif event.button.id == "up" and i > 0:
|
||||
segs[i - 1], segs[i] = segs[i], segs[i - 1]
|
||||
self.refresh_segments(i)
|
||||
elif event.button.id == "down" and i + 1 < len(segs):
|
||||
segs[i + 1], segs[i] = segs[i], segs[i + 1]
|
||||
self.refresh_segments(i + 2)
|
||||
|
||||
# --- preview ---------------------------------------------------------------
|
||||
def update_preview(self):
|
||||
out = Text()
|
||||
for n, ((uk, en), over) in enumerate(STATES):
|
||||
if n:
|
||||
out.append("\n")
|
||||
out.append(t(uk, en) + ":\n", style="dim italic")
|
||||
for text, style in prompt.preview(self.design, prompt.sample_state(**over)):
|
||||
out.append(text, style=style or None)
|
||||
out.append("ls", style="default")
|
||||
self.query_one("#preview", Static).update(out)
|
||||
|
||||
# --- actions -----------------------------------------------------------------
|
||||
def action_apply_me(self):
|
||||
path = apply_design(self.design, system=False)
|
||||
self.notify(t(f"Записано в {path}. Відкрийте новий термінал або exec bash.",
|
||||
f"Written to {path}. Open a new terminal or run exec bash."), timeout=6)
|
||||
|
||||
def action_apply_all(self):
|
||||
fd, path = tempfile.mkstemp(prefix="dprompt-", suffix=".json")
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.dumps(self.design))
|
||||
os.chmod(path, 0o644)
|
||||
ok = run_as_root(self, ["dprompt", "apply", path, "--system"])
|
||||
os.remove(path)
|
||||
if ok:
|
||||
self.notify(t("Запрошення для всіх записано в /etc/das/prompt.sh.",
|
||||
"The prompt for everyone is written to /etc/das/prompt.sh."), timeout=6)
|
||||
|
||||
def action_export(self):
|
||||
def done(result):
|
||||
if not result:
|
||||
return
|
||||
path = os.path.expanduser(result["path"])
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.dumps(dict(copy.deepcopy(self.design), name=os.path.basename(path)[:-5])))
|
||||
except OSError as e:
|
||||
self.notify(str(e), severity="error")
|
||||
return
|
||||
self.notify(t(f"Збережено: {path}", f"Saved: {path}"))
|
||||
self.push_screen(AskDialog(t("Зберегти запрошення у файл", "Save the prompt to a file"),
|
||||
[("path", t("Файл (.json)", "File (.json)"), "~/my-prompt.json")]), done)
|
||||
|
||||
|
||||
__all__ = ["PromptApp", "NONE"]
|
||||
@@ -0,0 +1,157 @@
|
||||
"""Textual pieces shared by the dprompt and dbanner editors."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from rich.style import Style
|
||||
from rich.text import Text
|
||||
from textual import events
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.screen import ModalScreen
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button, Input, Label, Select
|
||||
|
||||
from . import colors
|
||||
from .i18n import t
|
||||
|
||||
NONE = "-" # Select has no None value
|
||||
|
||||
|
||||
def color_options(allow_none=True):
|
||||
names = {
|
||||
"black": ("чорний", "black"), "red": ("червоний", "red"), "green": ("зелений", "green"),
|
||||
"yellow": ("жовтий", "yellow"), "blue": ("синій", "blue"), "magenta": ("пурпуровий", "magenta"),
|
||||
"cyan": ("бірюзовий", "cyan"), "white": ("білий", "white"),
|
||||
}
|
||||
opts = [(t("— типовий —", "— default —"), NONE)] if allow_none else []
|
||||
for c in colors.NAMES:
|
||||
base = c[7:] if c.startswith("bright_") else c
|
||||
label = t(*names[base])
|
||||
if c.startswith("bright_"):
|
||||
label = t("яскравий ", "bright ") + label
|
||||
opts.append((Text.assemble(("██ ", c), label), c))
|
||||
return opts
|
||||
|
||||
|
||||
def color_value(value):
|
||||
return None if value in (NONE, Select.NULL, None) else value
|
||||
|
||||
|
||||
def to_select(color):
|
||||
return color if color in colors.NAMES else NONE
|
||||
|
||||
|
||||
class Swatches(Widget):
|
||||
"""A row of the 16 colors (and "none") to click; the chosen one is marked."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Swatches { height: 2; width: 36; }
|
||||
"""
|
||||
|
||||
class Picked(Message):
|
||||
def __init__(self, swatches, color):
|
||||
super().__init__()
|
||||
self.swatches, self.color = swatches, color
|
||||
|
||||
def __init__(self, color=None, allow_none=True, **kw):
|
||||
super().__init__(**kw)
|
||||
self.color, self.allow_none = color, allow_none
|
||||
|
||||
def _items(self):
|
||||
return ([None] if self.allow_none else []) + list(colors.NAMES)
|
||||
|
||||
def render(self):
|
||||
text = Text()
|
||||
for i, c in enumerate(self._items()):
|
||||
if i == 9:
|
||||
text.append("\n")
|
||||
mark = "▣" if c == self.color else "■"
|
||||
if c is None:
|
||||
text.append("∅ " if c != self.color else "▣ ", Style(bold=True))
|
||||
else:
|
||||
text.append(mark * 2 + " " if c == self.color else "██ ", Style(color=c))
|
||||
return text
|
||||
|
||||
def on_click(self, event: events.Click):
|
||||
items = self._items()
|
||||
index = (event.y * 9) + event.x // 3
|
||||
if 0 <= event.x // 3 < 9 and 0 <= index < len(items):
|
||||
self.color = items[index]
|
||||
self.refresh()
|
||||
self.post_message(self.Picked(self, self.color))
|
||||
|
||||
|
||||
class AskDialog(ModalScreen):
|
||||
"""A few text fields with OK/Cancel; dismisses with {key: value} or None."""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AskDialog { align: center middle; }
|
||||
AskDialog > Vertical { width: 72; height: auto; border: round $accent; background: $surface; padding: 1 2; }
|
||||
AskDialog Label { margin-top: 1; }
|
||||
AskDialog Horizontal { height: auto; margin-top: 1; align-horizontal: right; }
|
||||
AskDialog Button { margin-left: 2; }
|
||||
"""
|
||||
BINDINGS = [("escape", "cancel", t("Скасувати", "Cancel"))]
|
||||
|
||||
def __init__(self, heading, fields):
|
||||
"""fields: [(key, label, value or a list of (label, value) choices, current)]"""
|
||||
super().__init__()
|
||||
self.heading, self.fields = heading, fields
|
||||
|
||||
def compose(self):
|
||||
with Vertical():
|
||||
yield Label(Text(self.heading, style="bold"))
|
||||
for key, label, value, *rest in self.fields:
|
||||
yield Label(Text(label))
|
||||
if isinstance(value, list):
|
||||
yield Select(value, value=rest[0] if rest else value[0][1], allow_blank=False, id="f_" + key)
|
||||
else:
|
||||
yield Input(str(value), id="f_" + key)
|
||||
with Horizontal():
|
||||
yield Button(t("Скасувати", "Cancel"), id="cancel")
|
||||
yield Button("OK", variant="primary", id="ok")
|
||||
|
||||
def on_mount(self):
|
||||
inputs = self.query(Input)
|
||||
if inputs:
|
||||
inputs.first().focus()
|
||||
|
||||
def _result(self):
|
||||
return {key: self.query_one("#f_" + key).value for key, *_ in self.fields}
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed):
|
||||
self.dismiss(self._result() if event.button.id == "ok" else None)
|
||||
|
||||
def on_input_submitted(self, event: Input.Submitted):
|
||||
self.dismiss(self._result())
|
||||
|
||||
def action_cancel(self):
|
||||
self.dismiss(None)
|
||||
|
||||
|
||||
def run_as_root(app, args):
|
||||
"""Runs one of our commands through sudo with the TUI suspended, so sudo can
|
||||
ask for the password. Returns True on success."""
|
||||
exe = "/usr/bin/" + args[0]
|
||||
if os.path.exists(exe):
|
||||
cmd = [exe] + args[1:]
|
||||
else: # from the source tree
|
||||
module = {"dprompt": "das_shell.cli_prompt", "dbanner": "das_shell.cli_banner"}[args[0]]
|
||||
env = [f"{k}={os.environ[k]}" for k in ("PYTHONPATH", "DAS_SHELL_DATA", "DAS_SHELL_ETC", "DAS_SHELL_CACHE")
|
||||
if k in os.environ]
|
||||
cmd = ["env"] + env + [sys.executable, "-m", module] + args[1:]
|
||||
if os.geteuid() != 0:
|
||||
cmd = ["sudo"] + cmd
|
||||
with app.suspend():
|
||||
print("\n$ " + " ".join(cmd))
|
||||
code = subprocess.run(cmd).returncode
|
||||
if code:
|
||||
input(t("Помилка. Enter — повернутися…", "Failed. Press Enter to go back…"))
|
||||
return code == 0
|
||||
|
||||
|
||||
def bar(*buttons):
|
||||
row = Horizontal(*buttons, classes="buttons")
|
||||
return row
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Values for banner fields ({hostname}, {ip}, ...) and widgets: blocks of lines
|
||||
from built-in functions, scripts, commands or files.
|
||||
|
||||
"live" widgets run at login with a short timeout; "cached" ones are run by the
|
||||
das-banner service, and the login only reads their last output from CACHE_DIR.
|
||||
"""
|
||||
|
||||
import concurrent.futures
|
||||
import datetime
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from . import CACHE_DIR, DATA_DIR, colors
|
||||
from .i18n import t
|
||||
|
||||
LIVE_TIMEOUT = 1.5
|
||||
CACHED_TIMEOUT = 60
|
||||
|
||||
|
||||
# --- field values -----------------------------------------------------------------
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
return f.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _ips():
|
||||
try:
|
||||
out = subprocess.run(["ip", "-4", "-o", "addr", "show", "scope", "global"],
|
||||
capture_output=True, text=True, timeout=1).stdout
|
||||
ips = [line.split()[3].split("/")[0] for line in out.splitlines() if len(line.split()) > 3]
|
||||
if ips:
|
||||
return ips
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
|
||||
s.connect(("192.0.2.1", 9)) # no packet is sent
|
||||
return [s.getsockname()[0]]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _os_name():
|
||||
for line in _read("/etc/os-release").splitlines():
|
||||
if line.startswith("PRETTY_NAME="):
|
||||
return line.split("=", 1)[1].strip().strip('"')
|
||||
return "Linux"
|
||||
|
||||
|
||||
def _duration(seconds):
|
||||
days, rest = divmod(int(seconds), 86400)
|
||||
hours, rest = divmod(rest, 3600)
|
||||
minutes = rest // 60
|
||||
parts = []
|
||||
if days:
|
||||
parts.append(t(f"{days} дн", f"{days} d"))
|
||||
if hours or days:
|
||||
parts.append(t(f"{hours} год", f"{hours} h"))
|
||||
if not days:
|
||||
parts.append(t(f"{minutes} хв", f"{minutes} min"))
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _uptime():
|
||||
try:
|
||||
return _duration(float(_read("/proc/uptime").split()[0]))
|
||||
except (IndexError, ValueError):
|
||||
return "?"
|
||||
|
||||
|
||||
def _meminfo():
|
||||
info = {}
|
||||
for line in _read("/proc/meminfo").splitlines():
|
||||
key, _, value = line.partition(":")
|
||||
try:
|
||||
info[key] = int(value.split()[0]) * 1024
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return info
|
||||
|
||||
|
||||
def human(n):
|
||||
for unit in ("B", "K", "M", "G", "T", "P"):
|
||||
if abs(n) < 1024 or unit == "P":
|
||||
return f"{n:.0f}{unit}" if unit in ("B", "K") or n >= 100 else f"{n:.1f}{unit}"
|
||||
n /= 1024
|
||||
return str(n)
|
||||
|
||||
|
||||
def _mem():
|
||||
m = _meminfo()
|
||||
total = m.get("MemTotal", 0)
|
||||
used = total - m.get("MemAvailable", 0)
|
||||
return f"{human(used)}/{human(total)}"
|
||||
|
||||
|
||||
def _cpu():
|
||||
for line in _read("/proc/cpuinfo").splitlines():
|
||||
if line.startswith("model name"):
|
||||
return " ".join(line.split(":", 1)[1].split())
|
||||
return os.uname().machine
|
||||
|
||||
|
||||
def _users():
|
||||
try:
|
||||
out = subprocess.run(["who"], capture_output=True, text=True, timeout=1).stdout
|
||||
return str(len({line.split()[0] for line in out.splitlines() if line.strip()}))
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return "?"
|
||||
|
||||
|
||||
PROVIDERS = {
|
||||
"hostname": lambda: socket.gethostname().split(".")[0],
|
||||
"fqdn": socket.getfqdn,
|
||||
"ip": lambda: (_ips() or ["?"])[0],
|
||||
"ips": lambda: " ".join(_ips()),
|
||||
"os": _os_name,
|
||||
"kernel": lambda: os.uname().release,
|
||||
"arch": lambda: os.uname().machine,
|
||||
"uptime": _uptime,
|
||||
"load": lambda: " ".join(_read("/proc/loadavg").split()[:3]),
|
||||
"mem": _mem,
|
||||
"cpu": _cpu,
|
||||
"cores": lambda: str(os.cpu_count() or 1),
|
||||
"date": lambda: datetime.datetime.now().strftime("%Y-%m-%d %H:%M"),
|
||||
"users": _users,
|
||||
"user": lambda: os.environ.get("USER") or os.environ.get("LOGNAME") or "?",
|
||||
}
|
||||
|
||||
VAR_HELP = {
|
||||
"hostname": ("ім'я комп'ютера", "host name"),
|
||||
"fqdn": ("повне ім'я в мережі", "fully qualified name"),
|
||||
"ip": ("перша IPv4-адреса", "first IPv4 address"),
|
||||
"ips": ("усі IPv4-адреси", "all IPv4 addresses"),
|
||||
"os": ("назва системи", "system name"),
|
||||
"kernel": ("версія ядра", "kernel version"),
|
||||
"arch": ("архітектура", "architecture"),
|
||||
"uptime": ("час роботи", "uptime"),
|
||||
"load": ("навантаження", "load average"),
|
||||
"mem": ("пам'ять зайнято/всього", "memory used/total"),
|
||||
"cpu": ("процесор", "processor"),
|
||||
"cores": ("кількість ядер", "number of cores"),
|
||||
"date": ("дата й час", "date and time"),
|
||||
"users": ("користувачів у системі", "users logged in"),
|
||||
"user": ("хто входить", "who logs in"),
|
||||
}
|
||||
|
||||
|
||||
class Values(dict):
|
||||
"""Field values, computed only when a field asks for them."""
|
||||
|
||||
def __init__(self, user_vars=None):
|
||||
super().__init__()
|
||||
self.user_vars = dict(user_vars or {})
|
||||
|
||||
def get(self, key, default=None):
|
||||
if key in self.user_vars:
|
||||
return self.user_vars[key]
|
||||
if key not in self and key in PROVIDERS:
|
||||
try:
|
||||
self[key] = PROVIDERS[key]()
|
||||
except Exception: # a broken value must not break the login
|
||||
self[key] = "?"
|
||||
return super().get(key, default)
|
||||
|
||||
|
||||
# --- built-in widgets -------------------------------------------------------------
|
||||
|
||||
def _bar(percent, width=10):
|
||||
full = round(percent / 100 * width)
|
||||
color = "green" if percent < 70 else ("yellow" if percent < 90 else "red")
|
||||
return (colors.sgr(color, None, True) + "█" * full + colors.sgr("bright_black") + "░" * (width - full)
|
||||
+ colors.RESET)
|
||||
|
||||
|
||||
PSEUDO_FS = {"proc", "sysfs", "devtmpfs", "devpts", "tmpfs", "cgroup", "cgroup2", "securityfs",
|
||||
"pstore", "efivarfs", "bpf", "debugfs", "tracefs", "configfs", "fusectl", "mqueue",
|
||||
"hugetlbfs", "autofs", "binfmt_misc", "overlay", "squashfs", "nsfs", "ramfs", "rpc_pipefs",
|
||||
"fuse.portal", "fuse.gvfsd-fuse", "iso9660", "zram"}
|
||||
|
||||
|
||||
def _mounts():
|
||||
seen, out = set(), []
|
||||
for line in _read("/proc/mounts").splitlines():
|
||||
parts = line.split()
|
||||
if len(parts) < 3 or parts[2] in PSEUDO_FS or not parts[0].startswith("/"):
|
||||
continue
|
||||
mount = parts[1].replace("\\040", " ")
|
||||
if parts[0] in seen or mount.startswith(("/snap", "/var/lib/docker", "/.snapshots", "/run")):
|
||||
continue
|
||||
seen.add(parts[0])
|
||||
out.append(mount)
|
||||
return out
|
||||
|
||||
|
||||
def w_disks(args, values):
|
||||
lines = [colors.sgr("white", None, True) + t("Диски:", "Disks:") + colors.RESET]
|
||||
rows = []
|
||||
for mount in args.get("mounts") or _mounts():
|
||||
try:
|
||||
st = os.statvfs(mount)
|
||||
except OSError:
|
||||
continue
|
||||
total = st.f_blocks * st.f_frsize
|
||||
if not total:
|
||||
continue
|
||||
used = total - st.f_bfree * st.f_frsize
|
||||
rows.append((mount, total, used))
|
||||
width = max([len(r[0]) for r in rows] + [1])
|
||||
for mount, total, used in rows:
|
||||
pct = round(used * 100 / total)
|
||||
lines.append(f" {mount.ljust(width)} {human(total):>6} {_bar(pct)} {pct:3d}%")
|
||||
return lines
|
||||
|
||||
|
||||
def w_memory(args, values):
|
||||
m = _meminfo()
|
||||
lines = []
|
||||
for label, total, free in ((t("Пам'ять", "Memory"), m.get("MemTotal", 0), m.get("MemAvailable", 0)),
|
||||
("Swap", m.get("SwapTotal", 0), m.get("SwapFree", 0))):
|
||||
if not total:
|
||||
continue
|
||||
pct = round((total - free) * 100 / total)
|
||||
lines.append(f"{colors.sgr('white', None, True)}{label + ':':<9}{colors.RESET} "
|
||||
f"{human(total - free):>6}/{human(total):<6} {_bar(pct)} {pct:3d}%")
|
||||
return lines
|
||||
|
||||
|
||||
def w_uptime(args, values):
|
||||
return [f"{colors.sgr('white', None, True)}{t('Працює:', 'Uptime:')}{colors.RESET} "
|
||||
f"{colors.sgr('red', None, True)}{values.get('uptime')}{colors.RESET}"]
|
||||
|
||||
|
||||
def w_load(args, values):
|
||||
return [f"{colors.sgr('white', None, True)}{t('Навантаження:', 'Load:')}{colors.RESET} "
|
||||
f"{values.get('load')} ({t('ядер', 'cores')}: {values.get('cores')})"]
|
||||
|
||||
|
||||
def w_network(args, values):
|
||||
lines = [colors.sgr("white", None, True) + t("Мережа:", "Network:") + colors.RESET]
|
||||
try:
|
||||
out = subprocess.run(["ip", "-o", "addr", "show", "scope", "global"],
|
||||
capture_output=True, text=True, timeout=1).stdout
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return []
|
||||
for line in out.splitlines():
|
||||
p = line.split()
|
||||
if len(p) > 3:
|
||||
lines.append(f" {p[1]:<10} {colors.sgr('cyan', None, True)}{p[3]}{colors.RESET}")
|
||||
return lines if len(lines) > 1 else []
|
||||
|
||||
|
||||
BUILTINS = {
|
||||
"uptime": (w_uptime, ("Час роботи", "Uptime")),
|
||||
"load": (w_load, ("Навантаження", "Load average")),
|
||||
"memory": (w_memory, ("Пам'ять і swap", "Memory and swap")),
|
||||
"disks": (w_disks, ("Диски", "Disks")),
|
||||
"network": (w_network, ("Мережа", "Network")),
|
||||
}
|
||||
|
||||
|
||||
def widget_dir():
|
||||
return os.path.join(DATA_DIR, "banner", "widgets")
|
||||
|
||||
|
||||
def script_meta(path):
|
||||
"""# dbanner-<key>: value lines of a widget script."""
|
||||
meta = {}
|
||||
for line in _read(path).splitlines()[:20]:
|
||||
if line.startswith("# dbanner-"):
|
||||
key, _, value = line[10:].partition(":")
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta
|
||||
|
||||
|
||||
def available():
|
||||
"""(name, title, mode) of every built-in widget and widget script."""
|
||||
out = [(name, t(*names), "live") for name, (_, names) in BUILTINS.items()]
|
||||
try:
|
||||
scripts = sorted(f for f in os.listdir(widget_dir()) if f.endswith(".sh") and f != "lib.sh")
|
||||
except OSError:
|
||||
scripts = []
|
||||
for f in scripts:
|
||||
meta = script_meta(os.path.join(widget_dir(), f))
|
||||
out.append((f[:-3], t(meta.get("title-uk", f[:-3]), meta.get("title-en", f[:-3])),
|
||||
meta.get("mode", "live")))
|
||||
return out
|
||||
|
||||
|
||||
def describe(w):
|
||||
"""A short line about a widget for lists."""
|
||||
kind = w.get("type", "builtin")
|
||||
if kind == "builtin":
|
||||
names = {n: title_ for n, title_, _ in available()}
|
||||
return names.get(w.get("name"), w.get("name", "?"))
|
||||
if kind == "command":
|
||||
return "$ " + w.get("cmd", "")
|
||||
return t("файл ", "file ") + w.get("path", "")
|
||||
|
||||
|
||||
def mode_of(w):
|
||||
if w.get("mode"):
|
||||
return w["mode"]
|
||||
if w.get("type", "builtin") == "builtin" and w.get("name") not in BUILTINS:
|
||||
return script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh")).get("mode", "live")
|
||||
return "live"
|
||||
|
||||
|
||||
def interval_of(w):
|
||||
if w.get("interval"):
|
||||
return int(w["interval"])
|
||||
if w.get("type", "builtin") == "builtin":
|
||||
meta = script_meta(os.path.join(widget_dir(), w.get("name", "") + ".sh"))
|
||||
if meta.get("interval", "").isdigit():
|
||||
return int(meta["interval"])
|
||||
return 600
|
||||
|
||||
|
||||
def cache_path(w):
|
||||
key = hashlib.sha1(json.dumps(w, sort_keys=True).encode()).hexdigest()[:16]
|
||||
return os.path.join(CACHE_DIR, key + ".txt")
|
||||
|
||||
|
||||
def _env(design_vars):
|
||||
env = dict(os.environ)
|
||||
for key, value in (design_vars or {}).items():
|
||||
env["DBANNER_" + key.upper()] = str(value)
|
||||
env["DBANNER_LANG"] = "uk" if t("uk", "en") == "uk" else "en"
|
||||
return env
|
||||
|
||||
|
||||
def run(w, values, design_vars=None, timeout=None):
|
||||
"""Runs a widget now and returns its lines (empty when it fails)."""
|
||||
kind = w.get("type", "builtin")
|
||||
try:
|
||||
if kind == "file":
|
||||
text = _read(os.path.expanduser(w.get("path", "")))
|
||||
lines = text.rstrip("\n").split("\n") if text.strip() else []
|
||||
elif kind == "builtin" and w.get("name") in BUILTINS:
|
||||
lines = BUILTINS[w["name"]][0](w.get("args") or {}, values)
|
||||
else:
|
||||
if kind == "builtin":
|
||||
cmd = [os.path.join(widget_dir(), w.get("name", "") + ".sh")]
|
||||
else:
|
||||
cmd = ["sh", "-c", w.get("cmd", "")]
|
||||
limit = timeout or float(w.get("timeout") or (CACHED_TIMEOUT if mode_of(w) == "cached" else LIVE_TIMEOUT))
|
||||
proc = subprocess.run(cmd, capture_output=True, text=True, errors="replace",
|
||||
timeout=limit, env=_env(design_vars), stdin=subprocess.DEVNULL)
|
||||
out = proc.stdout.rstrip("\n")
|
||||
lines = out.split("\n") if out.strip() else []
|
||||
except Exception: # a broken widget must not break the login
|
||||
return []
|
||||
if lines and w.get("title"):
|
||||
lines = [colors.sgr("white", None, True) + w["title"] + colors.RESET] + lines
|
||||
return lines
|
||||
|
||||
|
||||
def read_cache(w):
|
||||
text = _read(cache_path(w))
|
||||
return text.rstrip("\n").split("\n") if text.strip() else []
|
||||
|
||||
|
||||
def blocks(design, values, use_cache=True):
|
||||
"""Lines of every widget of a design, in order; live ones run in parallel."""
|
||||
widgets = design.get("widgets", [])
|
||||
result = [[] for _ in widgets]
|
||||
jobs = {}
|
||||
# No "with": leaving it would wait for a widget that hangs
|
||||
pool = concurrent.futures.ThreadPoolExecutor(max_workers=8)
|
||||
for i, w in enumerate(widgets):
|
||||
if w.get("enabled") is False:
|
||||
continue
|
||||
if use_cache and mode_of(w) == "cached":
|
||||
result[i] = read_cache(w)
|
||||
else:
|
||||
jobs[pool.submit(run, w, values, design.get("vars"))] = i
|
||||
try:
|
||||
for fut in concurrent.futures.as_completed(jobs, timeout=LIVE_TIMEOUT + 1 if use_cache else None):
|
||||
result[jobs[fut]] = fut.result()
|
||||
except concurrent.futures.TimeoutError:
|
||||
pass
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
return result
|
||||
|
||||
|
||||
def refresh(design, force=False):
|
||||
"""Runs the cached widgets whose output is older than their interval."""
|
||||
values = None
|
||||
os.makedirs(CACHE_DIR, mode=0o755, exist_ok=True)
|
||||
for w in design.get("widgets", []):
|
||||
if w.get("enabled") is False or mode_of(w) != "cached":
|
||||
continue
|
||||
path = cache_path(w)
|
||||
try:
|
||||
age = time.time() - os.stat(path).st_mtime
|
||||
except OSError:
|
||||
age = None
|
||||
if not force and age is not None and age < interval_of(w):
|
||||
continue
|
||||
if values is None:
|
||||
values = Values(design.get("vars"))
|
||||
lines = run(w, values, design.get("vars"))
|
||||
tmp = path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + ("\n" if lines else ""))
|
||||
os.chmod(tmp, 0o644)
|
||||
os.replace(tmp, path)
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "das-logo-small",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS, малий",
|
||||
"en": "Das OS logo, small"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄▄ ██ ▄▄",
|
||||
" ▀█▀█▀▀█▀█▀",
|
||||
" ▄▄▀▀▀▀▀▀▀▀▄▄",
|
||||
" ▀▀▀▀▀▀▀▀▀▀▀▀",
|
||||
" █▀█▀▀█▀█",
|
||||
" ▀▀ ██ ▀▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa bb aa",
|
||||
" aacaddacaa",
|
||||
" bbeffggffebb",
|
||||
" bbdggffggdbb",
|
||||
" ahaiiaha",
|
||||
" aa bb aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "das-logo",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS",
|
||||
"en": "Das OS logo"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" ▄▄ ▄▀▀▄ ▄▄",
|
||||
" ▀█▀▀██████▀▀█▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
|
||||
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄█▀▀██████▀▀█▄",
|
||||
" ▀▀ ▀▀▀▀ ▀▀",
|
||||
" ██",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa",
|
||||
" bb acca bb",
|
||||
" bbddbaeeabddbb",
|
||||
" bffbbaabbffb",
|
||||
" aaacccggghhgggcccaaa",
|
||||
" aaaiiihhhgghhhiiiaaa",
|
||||
" bddbbaabbddb",
|
||||
" bbffbaeeabffbb",
|
||||
" bb aiia bb",
|
||||
" aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "trident-shield",
|
||||
"title": {
|
||||
"uk": "Малий герб України (тризуб на щиті)",
|
||||
"en": "Lesser coat of arms of Ukraine (tryzub on a shield)"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"██████████████████████",
|
||||
"██████████████████████",
|
||||
"████▀████████████▀████",
|
||||
"████▀█▀████████▀█▀████",
|
||||
"██████████████████████",
|
||||
"███████▀██████▀███████",
|
||||
"██████████████████████",
|
||||
"█████▀█▀█▀██▀█▀█▀█████",
|
||||
"██████▀█▀████▀█▀██████",
|
||||
"██████▀███▀▀███▀██████",
|
||||
"████▀▀▀██▀██▀██▀▀▀████",
|
||||
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
|
||||
" ▀██████▀████▀██████▀",
|
||||
" ▀▀▀████▀▀████▀▀▀",
|
||||
" ▀▀██▀▀"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaa",
|
||||
"aaaaaaaaaabbaaaaaaaaaa",
|
||||
"aaabcaaaaabbaaaaacbaaa",
|
||||
"aaabdbcaaabbaaacbdbaaa",
|
||||
"aaabaabaaabbaaabaabaaa",
|
||||
"aaabaabcaabbaacbaabaaa",
|
||||
"aaabaabbaabbaabbaabaaa",
|
||||
"aaabacbdacbbcadbcabaaa",
|
||||
"aaabbbcacbaabcacbbbaaa",
|
||||
"aaabaadbbbccbbbdaabaaa",
|
||||
"aaabcccbbcbbcbbcccbaaa",
|
||||
"aaadddddbdbbdbdddddaaa",
|
||||
" aaaaaaadbbbbdaaaaaaa",
|
||||
" aaaaaaaddaaaaaaa",
|
||||
" aaaaaa"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "trident-small",
|
||||
"title": {
|
||||
"uk": "Тризуб, малий",
|
||||
"en": "Tryzub, small"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄ ██ ▄",
|
||||
" █▀▄ ██ ▄▀█",
|
||||
" █ █ ██ █ █",
|
||||
" █ ▄ ██ ▄ █",
|
||||
" █▄▄▀ ██ ▀▄▄█",
|
||||
" █▀█▄█ █▄█▀█",
|
||||
" █▄ ██████ ▄█",
|
||||
" ▀▀▀████▀▀▀",
|
||||
" ▀██▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" a aa a",
|
||||
" aaa aa aaa",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaaa aa aaaa",
|
||||
" aaaaa aaaaa",
|
||||
" aa aaaaaa aa",
|
||||
" aaaaaaaaaa",
|
||||
" aaaa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "trident",
|
||||
"title": {
|
||||
"uk": "Тризуб",
|
||||
"en": "Tryzub"
|
||||
},
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" █▄ ██ ▄█",
|
||||
" █▀█ ██ █▀█",
|
||||
" █ ██ ██ ██ █",
|
||||
" █ █ ██ █ █",
|
||||
" █ █ ██ █ █",
|
||||
" █▄█ █▀▀█ █▄█",
|
||||
" █ ▀▄██ ██▄▀ █",
|
||||
" █ █ ██ █ █",
|
||||
" ▀▀▀▀█▀██▀█▀▀▀▀",
|
||||
" ▀████▀",
|
||||
" ▀▀",
|
||||
""
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" aa",
|
||||
" aa aa aa",
|
||||
" aaa aa aaa",
|
||||
" a aa aa aa a",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaa aaaa aaa",
|
||||
" a aaaa aaaa a",
|
||||
" a a aa a a",
|
||||
" aaaaaaaaaaaaaa",
|
||||
" aaaaaa",
|
||||
" aa",
|
||||
""
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
{
|
||||
"name": "blocks",
|
||||
"title": {
|
||||
"uk": "Логотип Das OS і відомості про систему",
|
||||
"en": "Das OS logo and system facts"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 26,
|
||||
"y": 0,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "{user}@{hostname}",
|
||||
"style": "j"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 1,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "──────────────────────────────",
|
||||
"style": "i"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 2,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[ОС|OS]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 2,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{os}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 3,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Ядро|Kernel]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 3,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{kernel}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 4,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 4,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{uptime}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 5,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "CPU",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 5,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{cpu}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 6,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Пам'ять|Memory]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 6,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{mem}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 7,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "IP",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 7,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{ips}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 8,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Вхід|Users]]",
|
||||
"style": "k"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 8,
|
||||
"width": 41,
|
||||
"align": "left",
|
||||
"text": "{users}",
|
||||
"style": "l"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 10,
|
||||
"width": 50,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "g"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "bright_blue",
|
||||
"bg": "yellow",
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "yellow",
|
||||
"bg": "bright_blue",
|
||||
"bold": false
|
||||
},
|
||||
"g": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"h": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"i": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"j": {
|
||||
"fg": "bright_cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"k": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"l": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ██",
|
||||
" ▄▄ ▄▀▀▄ ▄▄",
|
||||
" ▀█▀▀██████▀▀█▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄▄▄▀▀▀▀▀▀▀▀▀▀▀▀▀▀▄▄▄",
|
||||
" ▀▀█▀▀▀▀▀▀▀▀▀▀▀▀▀▀█▀▀",
|
||||
" █▀▀██████▀▀█",
|
||||
" ▄█▀▀██████▀▀█▄",
|
||||
" ▀▀ ▀▀▀▀ ▀▀",
|
||||
" ██"
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" ii",
|
||||
" aa iddi aa",
|
||||
" aahhaiggiahhaa",
|
||||
" abbaaiiaabba",
|
||||
" iiidddeeecceeedddiii",
|
||||
" iiifffccceecccfffiii",
|
||||
" ahhaaiiaahha",
|
||||
" aabbaiggiabbaa",
|
||||
" aa iffi aa",
|
||||
" ii"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 76,
|
||||
"height": 12
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"name": "das-classic",
|
||||
"title": {
|
||||
"uk": "Das класичний: рамка з #, назва, IP, посилання",
|
||||
"en": "Das classic: # frame, name, IP, link"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"title": "Das OS",
|
||||
"url": "https://das.org.ua/"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 35,
|
||||
"align": "center",
|
||||
"text": "-=:dAs:=- {title}",
|
||||
"style": "c"
|
||||
},
|
||||
{
|
||||
"x": 18,
|
||||
"y": 2,
|
||||
"width": 19,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 13,
|
||||
"y": 3,
|
||||
"width": 24,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "d"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "docker"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "updates"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "red",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"c": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"#######################################",
|
||||
"# #",
|
||||
"# Server IP: #",
|
||||
"# Link: #",
|
||||
"#######################################"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"a bbbbbbbbbb a",
|
||||
"a bbbbb a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 39,
|
||||
"height": 5
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"title": {
|
||||
"uk": "Панель: усі віджети під заголовком",
|
||||
"en": "Dashboard: every widget under a header"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": "",
|
||||
"city": "Kyiv"
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 66,
|
||||
"align": "center",
|
||||
"text": "{hostname} · {ip} · {url}",
|
||||
"style": "b"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "load"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "network"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "docker"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "updates"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "weather"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "smart"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "fail2ban"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "snapshots"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "lastlog"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"╭────────────────────────────────────────────────────────────────────╮",
|
||||
"│ │",
|
||||
"╰────────────────────────────────────────────────────────────────────╯"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 70,
|
||||
"height": 3
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"name": "double",
|
||||
"title": {
|
||||
"uk": "Подвійна рамка з великим ім'ям сервера",
|
||||
"en": "Double frame with the server name in big letters"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"title": "Das OS Server",
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 2,
|
||||
"y": 1,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{hostname}",
|
||||
"style": "c",
|
||||
"font": "small"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 5,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{title}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 6,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "{url}",
|
||||
"style": "e"
|
||||
},
|
||||
{
|
||||
"x": 2,
|
||||
"y": 8,
|
||||
"width": 60,
|
||||
"align": "center",
|
||||
"text": "IP {ip} · {os} · {kernel}",
|
||||
"style": "f"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "uptime"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "load"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "memory"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_black",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_white",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"e": {
|
||||
"fg": "cyan",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"f": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"╔══════════════════════════════════════════════════════════════╗",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║ ║",
|
||||
"║──────────────────────────────────────────────────────────────║",
|
||||
"║ ║",
|
||||
"╚══════════════════════════════════════════════════════════════╝"
|
||||
],
|
||||
"style_map": [
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"a a",
|
||||
"abbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbba",
|
||||
"a a",
|
||||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 64,
|
||||
"height": 10
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "minimal",
|
||||
"title": {
|
||||
"uk": "Мінімальний: один рядок",
|
||||
"en": "Minimal: one line"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {},
|
||||
"fields": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 70,
|
||||
"align": "left",
|
||||
"text": "{hostname} · {ip} · [[працює|up]] {uptime} · {load}",
|
||||
"style": "a"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_cyan",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [],
|
||||
"style_map": [],
|
||||
"canvas": {
|
||||
"width": 70,
|
||||
"height": 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"name": "tryzub",
|
||||
"title": {
|
||||
"uk": "Малий тризуб з іменем і IP",
|
||||
"en": "Small tryzub with name and IP"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 17,
|
||||
"y": 2,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{hostname}",
|
||||
"style": "b"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 3,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "c"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 5,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]: {uptime}",
|
||||
"style": "d"
|
||||
},
|
||||
{
|
||||
"x": 17,
|
||||
"y": 6,
|
||||
"width": 43,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "e"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"d": {
|
||||
"fg": "white",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"",
|
||||
" ▄ ██ ▄",
|
||||
" █▀▄ ██ ▄▀█",
|
||||
" █ █ ██ █ █",
|
||||
" █ ▄ ██ ▄ █",
|
||||
" █▄▄▀ ██ ▀▄▄█",
|
||||
" █▀█▄█ █▄█▀█",
|
||||
" █▄ ██████ ▄█",
|
||||
" ▀▀▀████▀▀▀",
|
||||
" ▀██▀"
|
||||
],
|
||||
"style_map": [
|
||||
"",
|
||||
" a aa a",
|
||||
" aaa aa aaa",
|
||||
" a a aa a a",
|
||||
" a a aa a a",
|
||||
" aaaa aa aaaa",
|
||||
" aaaaa aaaaa",
|
||||
" aa aaaaaa aa",
|
||||
" aaaaaaaaaa",
|
||||
" aaaa"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 60,
|
||||
"height": 11
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
{
|
||||
"name": "ukraine",
|
||||
"title": {
|
||||
"uk": "Тризуб: малий герб України і дані сервера",
|
||||
"en": "Tryzub: Ukrainian coat of arms and server facts"
|
||||
},
|
||||
"local": false,
|
||||
"vars": {
|
||||
"url": ""
|
||||
},
|
||||
"fields": [
|
||||
{
|
||||
"x": 26,
|
||||
"y": 1,
|
||||
"width": 54,
|
||||
"align": "left",
|
||||
"text": "{hostname}",
|
||||
"style": "e",
|
||||
"font": "small"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 6,
|
||||
"width": 54,
|
||||
"align": "left",
|
||||
"text": "[[Слава Україні!|Glory to Ukraine!]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 8,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "IP",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 8,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{ip}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 9,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Сайт|Site]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 9,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{url}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 10,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Працює|Uptime]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 10,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{uptime}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 11,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[Пам'ять|Memory]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 11,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{mem}",
|
||||
"style": "a"
|
||||
},
|
||||
{
|
||||
"x": 26,
|
||||
"y": 12,
|
||||
"width": 9,
|
||||
"align": "left",
|
||||
"text": "[[ОС|OS]]",
|
||||
"style": "f"
|
||||
},
|
||||
{
|
||||
"x": 35,
|
||||
"y": 12,
|
||||
"width": 45,
|
||||
"align": "left",
|
||||
"text": "{os}",
|
||||
"style": "a"
|
||||
}
|
||||
],
|
||||
"widgets_position": "below",
|
||||
"widgets": [
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "disks"
|
||||
},
|
||||
{
|
||||
"type": "builtin",
|
||||
"name": "services"
|
||||
}
|
||||
],
|
||||
"styles": {
|
||||
"a": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"b": {
|
||||
"fg": "blue",
|
||||
"bg": "bright_yellow",
|
||||
"bold": false
|
||||
},
|
||||
"c": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": "blue",
|
||||
"bold": false
|
||||
},
|
||||
"d": {
|
||||
"fg": "blue",
|
||||
"bg": null,
|
||||
"bold": false
|
||||
},
|
||||
"e": {
|
||||
"fg": "bright_yellow",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
},
|
||||
"f": {
|
||||
"fg": "bright_blue",
|
||||
"bg": null,
|
||||
"bold": true
|
||||
}
|
||||
},
|
||||
"lines": [
|
||||
"██████████████████████",
|
||||
"██████████████████████",
|
||||
"████▀████████████▀████",
|
||||
"████▀█▀████████▀█▀████",
|
||||
"██████████████████████",
|
||||
"███████▀██████▀███████",
|
||||
"██████████████████████",
|
||||
"█████▀█▀█▀██▀█▀█▀█████",
|
||||
"██████▀█▀████▀█▀██████",
|
||||
"██████▀███▀▀███▀██████",
|
||||
"████▀▀▀██▀██▀██▀▀▀████",
|
||||
"███▀▀▀▀▀█▀██▀█▀▀▀▀▀███",
|
||||
" ▀██████▀████▀██████▀",
|
||||
" ▀▀▀████▀▀████▀▀▀",
|
||||
" ▀▀██▀▀"
|
||||
],
|
||||
"style_map": [
|
||||
"dddddddddddddddddddddd",
|
||||
"ddddddddddaadddddddddd",
|
||||
"dddabdddddaadddddbaddd",
|
||||
"dddacabdddaadddbacaddd",
|
||||
"dddaddadddaadddaddaddd",
|
||||
"dddaddabddaaddbaddaddd",
|
||||
"dddaddaaddaaddaaddaddd",
|
||||
"dddadbacdbaabdcabdaddd",
|
||||
"dddaaabdbaddabdbaaaddd",
|
||||
"dddaddcaaabbaaacddaddd",
|
||||
"dddabbbaabaabaabbbaddd",
|
||||
"dddcccccacaacacccccddd",
|
||||
" dddddddcaaaacddddddd",
|
||||
" dddddddccddddddd",
|
||||
" dddddd"
|
||||
],
|
||||
"canvas": {
|
||||
"width": 80,
|
||||
"height": 15
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Контейнери Docker
|
||||
# dbanner-title-en: Docker containers
|
||||
# dbanner-mode: live
|
||||
# Running and stopped containers; nothing when Docker is not there or not reachable.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v docker >/dev/null || exit 0
|
||||
mapfile -t states < <(timeout 1 docker ps -a --format '{{.State}}' 2>/dev/null) || exit 0
|
||||
((${#states[@]})) || exit 0
|
||||
running=0
|
||||
for s in "${states[@]}"; do [[ $s == running ]] && ((running++)); done
|
||||
stopped=$((${#states[@]} - running))
|
||||
color=$Green
|
||||
((stopped)) && color=$Yellow
|
||||
echo "$Bold$(say "Docker:" "Docker:")$Off $color$running$Off $(say "працює" "running"), $stopped $(say "зупинено" "stopped")"
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Заблоковані fail2ban адреси
|
||||
# dbanner-title-en: Addresses banned by fail2ban
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 300
|
||||
# How many addresses fail2ban keeps banned now and in total; needs root.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v fail2ban-client >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
out=$(timeout 5 fail2ban-client status sshd 2>/dev/null) || exit 0
|
||||
now=$(awk -F: '/Currently banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
|
||||
total=$(awk -F: '/Total banned/ {gsub(/ /, "", $2); print $2}' <<<"$out")
|
||||
echo "$Bold$(say "fail2ban (sshd):" "fail2ban (sshd):")$Off $Yellow${now:-0}$Off $(say "заблоковано зараз, усього" "banned now, total") ${total:-0}"
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Попередній вхід
|
||||
# dbanner-title-en: Previous login
|
||||
# dbanner-mode: live
|
||||
# The login before this one of the user who logs in (from last).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v last >/dev/null || exit 0
|
||||
user=${USER:-$(id -un)}
|
||||
line=$(timeout 1 last -n 2 -w --time-format iso "$user" 2>/dev/null | sed -n 2p)
|
||||
[[ -n $line && $line != wtmp* ]] || exit 0
|
||||
read -r _ tty from when _ <<<"$line"
|
||||
[[ $from == :* || $from =~ ^tty ]] && from=$tty
|
||||
echo "$Bold$(say "Попередній вхід:" "Previous login:")$Off ${when%+*} $(say "з" "from") $Cyan$from$Off"
|
||||
@@ -0,0 +1,14 @@
|
||||
# Shared by the dbanner widget scripts: colors and the user's language.
|
||||
# shellcheck shell=bash
|
||||
# shellcheck disable=SC2034
|
||||
Off=$'\e[0m' Bold=$'\e[1;37m' Red=$'\e[1;31m' Green=$'\e[1;32m' Yellow=$'\e[1;33m' Cyan=$'\e[1;36m' Gray=$'\e[0;90m'
|
||||
|
||||
# say UKRAINIAN ENGLISH
|
||||
say() {
|
||||
if [[ ${DBANNER_LANG:-} == uk ]]; then echo "$1"; else echo "$2"; fi
|
||||
}
|
||||
|
||||
# level VALUE WARN CRIT: green below WARN, yellow below CRIT, red otherwise
|
||||
level() {
|
||||
if (($1 < $2)); then echo -n "$Green"; elif (($1 < $3)); then echo -n "$Yellow"; else echo -n "$Red"; fi
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Служби, що не працюють
|
||||
# dbanner-title-en: Services that are down
|
||||
# dbanner-mode: live
|
||||
# Enabled runit services that are not running; nothing when all of them are up.
|
||||
. "${0%/*}/lib.sh"
|
||||
dir=/var/service
|
||||
[[ -d $dir ]] || exit 0
|
||||
down=()
|
||||
for s in "$dir"/*/; do
|
||||
s=${s%/}
|
||||
[[ -f $s/down ]] && continue
|
||||
state=$(timeout 1 sv status "$s" 2>/dev/null) || state=down
|
||||
[[ $state == run:* ]] || down+=("${s##*/}")
|
||||
done
|
||||
((${#down[@]})) || exit 0
|
||||
echo "$Bold$(say "Не працюють:" "Down:")$Off $Red${down[*]}$Off $(say "(dsv status)" "(dsv status)")"
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Стан дисків (SMART)
|
||||
# dbanner-title-en: Disk health (SMART)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 21600
|
||||
# Health, temperature and power-on hours of every disk; needs root and smartmontools.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v smartctl >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
first=1
|
||||
for dev in /sys/block/*; do
|
||||
name=${dev##*/}
|
||||
[[ $name == @(loop|ram|zram|sr|dm-|md)* ]] && continue
|
||||
out=$(smartctl -H -A -i "/dev/$name" 2>/dev/null) || [[ -n $out ]] || continue
|
||||
model=$(sed -n 's/^\(Device Model\|Model Number\|Product\): *//p' <<<"$out" | head -1)
|
||||
health=$(sed -n 's/.*\(overall-health\|Health Status\).*: *//p' <<<"$out" | head -1)
|
||||
temp=$(awk '/Temperature_Celsius|Airflow_Temperature/ {print $10; exit} /^Temperature:/ {print $2; exit}' <<<"$out")
|
||||
hours=$(awk '/Power_On_Hours/ {print $10; exit} /^Power On Hours:/ {gsub(",", "", $4); print $4; exit}' <<<"$out")
|
||||
[[ -n $health ]] || continue
|
||||
if ((first)); then echo "$Bold$(say "Стан дисків:" "Disk health:")$Off"; first=0; fi
|
||||
hcolor=$Green
|
||||
[[ $health == PASSED || $health == OK ]] || hcolor=$Red
|
||||
line=" /dev/$name ${model:+($model) }$hcolor$health$Off"
|
||||
[[ $temp =~ ^[0-9]+$ ]] && line+=", $(level "$temp" 55 70)$temp °C$Off"
|
||||
[[ $hours =~ ^[0-9]+$ ]] && line+=", $Cyan$hours $(say "год" "h")$Off"
|
||||
echo "$line"
|
||||
done
|
||||
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Знімки Btrfs (snapper)
|
||||
# dbanner-title-en: Btrfs snapshots (snapper)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 3600
|
||||
# Number of snapper snapshots of / and the date of the last one; needs root.
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v snapper >/dev/null || exit 0
|
||||
((EUID == 0)) || exit 0
|
||||
mapfile -t rows < <(timeout 10 snapper -c root --csvout list --columns number,date 2>/dev/null | tail -n +3)
|
||||
((${#rows[@]})) || exit 0
|
||||
last=${rows[-1]#*,}
|
||||
echo "$Bold$(say "Знімки:" "Snapshots:")$Off ${#rows[@]}, $(say "останній" "last") $Cyan${last%:*}$Off"
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Доступні оновлення
|
||||
# dbanner-title-en: Available updates
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 21600
|
||||
# Package updates (xbps-install -Mun downloads the repository index, so it runs
|
||||
# from the das-banner service, not at login).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v xbps-install >/dev/null || exit 0
|
||||
count=$(xbps-install -Mun 2>/dev/null | wc -l) || exit 0
|
||||
if ((count)); then
|
||||
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Yellow$count$Off $(say "пакетів — das-update" "packages — das-update")"
|
||||
else
|
||||
echo "$Bold$(say "Оновлення:" "Updates:")$Off $Green$(say "система оновлена" "up to date")$Off"
|
||||
fi
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
# dbanner-title-uk: Погода (wttr.in)
|
||||
# dbanner-title-en: Weather (wttr.in)
|
||||
# dbanner-mode: cached
|
||||
# dbanner-interval: 1800
|
||||
# Weather from wttr.in for the "city" variable of the banner (DBANNER_CITY).
|
||||
. "${0%/*}/lib.sh"
|
||||
command -v curl >/dev/null || exit 0
|
||||
city=${DBANNER_CITY:-}
|
||||
lang=${DBANNER_LANG:-en}
|
||||
line=$(curl -fsS -m 15 -H "Accept-Language: $lang" "https://wttr.in/${city// /+}?format=%l:+%c+%t+(%f),+%w,+%h&lang=$lang" 2>/dev/null) || exit 0
|
||||
[[ $line == *"Unknown location"* || -z $line ]] && exit 0
|
||||
echo "$Bold$(say "Погода:" "Weather:")$Off $Cyan$line$Off"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "classic",
|
||||
"title": {"uk": "Класичне Void: [user@host dir]$", "en": "Classic Void: [user@host dir]$"},
|
||||
"frame": "plain",
|
||||
"open": "",
|
||||
"close": "",
|
||||
"sep": "",
|
||||
"segments": [
|
||||
{"type": "text", "text": "["},
|
||||
{"type": "userhost", "fg": null, "at_fg": null, "at_bold": false, "host_fg": null, "host_bold": false, "root_fg": null, "root_bold": false},
|
||||
{"type": "text", "text": " "},
|
||||
{"type": "cwd", "mode": "base", "fg": null},
|
||||
{"type": "text", "text": "]"},
|
||||
{"type": "symbol", "fg": null}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "das-git",
|
||||
"title": {"uk": "Das для розробника: git, venv, фонові задачі", "en": "Das for developers: git, venv, background jobs"},
|
||||
"frame": "das",
|
||||
"frame_fg": "red",
|
||||
"segments": [
|
||||
{"type": "status"},
|
||||
{"type": "userhost"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "git"},
|
||||
{"type": "venv"},
|
||||
{"type": "jobs"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "das",
|
||||
"title": {"uk": "Das: дві лінії з датою й часом (стандарт)", "en": "Das: two lines with date and time (default)"},
|
||||
"frame": "das",
|
||||
"frame_fg": "red",
|
||||
"segments": [
|
||||
{"type": "status"},
|
||||
{"type": "userhost"},
|
||||
{"type": "date"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "minimal",
|
||||
"title": {"uk": "Мінімальне: каталог, git і ❯", "en": "Minimal: directory, git and ❯"},
|
||||
"frame": "plain",
|
||||
"open": "",
|
||||
"close": "",
|
||||
"sep": " ",
|
||||
"segments": [
|
||||
{"type": "cwd", "mode": "short", "fg": "blue", "bold": true},
|
||||
{"type": "git", "fg": "magenta"},
|
||||
{"type": "status", "fg": "red", "bold": true},
|
||||
{"type": "symbol", "char": "❯", "fg": "green", "bold": true}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "server",
|
||||
"title": {"uk": "Сервер: ім'я хоста й ознака SSH помітні", "en": "Server: host name and SSH mark stand out"},
|
||||
"frame": "das",
|
||||
"frame_fg": "blue",
|
||||
"segments": [
|
||||
{"type": "status", "mode": "code", "fg": "bright_red", "bold": true},
|
||||
{"type": "ssh"},
|
||||
{"type": "userhost", "host_fg": "bright_yellow"},
|
||||
{"type": "time"},
|
||||
{"type": "cwd"},
|
||||
{"type": "newline"},
|
||||
{"type": "symbol", "fg": "bright_yellow"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Points das_shell at the source tree and a scratch /etc before it is imported."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
FILES = os.path.dirname(HERE)
|
||||
SCRATCH = tempfile.mkdtemp(prefix="das-shell-test-")
|
||||
|
||||
os.environ.update({
|
||||
"DAS_SHELL_DATA": os.path.join(FILES, "share"),
|
||||
"DAS_SHELL_LIB": FILES,
|
||||
"DAS_SHELL_ETC": os.path.join(SCRATCH, "etc"),
|
||||
"DAS_SHELL_CACHE": os.path.join(SCRATCH, "cache"),
|
||||
"XDG_CONFIG_HOME": os.path.join(SCRATCH, "config"),
|
||||
"LANG": "en_US.UTF-8",
|
||||
})
|
||||
os.environ.pop("LC_ALL", None)
|
||||
os.environ.pop("LC_MESSAGES", None)
|
||||
sys.path.insert(0, os.path.join(FILES, "python"))
|
||||
@@ -0,0 +1,108 @@
|
||||
import os
|
||||
import time
|
||||
import unittest
|
||||
|
||||
from tests.common import SCRATCH
|
||||
|
||||
from das_shell import banner, widgets # noqa: E402
|
||||
|
||||
|
||||
class GridTest(unittest.TestCase):
|
||||
def test_round_trip(self):
|
||||
g = banner.Grid(10, 3)
|
||||
g.box(0, 0, 9, 2, "double", g.style_key("cyan", None, True))
|
||||
g.text(2, 1, "Дас", g.style_key("yellow"))
|
||||
d = g.to_design({"name": "x"})
|
||||
g2 = banner.Grid.from_design(d)
|
||||
self.assertEqual(banner.grid_lines(g2, color=False), ["╔════════╗", "║ Дас ║", "╚════════╝"])
|
||||
self.assertEqual(len(d["styles"]), 2)
|
||||
|
||||
def test_stamp_merges_styles(self):
|
||||
a = banner.Grid(4, 1)
|
||||
a.text(0, 0, "ab", a.style_key("red"))
|
||||
b = banner.Grid(6, 1)
|
||||
b.text(0, 0, "x", b.style_key("blue"))
|
||||
b.stamp(2, 0, a)
|
||||
self.assertEqual(banner.grid_lines(b, color=False), ["x ab"])
|
||||
self.assertEqual(b.style(b.get(2, 0)[1])[0], "red")
|
||||
|
||||
def test_region_and_resize(self):
|
||||
g = banner.Grid(5, 5)
|
||||
g.fill(1, 1, 3, 3, "#", " ")
|
||||
r = g.region(1, 1, 2, 2)
|
||||
self.assertEqual((r.width, r.height), (2, 2))
|
||||
g.resize(2, 2)
|
||||
self.assertEqual(banner.grid_lines(g, color=False), ["", " #"])
|
||||
|
||||
|
||||
class RenderTest(unittest.TestCase):
|
||||
def test_fields_keep_the_frame(self):
|
||||
d = banner.load("das-classic")
|
||||
values = widgets.Values({"title": "T", "url": "https://example.org/"})
|
||||
values["ip"] = "10.0.0.123"
|
||||
lines = banner.render(dict(d, widgets=[]), values, [], color=False).splitlines()
|
||||
self.assertEqual(len({len(line) for line in lines}), 1, lines)
|
||||
self.assertIn("10.0.0.123", lines[2])
|
||||
|
||||
def test_long_value_is_cut(self):
|
||||
self.assertEqual(banner.fit("abcdef", 4, "left"), "abc…")
|
||||
self.assertEqual(banner.fit("ab", 4, "center"), " ab ")
|
||||
|
||||
def test_languages_and_empty_values(self):
|
||||
values = widgets.Values({"url": ""})
|
||||
self.assertEqual(banner.expand("[[Сайт|Site]]: {url}", values), "Site: ")
|
||||
f = {"x": 0, "y": 0, "width": 20, "text": "{x} · {url}"}
|
||||
self.assertEqual(banner.field_lines(f, widgets.Values({"x": "host", "url": ""}))[0].rstrip(), "host")
|
||||
self.assertEqual(banner.expand("{unknown}", values), "{unknown}")
|
||||
|
||||
def test_widgets_right(self):
|
||||
d = {"lines": ["AB", "CD"], "widgets_position": "right"}
|
||||
out = banner.render(d, widgets.Values(), [["w1", "w2", "w3"]], color=False)
|
||||
self.assertEqual(out.splitlines(), ["AB w1", "CD w2", " w3"])
|
||||
|
||||
def test_every_preset_renders(self):
|
||||
for name, _ in banner.list_presets():
|
||||
d = banner.load(name)
|
||||
out = banner.render(d, widgets.Values(d["vars"]), [], color=True)
|
||||
self.assertTrue(out.strip(), name)
|
||||
|
||||
def test_art_pictures(self):
|
||||
names = {n for n, _, _ in banner.list_art()}
|
||||
self.assertTrue({"das-logo", "das-logo-small", "trident", "trident-small", "trident-shield"} <= names)
|
||||
|
||||
|
||||
class WidgetTest(unittest.TestCase):
|
||||
def test_slow_widget_does_not_hold_the_login(self):
|
||||
d = {"widgets": [{"type": "command", "cmd": "sleep 10"}, {"type": "command", "cmd": "echo hi"}]}
|
||||
start = time.monotonic()
|
||||
blocks = widgets.blocks(d, widgets.Values())
|
||||
self.assertLess(time.monotonic() - start, 4)
|
||||
self.assertEqual(blocks, [[], ["hi"]])
|
||||
|
||||
def test_cache(self):
|
||||
w = {"type": "command", "cmd": "echo cached", "mode": "cached", "interval": 1000}
|
||||
d = {"widgets": [w]}
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values()), [[]])
|
||||
widgets.refresh(d)
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values()), [["cached"]])
|
||||
|
||||
def test_file_widget(self):
|
||||
path = os.path.join(SCRATCH, "out.txt")
|
||||
with open(path, "w") as f:
|
||||
f.write("a\nb\n")
|
||||
self.assertEqual(widgets.run({"type": "file", "path": path}, widgets.Values()), ["a", "b"])
|
||||
|
||||
def test_builtins(self):
|
||||
values = widgets.Values()
|
||||
for name in widgets.BUILTINS:
|
||||
widgets.run({"type": "builtin", "name": name}, values)
|
||||
self.assertTrue(values.get("hostname"))
|
||||
self.assertNotEqual(values.get("uptime"), "?")
|
||||
|
||||
def test_vars_reach_scripts(self):
|
||||
d = {"vars": {"city": "Kyiv"}, "widgets": [{"type": "command", "cmd": "echo $DBANNER_CITY"}]}
|
||||
self.assertEqual(widgets.blocks(d, widgets.Values())[0], ["Kyiv"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,86 @@
|
||||
import datetime
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from tests.common import FILES, SCRATCH
|
||||
|
||||
from das_shell import prompt # noqa: E402
|
||||
|
||||
NOW = datetime.datetime(2026, 10, 21, 8, 12)
|
||||
|
||||
|
||||
def text(design, **state):
|
||||
return prompt.preview_text(design, prompt.sample_state(now=NOW, **state))
|
||||
|
||||
|
||||
class PresetTest(unittest.TestCase):
|
||||
def test_presets_load(self):
|
||||
names = [n for n, _ in prompt.list_presets()]
|
||||
self.assertEqual(names[0], "das")
|
||||
self.assertTrue({"das", "das-git", "classic", "minimal", "server"} <= set(names))
|
||||
|
||||
def test_das_matches_the_authors_prompt(self):
|
||||
d = prompt.load_preset("das")
|
||||
self.assertEqual(text(d), "┌─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
|
||||
self.assertEqual(text(d, rc=1), "┌─[✗]─[das@dasos]─[2026-10-21]─[08:12]─[~/Projects/web]\n└─╼ $ ")
|
||||
self.assertEqual(text(d, root=True), "┌─[root@dasos]─[2026-10-21]─[08:12]─[/etc/sv]\n└─╼ # ")
|
||||
|
||||
def test_classic_is_void(self):
|
||||
self.assertEqual(text(prompt.load_preset("classic")), "[das@dasos web]$ ")
|
||||
|
||||
def test_hidden_parts_take_their_separator(self):
|
||||
d = prompt.load_preset("das-git")
|
||||
self.assertNotIn("──", text(d))
|
||||
self.assertIn("─[main*]─[.venv]─[jobs:2]", text(d, git="main*", venv=".venv", jobs=2))
|
||||
|
||||
def test_unknown_segments_are_dropped(self):
|
||||
d = prompt.normalize({"segments": [{"type": "nope"}, {"type": "cwd"}]})
|
||||
self.assertEqual([s["type"] for s in d["segments"]], ["cwd"])
|
||||
|
||||
|
||||
@unittest.skipUnless(shutil.which("bash"), "needs bash")
|
||||
class BashTest(unittest.TestCase):
|
||||
"""The generated script in a real bash, after Void's skel .bashrc."""
|
||||
|
||||
def run_prompt(self, preset, before="true", term="xterm-256color"):
|
||||
path = os.path.join(SCRATCH, preset + ".sh")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.script(prompt.load_preset(preset)))
|
||||
code = (f". {FILES}/prompt-lib.sh; . {path}; PS1='[\\u@\\h \\W]\\$ '; "
|
||||
f"{before}; __das_prompt_hook; printf '%s' \"${{PS1@P}}\"")
|
||||
out = subprocess.run(["bash", "--norc", "--noprofile", "-c", code], capture_output=True, text=True,
|
||||
env=dict(os.environ, TERM=term, HOME="/home/das"), cwd="/").stdout
|
||||
for seq in ("\x01", "\x02"):
|
||||
out = out.replace(seq, "")
|
||||
import re
|
||||
return re.sub(r"\x1b\[[0-9;]*m|\x1b\][^\a]*\a", "", out)
|
||||
|
||||
def test_script_is_valid(self):
|
||||
for name, _ in prompt.list_presets():
|
||||
path = os.path.join(SCRATCH, name + ".sh")
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(prompt.script(prompt.load_preset(name)))
|
||||
subprocess.run(["bash", "-n", path], check=True)
|
||||
|
||||
def test_skel_ps1_is_replaced(self):
|
||||
out = self.run_prompt("das")
|
||||
self.assertTrue(out.startswith("┌─["), out)
|
||||
self.assertNotIn("[✗]", out)
|
||||
|
||||
def test_error_mark(self):
|
||||
self.assertIn("┌─[✗]─[", self.run_prompt("das", "false"))
|
||||
|
||||
def test_console_glyphs(self):
|
||||
out = self.run_prompt("das", "false", term="linux")
|
||||
self.assertIn("[x]", out)
|
||||
self.assertIn("└─> ", out)
|
||||
|
||||
def test_plain_terminal(self):
|
||||
out = self.run_prompt("das", term="dumb")
|
||||
self.assertTrue(out.startswith("┌─["), out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,57 @@
|
||||
# Template file for 'das-shell'
|
||||
# In-tree package: sources are in files/ (FILESDIR).
|
||||
pkgname=das-shell
|
||||
version=0.1.0
|
||||
revision=1
|
||||
create_wrksrc=yes
|
||||
hostmakedepends="python3"
|
||||
checkdepends="bash"
|
||||
depends="bash python3 python3-textual figlet"
|
||||
short_desc="Das OS terminal: bash prompt, dprompt and dbanner (SSH login banner)"
|
||||
maintainer="Das <anry.das@gmail.com>"
|
||||
license="MIT"
|
||||
homepage="https://git.das.org.ua/das/Das-OS"
|
||||
conf_files="/etc/bash/bashrc.d/das-prompt.sh /etc/profile.d/das-banner.sh"
|
||||
make_dirs="/var/cache/das/banner 0755 root root"
|
||||
pycompile_dirs="usr/lib/das/python"
|
||||
python_version=3
|
||||
|
||||
do_build() {
|
||||
# The stock prompt is the "das" preset turned into bash by the code itself
|
||||
PYTHONPATH="${FILESDIR}/python" DAS_SHELL_DATA="${FILESDIR}/share" \
|
||||
python3 -m das_shell.cli_prompt show das --script >default-prompt.sh
|
||||
}
|
||||
|
||||
do_check() {
|
||||
cd "${FILESDIR}" && PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s tests -t .
|
||||
}
|
||||
|
||||
do_install() {
|
||||
local f
|
||||
vinstall "${FILESDIR}/bashrc.d/das-prompt.sh" 644 etc/bash/bashrc.d
|
||||
vinstall "${FILESDIR}/profile.d/das-banner.sh" 644 etc/profile.d
|
||||
vinstall "${FILESDIR}/prompt-lib.sh" 644 usr/lib/das
|
||||
vinstall default-prompt.sh 644 usr/share/das/prompt default.sh
|
||||
for f in "${FILESDIR}"/python/das_shell/*.py; do
|
||||
vinstall "${f}" 644 usr/lib/das/python/das_shell
|
||||
done
|
||||
for f in "${FILESDIR}"/share/prompt/presets/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/prompt/presets
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/presets/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/banner/presets
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/art/*.json; do
|
||||
vinstall "${f}" 644 usr/share/das/banner/art
|
||||
done
|
||||
for f in "${FILESDIR}"/share/banner/widgets/*.sh; do
|
||||
vinstall "${f}" 755 usr/share/das/banner/widgets
|
||||
done
|
||||
chmod 644 "${DESTDIR}/usr/share/das/banner/widgets/lib.sh"
|
||||
vsv das-banner
|
||||
for f in dprompt dbanner; do
|
||||
vbin "${FILESDIR}/bin/${f}"
|
||||
vcompletion "${FILESDIR}/${f}.bash-completion" bash ${f}
|
||||
done
|
||||
vlicense "${FILESDIR}/LICENSE"
|
||||
}
|
||||
Reference in New Issue
Block a user