Terminal: Das prompt, dprompt and dbanner (0.9.1)
build / build (push) Successful in 38m26s

- 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:
das
2026-10-21 09:17:10 +03:00
co-authored by Claude Opus 5.5
parent 66ea614e0d
commit a2bffc7fe9
72 changed files with 5506 additions and 23 deletions
+21
View File
@@ -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
+9
View File
@@ -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())
+9
View File
@@ -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())
+4
View File
@@ -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
+53
View File
@@ -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
View File
@@ -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")"
+13
View File
@@ -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
View File
@@ -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
}
+17
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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
View File
@@ -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
View File
@@ -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"}
]
}
+21
View File
@@ -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()
+57
View File
@@ -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"
}