diff --git a/examples/todoapp/main.py b/examples/todoapp/main.py index 0dd9adb..91a316f 100644 --- a/examples/todoapp/main.py +++ b/examples/todoapp/main.py @@ -1,3 +1,9 @@ from todoapp import Application +import sys -Application().run() +entry = "/todos" + +if len(sys.argv) > 1: + entry = sys.argv[1] + +Application().redirect_to_singleton(entry) diff --git a/examples/todoapp/todoapp/__init__.py b/examples/todoapp/todoapp/__init__.py index 3d43400..739f117 100644 --- a/examples/todoapp/todoapp/__init__.py +++ b/examples/todoapp/todoapp/__init__.py @@ -1,14 +1,19 @@ +import sys +from urllib.request import urlopen + from . import pages from pathlib import Path from taktk.application import Application from taktk.component import Component from taktk.component import Component +from taktk.dictionary import Dictionary from taktk.media import get_media from taktk.menu import Menu from taktk.writeable import Writeable -from taktk.dictionary import Dictionary -from .admin import DIR, User, Todo +from .admin import DIR +from .admin import Todo +from .admin import User from taktk.notification import Notification recent_files = ["ama.py", "test.py", "ttkbootstrap.py", "label.py"] @@ -26,6 +31,7 @@ def func(): class Application(Application): + icon = "@icon" pages = pages dictionaries = DIR / "dictionaries" media = DIR / "media" @@ -34,6 +40,7 @@ class Application(Application): minsize=(800, 400), ) destroy_cache = 5 + address = ('', 56789) menu = Menu( { "@file": { @@ -103,11 +110,12 @@ def update_language(self): class Layout(Component): r""" \frame weight:x='0: 10' weight:y='1: 10, 2: 10' - \frame padding=5 weight:y='2:10' weight:x='3:10' pos:grid=0,0 pos:sticky='nsew' + \frame padding=5 weight:y='2:10' weight:x='4:10' pos:grid=0,0 pos:sticky='nsew' \button command={back} image=img:@backward{width: 20} pos:grid=0,0 pos:sticky='w' bootstyle='dark outline' \button command={gt_users} image=img:@users-between-lines{height: 20} pos:grid=1,0 pos:sticky='w' bootstyle='dark outline' - \label text={f'logged in as: {User.current().name}' if User.current() else "not logged in!"} pos:grid=2,0 - \button command={forward} image=img:@forward{width: 20} pos:grid=4,0 pos:sticky='e' bootstyle='dark outline' + \button command={gt_todos} image=img:@check-double{height: 20} pos:grid=2,0 pos:sticky='w' bootstyle='dark outline' + \label text={f'logged in as: {User.current().name}' if User.current() else "not logged in!"} pos:grid=3,0 + \button command={forward} image=img:@forward{width: 20} pos:grid=5,0 pos:sticky='e' bootstyle='dark outline' \frame:outlet pos:grid=0,1 """ @@ -126,4 +134,7 @@ def forward(self): def gt_users(self): self.app("users") + def gt_todos(self): + self.app("todos") + User = User diff --git a/examples/todoapp/todoapp/media/img/check-double.png b/examples/todoapp/todoapp/media/img/check-double.png new file mode 100644 index 0000000..0364a19 Binary files /dev/null and b/examples/todoapp/todoapp/media/img/check-double.png differ diff --git a/examples/todoapp/todoapp/media/img/check-double.svg b/examples/todoapp/todoapp/media/img/check-double.svg new file mode 100644 index 0000000..ac4a6ba --- /dev/null +++ b/examples/todoapp/todoapp/media/img/check-double.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/todoapp/todoapp/media/img/icon.png b/examples/todoapp/todoapp/media/img/icon.png new file mode 100644 index 0000000..ba26542 Binary files /dev/null and b/examples/todoapp/todoapp/media/img/icon.png differ diff --git a/examples/todoapp/todoapp/media/img/rmbg.py b/examples/todoapp/todoapp/media/img/rmbg.py index 59c852e..ce2279d 100644 --- a/examples/todoapp/todoapp/media/img/rmbg.py +++ b/examples/todoapp/todoapp/media/img/rmbg.py @@ -26,4 +26,4 @@ def rmbg( for x in Path(".").glob("*.png"): - rmbg(x, color=(0, 0, 0), replace=(50, 50, 70)) + rmbg(x, color=(255, 255, 255), replace=(255, 255, 255, 0)) diff --git a/examples/todoapp/todoapp/media/img/users-0.png b/examples/todoapp/todoapp/media/img/users-0.png index b702e46..8fa52d8 100644 Binary files a/examples/todoapp/todoapp/media/img/users-0.png and b/examples/todoapp/todoapp/media/img/users-0.png differ diff --git a/examples/todoapp/todoapp/media/img/users-1.png b/examples/todoapp/todoapp/media/img/users-1.png index 41d637f..1ad2429 100644 Binary files a/examples/todoapp/todoapp/media/img/users-1.png and b/examples/todoapp/todoapp/media/img/users-1.png differ diff --git a/examples/todoapp/todoapp/media/img/users-between-lines.png b/examples/todoapp/todoapp/media/img/users-between-lines.png index b702e46..56c5d9b 100644 Binary files a/examples/todoapp/todoapp/media/img/users-between-lines.png and b/examples/todoapp/todoapp/media/img/users-between-lines.png differ diff --git a/examples/todoapp/todoapp/media/img/users.png b/examples/todoapp/todoapp/media/img/users.png index 41d637f..31dc67b 100644 Binary files a/examples/todoapp/todoapp/media/img/users.png and b/examples/todoapp/todoapp/media/img/users.png differ diff --git a/examples/todoapp/todoapp/pages/todos.py b/examples/todoapp/todoapp/pages/todos.py index 320506a..98a7490 100644 --- a/examples/todoapp/todoapp/pages/todos.py +++ b/examples/todoapp/todoapp/pages/todos.py @@ -45,8 +45,7 @@ def add_todo(self, *_): return Notification( "Empty field", "Please, enter an item", - icon=r"C:\taktk\images\example-simple.png", - duration=10000, + duration=1000, bootstyle="warning", source="todo-empty-notification", ).show() diff --git a/examples/todoapp/todoapp/store.json b/examples/todoapp/todoapp/store.json index 80db03e..c97ea24 100644 --- a/examples/todoapp/todoapp/store.json +++ b/examples/todoapp/todoapp/store.json @@ -1,6 +1,6 @@ { - "language": "French", - "theme": "journal", + "language": "English", + "theme": "darkly", "__pageStore_todoapp.admin__": { "users": [ { @@ -56,6 +56,30 @@ "author_id": "d23acc8d-6577-11ef-82f6-28b2bda8b9ca", "desc": "kenpd", "done": true + }, + { + "uuid": "00bbcaed-65f2-11ef-a2f7-28d244ed9304", + "author_id": "d23acc8d-6577-11ef-82f6-28b2bda8b9ca", + "desc": "I love python!", + "done": false + }, + { + "uuid": "07ca2fd9-65f2-11ef-b1d9-28d244ed9304", + "author_id": "d23acc8d-6577-11ef-82f6-28b2bda8b9ca", + "desc": "I love tkinter!", + "done": true + }, + { + "uuid": "0afbe6e3-65f2-11ef-8b72-28d244ed9304", + "author_id": "d23acc8d-6577-11ef-82f6-28b2bda8b9ca", + "desc": "I love Tk!", + "done": false + }, + { + "uuid": "346f294e-65f2-11ef-825c-28d244ed9304", + "author_id": "d23acc8d-6577-11ef-82f6-28b2bda8b9ca", + "desc": "kkkdmc", + "done": false } ] }, @@ -63,4 +87,4 @@ "entry:ama": "", "entry:ken-morel": "Notez ici" } -} +} \ No newline at end of file diff --git a/src/taktk/application.py b/src/taktk/application.py index 1ddf34c..ff912dc 100644 --- a/src/taktk/application.py +++ b/src/taktk/application.py @@ -3,6 +3,7 @@ import json from tempfile import NamedTemporaryFile from .store import Store +from .media import get_media, get_image from logging import getLogger from . import ON_CREATE_HANDLERS @@ -17,6 +18,8 @@ class Application: layout = None destroy_cache: int = 5 store = (None, {}) + address = None + icon = None def __init__(self): import taktk @@ -52,7 +55,11 @@ def setup_taktk(self): media.MEDIA_DIR = Path(self.media) + def create(self): + if self.icon is not None: + self.icon = get_image(self.icon) + self.params['iconphoto'] = self.icon.full_path self.root = root = Window(**self.params) root.columnconfigure(0, weight=10) root.rowconfigure(0, weight=10) @@ -71,18 +78,41 @@ def run(self, entry="/"): self.init() for handler in ON_CREATE_HANDLERS: handler(self) + if self.address is not None: + self.listen_at(self.address) self.view = PageView(root, self.pages, self, self.destroy_cache) self.view.geometry() self.view.url(entry) self.root.mainloop() - def __call__(self, module, function=None, /, **params): - self.view(module, function, params) + def __call__(self, module, function=None, redirect=True, /, **params): + try: + self.view(module, function, params) + except Redirect as e: + target = e.url + if redirect: + self.view.url(target) + else: + raise self.layout.update() + def url(self, url): + return self.view.url(url) + def exit(self): self.root.destroy() - def listen_at(self, port): + def listen_at(self, address): from .application_server import ApplicationServer - ApplicationServer(self).thread_serve() + ApplicationServer(self, address).thread_serve() + + def redirect_to_singleton(self, url=""): + from urllib.request import urlopen + try: + urlopen(f"http://localhost:{self.address[1]}/!current") + except Exception as e: + self.run(url) + return True + else: + urlopen(f"http://localhost:{self.address[1]}/" + url.lstrip('/')) + return False diff --git a/src/taktk/application_server.py b/src/taktk/application_server.py new file mode 100644 index 0000000..b8c16a6 --- /dev/null +++ b/src/taktk/application_server.py @@ -0,0 +1,45 @@ +from http.server import HTTPServer, SimpleHTTPRequestHandler +from threading import Thread +import json +from .page import Error404, Redirect + + +class ApplicationServer(HTTPServer): + class RequestHandler(SimpleHTTPRequestHandler): + def do_GET(self): + try: + _, response = self.server.app.url(self.path) + except Error404: + self.send_response(404) + self.send_header( + 'Content-Type', 'application/x-json', + ) + self.end_headers() + return self.wfile.write(json.dumps({ + "ok": False, + "status": 404, + }).encode()) + else: + self.send_response(200) + self.send_header( + 'Content-Type', 'application/x-json', + ) + self.end_headers() + return self.wfile.write(json.dumps(response or { + "ok": True, + "status": 200, + }).encode()) + + + def __init__(self, app, address): + self.app = app + super().__init__(address, self.RequestHandler) + + def thread_serve(self): + self.thread = Thread( + target=self.serve_forever, + ) + self.thread.setDaemon(True) + self.thread.start() + # self.app.on_close(self.thread.kill) + return self.thread diff --git a/src/taktk/media.py b/src/taktk/media.py index 5831867..b458bee 100644 --- a/src/taktk/media.py +++ b/src/taktk/media.py @@ -31,7 +31,7 @@ def get_media(spec): spec, props = parse_media_spec(spec) assert ( n := spec.count(":") - ) == 1, f"media spec should include one ':', has: {n}" + ) >= 1, f"media spec should include one ':', has: {n}" match tuple(spec.split(":", 1)): case ("img", path): if path[0] == "@": @@ -41,6 +41,10 @@ def get_media(spec): case wrong: raise ValueError(f"Unrecognised media {spec!r}") +def get_image(spec): + if not spec.startswith('img:'): + spec = 'img:' + spec + return get_media(spec) class Resource: pass @@ -49,7 +53,7 @@ class Resource: class Image(Resource): @cached_property def image(self): - image = PIL.Image.open(self.path) + image = PIL.Image.open(self.full_path) iw, ih = image.size width, height = self.props.get("width"), self.props.get("height") if width == height == None: @@ -65,13 +69,13 @@ def tk(self): return PIL.ImageTk.PhotoImage(self.image) def get(self): - print(self, self.tk) return self.tk def __init__(self, path, props): if not "." in path: path += ".png" self.path = path + self.full_path = path self.props = props @@ -80,7 +84,7 @@ class MediaImage(Resource): def image(self): if MEDIA_DIR is None: raise RuntimeError("Media directory not set") - image = PIL.Image.open(MEDIA_DIR / "img" / self.path) + image = PIL.Image.open(self.full_path) iw, ih = image.size width, height = self.props.get("width"), self.props.get("height") if width == height == None: @@ -91,6 +95,10 @@ def image(self): height = width / iw * ih return image.resize((int(width), int(height))) + @property + def full_path(self): + return MEDIA_DIR / "img" / self.path + @cached_property def tk(self): return PIL.ImageTk.PhotoImage(self.image) diff --git a/src/taktk/notification.py b/src/taktk/notification.py index c963164..0121c77 100644 --- a/src/taktk/notification.py +++ b/src/taktk/notification.py @@ -9,6 +9,8 @@ from ttkbootstrap import * from ttkbootstrap.icons import * from ttkbootstrap.utility import * +from .media import get_image +from . import Nil DEFAULT_ICON_WIN32 = "\ue154" DEFAULT_ICON = "\u25f0" @@ -28,7 +30,7 @@ def __init__( duration=None, bootstyle="dark", alert=False, - icon=None, + icon=Nil, source=None, ): self.source = source @@ -37,11 +39,22 @@ def __init__( self.duration = duration self.bootstyle = bootstyle - if isinstance(icon, str): - image = Image.open(icon) + self.setup_icon(icon) + self.titlefont = None + + def setup_icon(self, icon=Nil): + image = None + if icon is Nil: + import taktk + if taktk.application is None: + return + image = taktk.application.icon.image + elif isinstance(icon, str): + image = get_image(icon).image + if image is not None: w, h = image.size sc = Notification.IMAGE_WIDTH / w - icon = ImageTk.PhotoImage(image.resize((int(w * sc), int(h * sc)))) + self.icon = ImageTk.PhotoImage(image.resize((int(w * sc), int(h * sc)))) else: try: sc = Notification.IMAGE_WIDTH / icon.width() @@ -49,10 +62,9 @@ def __init__( width=int(sc * icon.width()), height=int(sc * icon.height()), ) + self.icon = icon except Exception: pass - self.icon = icon - self.titlefont = None def show(self): self.root = window = Toplevel(overrideredirect=True, alpha=0.7) @@ -110,14 +122,6 @@ def _hide(self): @classmethod def add(cls, notification): - for x in range(len(cls._STACK)): - if ( - cls._STACK[x].source == notification.source - and notification.source is not None - ): - cls.remove(cls._STACK[x]) - cls._STACK.insert(x, notification) - return cls.position_widgets() marg = Notification.MARGIN width = Notification.WIDTH @@ -125,19 +129,30 @@ def add(cls, notification): height = notification.root.winfo_height() screen_height = notification.root.winfo_screenheight() - - while True: - taken = 0 - for notif in Notification._STACK: - taken += marg + notif.root.winfo_height() - - if screen_height - (taken + marg) < height: - Notification.remove_earliset() - continue - else: + for x in range(len(cls._STACK)): + if ( + cls._STACK[x].source == notification.source + and notification.source is not None + ): + px, py = cls._STACK[x].root.winfo_rootx(), cls._STACK[x].root.winfo_rooty() + cls.remove(cls._STACK[x]) + cls._STACK.insert(x, notification) + notification.root.geometry(f"{width}x{height}{px:+}{py:+}") + cls.position_widgets() break - cls._STACK.append(notification) - notification.root.geometry(f"{width}x{height}-{marg}-{taken+marg}") + else: + while True: + taken = 0 + for notif in Notification._STACK: + taken += marg + notif.root.winfo_height() + + if screen_height - (taken + marg) < height: + Notification.remove_earliset() + continue + else: + break + cls._STACK.append(notification) + notification.root.geometry(f"{width}x{height}-{marg}-{taken+marg}") @classmethod def remove_earliset(cls): diff --git a/src/taktk/page.py b/src/taktk/page.py index b2de06b..21f89ca 100644 --- a/src/taktk/page.py +++ b/src/taktk/page.py @@ -21,6 +21,7 @@ def __init__(self, parent, page, app, destroy_cache: int = 5): self.store = app.store self.destroy_cache = destroy_cache self.package = page + self.current_url = None def geometry(self): self.parent.columnconfigure(0, weight=1) @@ -28,13 +29,13 @@ def geometry(self): def url(self, url): target = url - while target is not None: + while True: try: result = self.exec_url(target) except Redirect as r: target = r.url else: - target = None + return result def view_component(self, component): if self.current_page is None: @@ -56,10 +57,16 @@ def destroy_later(self, widget, cache=[]): def back(self): if self.current_page > 0: self.focus_page(self.current_page - 1) + return True + else: + return False def forward(self): if self.current_page < len(self.history) - 1: self.focus_page(self.current_page + 1) + return True + else: + return False def focus_page(self, idx): page = self.history[idx] @@ -70,19 +77,44 @@ def focus_page(self, idx): self.current_page = idx def exec_url(self, cmd): - parsed = urlparse(cmd) - path = [self.package.__package__] + list(filter(bool, cmd.split("/"))) - args = {k: json.loads(v) for k, v in parse_qsl(parsed.query)} - return self(parsed.path, parsed.fragment, args) + if cmd.strip('/') == "!current": + return (None, { + "ok": True, + "url": self.current_url, + }) + elif cmd.strip('/') == "!back": + return (None, { + "ok": True, + "changed": self.back(), + "url": self.current_url, + }) + elif cmd.strip('/') == "!forward": + return (None, { + "ok": True, + "changed": self.forward(), + "url": self.current_url, + }) + else: + parsed = urlparse(cmd) + path = [self.package.__package__] + list(filter(bool, cmd.split("/"))) + args = {k: json.loads(v) for k, v in parse_qsl(parsed.query)} + handler = parsed.fragment + path = parsed.path + if ':' in path: + path, handler = path.rsplit(':', 1) + self.current_url = parsed.path + return self(path, handler, args) def __call__(self, module, handler, params={}, /, **kwparams): from .component import Component if isinstance(module, str): module, urlparams = self.import_module(module) - function = getattr(module, handler or "handle") - comp = None - http = {"ok": True, "error": None} + try: + function = getattr(module, handler or "handle") + except AttributeError: + raise Error404() + http = comp = None page = function( self.store, *urlparams, @@ -118,7 +150,12 @@ def import_module(self, path): except ImportError: continue else: - params.append(converter(package)) + try: + param = converter(package) + except: + raise Error404() + else: + params.append(param) break else: raise Error404(path) diff --git a/taktk.sublime-project b/taktk.sublime-project index 1d76d50..d3441d9 100644 --- a/taktk.sublime-project +++ b/taktk.sublime-project @@ -2,7 +2,10 @@ "folders": [ { "path": ".", - } + }, + { + "path": "C:\\Program Files\\Python313\\Lib" + }, ], "build_systems": [ {