summaryrefslogtreecommitdiff
path: root/modui
diff options
context:
space:
mode:
authorMia Herkt <mia@0x0.st>2022-12-20 15:45:55 +0100
committerMia Herkt <mia@0x0.st>2022-12-20 16:19:49 +0100
commiteebd5d8c6d8a756384b6cf1b2cc76b8a83cdf929 (patch)
tree764a7c28cbdc165d0cf843f8cc0f6bc53ab224b6 /modui
parentdcea8bffe19b8ba308f9361d6c8ef08281ddff1b (diff)
Add moderation TUI
This ended up way fancier than I imagined.
Diffstat (limited to 'modui')
-rw-r--r--modui/__init__.py3
-rw-r--r--modui/filetable.py72
-rw-r--r--modui/mime.py122
-rw-r--r--modui/mpvwidget.py88
-rw-r--r--modui/notification.py8
5 files changed, 293 insertions, 0 deletions
diff --git a/modui/__init__.py b/modui/__init__.py
new file mode 100644
index 0000000..2e190ea
--- /dev/null
+++ b/modui/__init__.py
@@ -0,0 +1,3 @@
+from .filetable import FileTable
+from .notification import Notification
+from .mpvwidget import MpvWidget
diff --git a/modui/filetable.py b/modui/filetable.py
new file mode 100644
index 0000000..7be0f1b
--- /dev/null
+++ b/modui/filetable.py
@@ -0,0 +1,72 @@
+from textual.widgets import DataTable, Static
+from textual.reactive import Reactive
+from textual.message import Message, MessageTarget
+from textual import events, log
+from jinja2.filters import do_filesizeformat
+
+from fhost import File
+from modui import mime
+
+class FileTable(DataTable):
+ query = Reactive(None)
+ order_col = Reactive(0)
+ order_desc = Reactive(True)
+ limit = 10000
+ colmap = [File.id, File.removed, File.nsfw_score, None, File.ext, File.size, File.mime]
+
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+ self.add_columns("#", "☣️", "🔞", "📂", "name", "size", "mime")
+ self.base_query = File.query.filter(File.size != None)
+ self.query = self.base_query
+
+ class Selected(Message):
+ def __init__(self, sender: MessageTarget, f: File) -> None:
+ self.file = f
+ super().__init__(sender)
+
+ def watch_order_col(self, old, value) -> None:
+ self.watch_query(None, None)
+
+ def watch_order_desc(self, old, value) -> None:
+ self.watch_query(None, None)
+
+ def watch_query(self, old, value) -> None:
+ def fmt_file(f: File) -> tuple:
+ return (
+ str(f.id),
+ "🔴" if f.removed else " ",
+ "🚩" if f.is_nsfw else " ",
+ "👻" if not f.getpath().is_file() else " ",
+ f.getname(),
+ do_filesizeformat(f.size, True),
+ f"{mime.mimemoji.get(f.mime.split('/')[0], mime.mimemoji.get(f.mime)) or ' '} " + f.mime,
+ )
+
+ if (self.query):
+ self.clear()
+ order = FileTable.colmap[self.order_col]
+ q = self.query
+ if order: q = q.order_by(order.desc() if self.order_desc else order, File.id)
+ self.add_rows(map(fmt_file, q.limit(self.limit)))
+
+ def _scroll_cursor_in_to_view(self, animate: bool = False) -> None:
+ region = self._get_cell_region(self.cursor_row, 0)
+ spacing = self._get_cell_border()
+ self.scroll_to_region(region, animate=animate, spacing=spacing)
+
+ async def watch_cursor_cell(self, old, value) -> None:
+ super().watch_cursor_cell(old, value)
+ if value[0] < len(self.data) and value[0] >= 0:
+ f = File.query.get(int(self.data[value[0]][0]))
+ await self.emit(self.Selected(self, f))
+
+ def on_click(self, event: events.Click) -> None:
+ super().on_click(event)
+ meta = self.get_style_at(event.x, event.y).meta
+ if meta:
+ if meta["row"] == -1:
+ qi = FileTable.colmap[meta["column"]]
+ if meta["column"] == self.order_col:
+ self.order_desc = not self.order_desc
+ self.order_col = meta["column"]
diff --git a/modui/mime.py b/modui/mime.py
new file mode 100644
index 0000000..c94e800
--- /dev/null
+++ b/modui/mime.py
@@ -0,0 +1,122 @@
+from enum import Enum
+from textual import log
+
+mimemoji = {
+ "audio" : "🔈",
+ "video" : "🎞",
+ "text" : "📄",
+ "image" : "🖼",
+ "application/zip" : "🗜️",
+ "application/x-zip-compressed" : "🗜️",
+ "application/x-tar" : "🗄",
+ "application/x-cpio" : "🗄",
+ "application/x-xz" : "🗜️",
+ "application/x-7z-compressed" : "🗜️",
+ "application/gzip" : "🗜️",
+ "application/zstd" : "🗜️",
+ "application/x-rar" : "🗜️",
+ "application/x-rar-compressed" : "🗜️",
+ "application/vnd.ms-cab-compressed" : "🗜️",
+ "application/x-bzip2" : "🗜️",
+ "application/x-lzip" : "🗜️",
+ "application/x-iso9660-image" : "💿",
+ "application/pdf" : "📕",
+ "application/epub+zip" : "📕",
+ "application/mxf" : "🎞",
+ "application/vnd.android.package-archive" : "📦",
+ "application/vnd.debian.binary-package" : "📦",
+ "application/x-rpm" : "📦",
+ "application/x-dosexec" : "⚙",
+ "application/x-execuftable" : "⚙",
+ "application/x-sharedlib" : "⚙",
+ "application/java-archive" : "☕",
+ "application/x-qemu-disk" : "🖴",
+ "application/pgp-encrypted" : "🔏",
+}
+
+MIMECategory = Enum("MIMECategory",
+ ["Archive", "Text", "AV", "Document", "Fallback"]
+)
+
+class MIMEHandler:
+ def __init__(self):
+ self.handlers = {
+ MIMECategory.Archive : [[
+ "application/zip",
+ "application/x-zip-compressed",
+ "application/x-tar",
+ "application/x-cpio",
+ "application/x-xz",
+ "application/x-7z-compressed",
+ "application/gzip",
+ "application/zstd",
+ "application/x-rar",
+ "application/x-rar-compressed",
+ "application/vnd.ms-cab-compressed",
+ "application/x-bzip2",
+ "application/x-lzip",
+ "application/x-iso9660-image",
+ "application/vnd.android.package-archive",
+ "application/vnd.debian.binary-package",
+ "application/x-rpm",
+ "application/java-archive",
+ "application/vnd.openxmlformats"
+ ], []],
+ MIMECategory.Text : [["text"], []],
+ MIMECategory.AV : [[
+ "audio", "video", "image",
+ "application/mxf"
+ ], []],
+ MIMECategory.Document : [[
+ "application/pdf",
+ "application/epub",
+ "application/x-mobipocket-ebook",
+ ], []],
+ MIMECategory.Fallback : [[], []]
+ }
+
+ self.exceptions = {
+ MIMECategory.Archive : {
+ ".cbz" : MIMECategory.Document,
+ ".xps" : MIMECategory.Document,
+ ".epub" : MIMECategory.Document,
+ },
+ MIMECategory.Text : {
+ ".fb2" : MIMECategory.Document,
+ }
+ }
+
+ def register(self, category, handler):
+ self.handlers[category][1].append(handler)
+
+ def handle(self, mime, ext):
+ def getcat(s):
+ cat = MIMECategory.Fallback
+ for k, v in self.handlers.items():
+ s = s.split(";")[0]
+ if s in v[0] or s.split("/")[0] in v[0]:
+ cat = k
+ break
+
+ for x in v[0]:
+ if s.startswith(x):
+ cat = k
+ break
+
+ if cat in self.exceptions:
+ cat = self.exceptions[cat].get(ext) or cat
+
+ return cat
+
+ cat = getcat(mime)
+ for handler in self.handlers[cat][1]:
+ try:
+ if handler(cat): return
+ except: pass
+
+ for handler in self.handlers[MIMECategory.Fallback][1]:
+ try:
+ if handler(None): return
+ except: pass
+
+ raise RuntimeError(f"Unhandled MIME type category: {cat}")
diff --git a/modui/mpvwidget.py b/modui/mpvwidget.py
new file mode 100644
index 0000000..50e5859
--- /dev/null
+++ b/modui/mpvwidget.py
@@ -0,0 +1,88 @@
+import time
+import fcntl, struct, termios
+from sys import stdout
+
+from textual import events, log
+from textual.widgets import Static
+
+from fhost import app as fhost_app
+
+class MpvWidget(Static):
+ def __init__(self, **kwargs):
+ super().__init__(**kwargs)
+
+ self.mpv = None
+ self.vo = fhost_app.config.get("MOD_PREVIEW_PROTO")
+
+ if not self.vo in ["sixel", "kitty"]:
+ self.update("⚠ Previews not enabled. \n\nSet MOD_PREVIEW_PROTO to 'sixel' or 'kitty' in config.py,\nwhichever is supported by your terminal.")
+ else:
+ try:
+ import mpv
+ self.mpv = mpv.MPV()
+ self.mpv.profile = "sw-fast"
+ self.mpv["vo"] = self.vo
+ self.mpv[f"vo-{self.vo}-config-clear"] = False
+ self.mpv[f"vo-{self.vo}-alt-screen"] = False
+ self.mpv[f"vo-sixel-buffered"] = True
+ self.mpv["audio"] = False
+ self.mpv["loop-file"] = "inf"
+ self.mpv["image-display-duration"] = 0.5 if self.vo == "sixel" else "inf"
+ except Exception as e:
+ self.mpv = None
+ self.update(f"⚠ Previews require python-mpv with libmpv 0.36.0 or later \n\nError was:\n{type(e).__name__}: {e}")
+
+ def start_mpv(self, f: str|None = None, pos: float|str|None = None) -> None:
+ self.display = True
+ self.screen._refresh_layout()
+
+ if self.mpv:
+ if self.content_region.x:
+ r, c, w, h = struct.unpack('hhhh', fcntl.ioctl(0, termios.TIOCGWINSZ, '12345678'))
+ width = int((w / c) * self.content_region.width)
+ height = int((h / r) * (self.content_region.height + (1 if self.vo == "sixel" else 0)))
+ self.mpv[f"vo-{self.vo}-left"] = self.content_region.x + 1
+ self.mpv[f"vo-{self.vo}-top"] = self.content_region.y + 1
+ self.mpv[f"vo-{self.vo}-rows"] = self.content_region.height + (1 if self.vo == "sixel" else 0)
+ self.mpv[f"vo-{self.vo}-cols"] = self.content_region.width
+ self.mpv[f"vo-{self.vo}-width"] = width
+ self.mpv[f"vo-{self.vo}-height"] = height
+
+ if pos != None:
+ self.mpv["start"] = pos
+
+ if f:
+ self.mpv.loadfile(f)
+ else:
+ self.mpv.playlist_play_index(0)
+
+ def stop_mpv(self, wait: bool = False) -> None:
+ if self.mpv:
+ if not self.mpv.idle_active:
+ self.mpv.stop(True)
+ if wait:
+ time.sleep(0.1)
+ self.clear_mpv()
+ self.display = False
+
+ def on_resize(self, size) -> None:
+ if self.mpv:
+ if not self.mpv.idle_active:
+ t = self.mpv.time_pos
+ self.stop_mpv()
+ if t:
+ self.mpv["start"] = t
+ self.start_mpv()
+
+ def clear_mpv(self) -> None:
+ if self.vo == "kitty":
+ stdout.write("\033_Ga=d;\033\\")
+ stdout.flush()
+
+ def shutdown(self) -> None:
+ if self.mpv:
+ self.mpv.stop()
+ del self.mpv
+ if self.vo == "kitty":
+ stdout.write("\033_Ga=d;\033\\\033[?25l")
+ stdout.flush()
diff --git a/modui/notification.py b/modui/notification.py
new file mode 100644
index 0000000..ecae6e4
--- /dev/null
+++ b/modui/notification.py
@@ -0,0 +1,8 @@
+from textual.widgets import Static
+
+class Notification(Static):
+ def on_mount(self) -> None:
+ self.set_timer(3, self.remove)
+
+ def on_click(self) -> None:
+ self.remove()