Skip to content

Instantly share code, notes, and snippets.

@apple1417
Created May 17, 2021 03:50
Show Gist options
  • Save apple1417/7eab94b0207eae816de643220c744017 to your computer and use it in GitHub Desktop.
Save apple1417/7eab94b0207eae816de643220c744017 to your computer and use it in GitHub Desktop.
tkinter marquee
import tkinter as tk
from threading import Lock
from tkinter import font
from typing import ClassVar, Optional
from util import Animatable, set_callback_property
class Marquee(tk.Canvas, Animatable):
_text_id: Optional[int]
_draw_lock: Lock
_should_animate: bool
_initalized: bool
text: str = set_callback_property("_text", "_update_text")
text_font: font.Font = set_callback_property("_text_font", "_update_text")
text_fill: str = set_callback_property("_text_fill", "_update_text")
speed: float
L_MARGIN: ClassVar[int] = 2
def __init__(
self,
text: str,
text_font: font.Font,
text_fill: str,
speed: float,
) -> None:
self._initalized = False
super().__init__(bd=-2) # Supress the default border
self._text_id = None
self._draw_lock = Lock()
self.text = text
self.text_font = text_font
self.text_fill = text_fill
self.speed = speed
self._should_animate = False
self._initalized = True
self._update_text()
self.bind("<Configure>", self._handle_resize)
def _update_text(self, _old=None, _new=None) -> None:
if not self._initalized:
return
with self._draw_lock:
if self._text_id is not None:
self.delete(self._text_id)
self._text_id = self.create_text(
# Placeholder coords - we need to create the text first to get it's boundaries and check
# if to scroll or not
-1e6, -1e6,
fill=self.text_fill,
text=self.text,
font=self.text_font,
anchor=tk.W,
)
w = self.winfo_width()
y = (self.winfo_height() / 2) - 1
x_min, _, x_max, _ = self.bbox(self._text_id)
if x_max - x_min < w:
self._should_animate = False
self.coords(self._text_id, self.L_MARGIN, y)
else:
self._should_animate = True
self.coords(self._text_id, w, y)
def _handle_resize(self, event: tk.Event) -> None:
with self._draw_lock:
x_min, _, x_max, _ = self.bbox(self._text_id)
w = self.winfo_width()
x: float
if x_max - x_min < w:
self._should_animate = False
x = self.L_MARGIN
elif x_min > w:
self._should_animate = True
x = w
else:
self._should_animate = True
x = x_min
y = (event.height / 2) - 1
self.coords(self._text_id, x, y)
def tick(self, delta: float) -> None:
with self._draw_lock:
if not self._should_animate:
return
_, _, x_max, _ = self.bbox(self._text_id)
if x_max < self.L_MARGIN:
w = self.winfo_width()
h = self.winfo_height()
self.coords(self._text_id, w, (h / 2) - 1)
else:
self.move(self._text_id, -self.speed * delta, 0)
import tkinter as tk
from datetime import datetime, timezone
from tkinter import font
from typing import ClassVar, Optional
from marquee import Marquee
from util import Animatable
LONG_TEXT: str = "aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffffffffff gggggggggg"
SHORT_TEXT: str = "aaaaaaaaaa"
# TODO: config file for all these
DEFAULT_W: ClassVar[int] = 500
DEFAULT_H: ClassVar[int] = 27
DEFAULT_FONT_FAMILY: ClassVar[str] = "Open Sans"
DEFAULT_FONT_SIZE: ClassVar[int] = 15
DEFAULT_FONT_FILL: ClassVar[str] = "black" # "white"
DEFAULT_FPS: ClassVar[int] = 144
DEFAULT_SPEED: ClassVar[int] = 64
class App(tk.Tk):
label: Marquee
animated_elements: list[Animatable]
_last_animate: datetime
_after_id: Optional[str]
fps: int
def __init__(self, fps: int) -> None:
super().__init__()
self.fps = fps
self.minsize(10, 10)
self.geometry(f"{DEFAULT_W}x{DEFAULT_H}")
self.label = Marquee(
LONG_TEXT,
font.Font(
family=DEFAULT_FONT_FAMILY,
size=DEFAULT_FONT_SIZE,
),
DEFAULT_FONT_FILL,
DEFAULT_SPEED
)
self.label.pack(fill="both", expand=True)
self.animated_elements = [self.label]
self._last_animate = datetime.now(timezone.utc)
self._after_id = None
self.start_animation()
# temp
self.bind("r", self.test)
self.bind("t", self.test2)
def start_animation(self) -> None:
if self._after_id is None:
self._after_id = self.after(round(1000 / DEFAULT_FPS), self.animate)
def stop_animation(self) -> None:
if self._after_id is not None:
self.after_cancel(self._after_id)
self._after_id = None
def animate(self) -> None:
# TODO: adjust this based on the previous delta
# 144 fps actually runs at 142.9 because it always rounds the same
self._after_id = self.after(round(1000 / self.fps), self.animate)
now = datetime.now(timezone.utc)
delta = (now - self._last_animate).total_seconds()
for element in self.animated_elements:
element.tick(delta)
self._last_animate = now
def test(self, _) -> None:
self.label.text = LONG_TEXT
def test2(self, _) -> None:
self.label.text = SHORT_TEXT
App(DEFAULT_FPS).mainloop()
from abc import ABC, abstractmethod
from typing import TypeVar
_T = TypeVar("_T")
def set_callback_property(name: str, callback_name: str) -> property:
def getter(self) -> _T:
return getattr(self, name)
def setter(self, new_value: _T) -> None:
setattr(self, name, new_value)
getattr(self, callback_name)()
return property(getter, setter)
class Animatable(ABC):
@abstractmethod
def tick(self, delta: float) -> None:
"""
Argument called to animate each frame.
Args:
delta: The amount of ms since the last time this was called.
"""
raise NotImplementedError
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment