it is now split into files and there are many things

This commit is contained in:
trav
2026-01-27 21:57:08 -08:00
parent d4dbd79cc2
commit d33717825d
52 changed files with 2842 additions and 2337 deletions

View File

@ -3,6 +3,24 @@ kiosk.py is the main program here. It's the front-end to a physical kiosk with 2
the whole thing is graphical so it's hard for you to test so you'll need to ask the user to test.
you'll run into errors if you try `python -m py_compile kiosk.py && echo "Syntax OK"`
you gotta do `python3 -m py_compile
/home/trav/Documents/custodisco-kiosk/kiosk.py && echo "Syntax
OK"`
you gotta do `python3 -m py_compile /home/trav/Documents/custodisco-kiosk/kiosk.py && echo "Syntax OK"`
## QR Code Sizing
SSB message IDs are always 52 characters (format: `%<43-char-base64>=.sha256`).
Due to special characters (`%` and `.`), QR uses **byte mode encoding**.
With ERROR_CORRECT_H for 52 bytes:
- **Version 6** is required
- Module count = 4 × 6 + 17 = **41 modules**
- QR pixel size = **41 × scale**
| Scale | QR Size (px) | Notes |
|-------|--------------|-------|
| 3 | 123 | Small - fits in 650×360 canvas |
| 6 | 246 | Medium - fits in canvas |
| 8 | 328 | Large - max that fits in 360px height with margin |
| 9 | 369 | Too tall for 360px canvas |
Canvas dimensions: 650×360
Merged image: 675×375 (drawing pasted at y=8)

24
desktop-mode.sh Executable file
View File

@ -0,0 +1,24 @@
#!/bin/bash
# Switch to GNOME desktop mode
# Run with: sudo ./desktop-mode.sh
ACCOUNTS_FILE="/var/lib/AccountsService/users/trav"
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo: sudo $0"
exit 1
fi
# Stop the kiosk script service first
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user stop org.gnome.Kiosk.Script.service 2>/dev/null
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user mask org.gnome.Kiosk.Script.service 2>/dev/null
# Update AccountsService to use GNOME desktop
cat > "$ACCOUNTS_FILE" << EOF
[User]
Session=gnome-wayland
SystemAccount=false
EOF
echo "Switched to desktop mode."
echo "Run 'sudo systemctl restart gdm3' to apply, or log out and back in."

101
kiosk-commands.md Normal file
View File

@ -0,0 +1,101 @@
# Custodisco Kiosk - Systemd Services & Commands
## Services Overview
### 1. ssb-server.service (User Service)
- **Purpose**: Runs the Scuttlebutt server for decentralized social features
- **Location**: `~/.config/systemd/user/ssb-server.service`
- **Auto-starts**: Yes, via `WantedBy=default.target`
### 2. org.gnome.Kiosk.Script.service (GNOME User Service)
- **Purpose**: Runs the kiosk application via ~/.local/bin/gnome-kiosk-script
- **Location**: `/usr/lib/systemd/user/org.gnome.Kiosk.Script.service`
- **Auto-starts**: Only when session type is `gnome-kiosk-script-wayland`
### 3. gdm.service (System Service)
- **Purpose**: GNOME Display Manager - handles login and session management
- **Controls**: Which session type to start (kiosk vs desktop)
## SSH Commands
### Check Service Status
```bash
# SSB server status
systemctl --user status ssb-server.service
# Kiosk script status (only relevant in kiosk mode)
systemctl --user status org.gnome.Kiosk.Script.service
# GDM status
systemctl status gdm3
```
### Restart Services
```bash
# Restart SSB server
systemctl --user restart ssb-server.service
# Restart GDM (will log out user and restart display)
sudo systemctl restart gdm3
```
### Switch to Kiosk Mode
```bash
cd /home/trav/Documents/custodisco-kiosk
sudo ./kiosk-mode.sh
sudo systemctl restart gdm3
```
### Switch to Desktop Mode
```bash
cd /home/trav/Documents/custodisco-kiosk
sudo ./desktop-mode.sh
sudo systemctl restart gdm3
```
### View Logs
```bash
# SSB server logs
journalctl --user -u ssb-server.service -f
# Kiosk script logs
journalctl --user -u org.gnome.Kiosk.Script.service -f
```
## How Boot Works
1. System boots, starts `gdm.service`
2. GDM reads `/var/lib/AccountsService/users/trav` to get session type
3. If `Session=gnome-kiosk-script-wayland`, GDM starts kiosk session
4. Kiosk session starts `org.gnome.Kiosk.Script.service`
5. Service runs `/usr/bin/gnome-kiosk-script` which calls `~/.local/bin/gnome-kiosk-script`
6. That script activates venv and runs `python kiosk.py`
## File Locations
| File | Description |
|------|-------------|
| `~/.local/bin/gnome-kiosk-script` | Kiosk startup script |
| `~/Documents/custodisco-kiosk/kiosk-mode.sh` | Switch to kiosk mode |
| `~/Documents/custodisco-kiosk/desktop-mode.sh` | Switch to desktop mode |
| `~/.config/systemd/user/ssb-server.service` | SSB server service file |
| `/var/lib/AccountsService/users/trav` | AccountsService config (root owned) |
## Troubleshooting
### Kiosk not starting at boot
1. Check session type: `cat /var/lib/AccountsService/users/trav`
2. Should show `Session=gnome-kiosk-script-wayland`
3. If not, run: `sudo ./kiosk-mode.sh && sudo systemctl restart gdm3`
### SSB server not running
```bash
systemctl --user status ssb-server.service
systemctl --user restart ssb-server.service
journalctl --user -u ssb-server.service --no-pager -n 50
```
### Check if kiosk.py is running
```bash
ps aux | grep kiosk.py
```

23
kiosk-mode.sh Executable file
View File

@ -0,0 +1,23 @@
#!/bin/bash
# Switch to GNOME kiosk mode
# Run with: sudo ./kiosk-mode.sh
ACCOUNTS_FILE="/var/lib/AccountsService/users/trav"
if [ "$EUID" -ne 0 ]; then
echo "Please run with sudo: sudo $0"
exit 1
fi
# Unmask the kiosk script service
sudo -u trav XDG_RUNTIME_DIR=/run/user/$(id -u trav) systemctl --user unmask org.gnome.Kiosk.Script.service 2>/dev/null
# Update AccountsService to use kiosk session
cat > "$ACCOUNTS_FILE" << EOF
[User]
Session=gnome-kiosk-script-wayland
SystemAccount=false
EOF
echo "Switched to kiosk mode."
echo "Run 'sudo systemctl restart gdm3' to apply, or log out and back in."

2310
kiosk.py

File diff suppressed because it is too large Load Diff

6
kiosk/__init__.py Normal file
View File

@ -0,0 +1,6 @@
"""Custodisco Kiosk - A touchscreen kiosk for item tagging."""
from kiosk.app import Kiosk
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
__all__ = ['Kiosk', 'CONFIG', 'BG_COLOR', 'GlobalVars']

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

92
kiosk/app.py Normal file
View File

@ -0,0 +1,92 @@
"""Main Kiosk application controller."""
import subprocess
import tkinter as tk
from tkinter import font as tkfont, messagebox
from kiosk.config import CONFIG
from kiosk.state import GlobalVars
from kiosk.screens.home import Screen0
class Kiosk(tk.Tk):
"""Main kiosk application controller."""
def __init__(self, *args, **kwargs):
tk.Tk.__init__(self, *args, **kwargs)
self.frame = None
self.frames_history = []
self.geometry('1366x768')
self.attributes('-fullscreen', True)
self.config(cursor="crosshair")
self.QRX = None
self.QRY = None
self.QRscale = 3
# Initialize fonts
GlobalVars.BUTTON_FONT = tkfont.Font(size=24, family='Helvetica')
GlobalVars.TEXT_FONT = tkfont.Font(size=30, family='Georgia')
# Key blocking for kiosk mode (unless developer_mode is enabled)
self.keys_blocked = False
if not CONFIG.get("developer_mode", False):
self._check_network_and_block_keys()
self.switch_frame(Screen0)
def switch_frame(self, frame_class, keep_history=True):
if keep_history and self.frame:
self.frames_history.append(type(self.frame))
new_frame = frame_class(self)
if self.frame is not None:
self.frame.destroy()
self.frame = new_frame
self.frame.pack(fill="both", expand=True)
def add_home_button(self, frame):
# Create the "Start Over" button
home_button = tk.Button(frame, text="Start Over from the beginning", command=self.show_warning_dialog, bg='peach puff', width=24, font=GlobalVars.BUTTON_FONT)
home_button.place(x=0, y=0) # top-left corner
def show_warning_dialog(self):
result = messagebox.askokcancel("Warning", "If you start over, you will lose all information entered so far. Are you sure you want to start over?", icon='warning')
if result:
self.start_over()
def _has_network(self):
"""Check if we have network connectivity."""
try:
subprocess.run(
["ping", "-c", "1", "-W", "2", "8.8.8.8"],
capture_output=True, check=True
)
return True
except subprocess.CalledProcessError:
return False
def _block_keys(self):
"""Block Alt/Super keys to prevent exiting kiosk."""
if self.keys_blocked:
return
self.bind_all("<Alt_L>", lambda e: "break")
self.bind_all("<Alt_R>", lambda e: "break")
self.bind_all("<Super_L>", lambda e: "break")
self.bind_all("<Super_R>", lambda e: "break")
self.bind_all("<Alt-Tab>", lambda e: "break")
self.bind_all("<Alt-F4>", lambda e: "break")
self.keys_blocked = True
def _check_network_and_block_keys(self):
"""Check network; block keys if connected, otherwise retry."""
if self._has_network():
self._block_keys()
else:
self.after(5000, self._check_network_and_block_keys)
def start_over(self):
# Reset global variables
GlobalVars.qr_code_value = None
GlobalVars.print_type = "neither"
GlobalVars.selected_user = None
GlobalVars.ribbon_size = None
# Switch to the home screen
self.switch_frame(Screen0)

23
kiosk/config.py Normal file
View File

@ -0,0 +1,23 @@
"""Configuration loading and global settings."""
import json
import os
def load_config():
"""Load configuration from config.json."""
config_path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'config.json')
try:
with open(config_path) as f:
return json.load(f)
except FileNotFoundError:
print("Warning: config.json not found. Copy config.example.json to config.json and customize it.")
return {
"printers": {"sticker": "sticker_printer", "ribbon": "tag-printer"},
"camera": {"preferred_name": "NexiGo"},
"ribbon": {"width": 450, "margin": 50},
"background_color": "#bcfef9"
}
CONFIG = load_config()
BG_COLOR = CONFIG.get("background_color", "#bcfef9")

28
kiosk/screens/__init__.py Normal file
View File

@ -0,0 +1,28 @@
"""Screen components for the kiosk application."""
from .home import Screen0
from .intro import HaveYouGeneratedTagScreen, InfoPage1, InfoPage2, InfoPage3
from .ssb_selection import Screen1, ScreenWhyScuttlebutt, Screen2
from .camera import Screen3, Screen14
from .drawing import Screen4, Screen8
from .description import Screen5
from .confirmation import Screen6, Screen11, ScreenRibbonSize
from .print_flow import Screen13, PrintFailedScreen
from .completion import Screen10
from .lookup import Screen12
from .placeholder import Screen7, Screen9
from .no_qr_flow import NoQRRibbonSizeScreen, NoQRDrawingScreen, NoQRImportImageScreen, NoQRPrintScreen
__all__ = [
'Screen0',
'HaveYouGeneratedTagScreen', 'InfoPage1', 'InfoPage2', 'InfoPage3',
'Screen1', 'ScreenWhyScuttlebutt', 'Screen2',
'Screen3', 'Screen14',
'Screen4', 'Screen8',
'Screen5',
'Screen6', 'Screen11', 'ScreenRibbonSize',
'Screen13', 'PrintFailedScreen',
'Screen10',
'Screen12',
'Screen7', 'Screen9',
'NoQRRibbonSizeScreen', 'NoQRDrawingScreen', 'NoQRImportImageScreen', 'NoQRPrintScreen',
]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

223
kiosk/screens/camera.py Normal file
View File

@ -0,0 +1,223 @@
"""Camera screens (Screen3 for photo, Screen14 for QR lookup)."""
import tkinter as tk
from tkinter import messagebox
from PIL import Image, ImageTk
import cv2
import threading
import time
from qreader import QReader
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.utils import get_preferred_camera_index
from kiosk.widgets import RoundedLabel
class Screen3(tk.Frame):
"""Take photo of item."""
def __init__(self, master=None):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Create the "Start Over" button
home_button = tk.Button(text="Start Over", command=self.show_warning_dialog, bg='peach puff', font=GlobalVars.BUTTON_FONT)
home_button.place(x=0, y=0) # top-left corner
# Prefer the external NexiGo USB webcam when available
self.vid = cv2.VideoCapture(get_preferred_camera_index(CONFIG["camera"]["preferred_name"]))
self.is_capturing = True
self.freeze_frame = None
self.countdown_text = None
self.last_photo = None
# Video feed
self.canvas = tk.Canvas(self, width=self.vid.get(cv2.CAP_PROP_FRAME_WIDTH), height=self.vid.get(cv2.CAP_PROP_FRAME_HEIGHT))
self.canvas.pack(side="left")
# Info and button on the right
self.text_frame = tk.Frame(self, bg=BG_COLOR)
self.text_frame.pack(side="right", fill="both", expand=True)
self.button = tk.Button(self.text_frame, text="Take Photo", command=self.take_photo, height=3, width=37, bg='peach puff', font=GlobalVars.BUTTON_FONT)
self.button.pack(pady=10)
# Instructional text for photo
instruction_text = ("Now take a photo of your object. If you don't have it with you "
"you can draw a detailed drawing of it and take a picture of that. "
"Please be mindful of who/what you include in the photo: "
"THIS PHOTO IS POSTED ONLINE AND CANNOT BE DELETED")
self.instruction_label = RoundedLabel(self.text_frame, text=instruction_text, bg='white',
font=('Georgia', 18), wraplength=500, justify="center")
self.instruction_label.pack(pady=10)
self.done_button = tk.Button(self, text="Done", command=self.done, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT)
self.done_button.place(relx=0.9, rely=0.9, anchor='se')
self.update_image()
# Bind the destroy event to release resources
self.bind("<Destroy>", self.on_destroy)
def show_warning_dialog(self):
result = messagebox.askokcancel("Warning", "If you start over, you will lose all information entered so far. Are you sure you want to start over?", icon='warning')
if result:
self.start_over()
def start_over(self):
self.release_resources()
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
def update_image(self):
if self.is_capturing and self.vid.isOpened():
ret, frame = self.vid.read()
if ret:
self.cv2image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGBA)
self.img = Image.fromarray(self.cv2image)
self.imgtk = ImageTk.PhotoImage(image=self.img)
self.canvas.delete("all") # Clear previous image
self.canvas.create_image(0, 0, image=self.imgtk, anchor='nw')
elif self.last_photo:
self.canvas.delete("all")
self.canvas.create_image(0, 0, image=self.last_photo, anchor='nw')
if self.countdown_text:
self.canvas.create_text(self.canvas.winfo_width() // 2, self.canvas.winfo_height() // 2,
text=self.countdown_text, fill="white", font=("Georgia", 120))
self.after(10, self.update_image)
def take_photo(self):
self.is_capturing = True
self.countdown_text = None
countdown_thread = threading.Thread(target=self.countdown)
countdown_thread.start()
def countdown(self):
for i in range(3, 0, -1):
self.countdown_text = str(i)
time.sleep(1)
self.countdown_text = None
# Capture the current frame
ret, frame = self.vid.read()
if ret:
rgb_image = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_image = Image.fromarray(rgb_image)
pil_image.save('freeze_frame.jpg')
self.display_taken_photo()
self.is_capturing = False
def display_taken_photo(self):
image = Image.open('freeze_frame.jpg')
self.last_photo = ImageTk.PhotoImage(image)
self.canvas.delete("all") # Clear the canvas
self.canvas.create_image(0, 0, image=self.last_photo, anchor='nw')
def done(self):
self.release_resources()
from kiosk.screens.description import Screen5
self.master.switch_frame(Screen5)
def release_resources(self):
self.is_capturing = False
if self.vid.isOpened():
self.vid.release()
def on_destroy(self, event):
self.release_resources()
def __del__(self):
self.release_resources()
class Screen14(tk.Frame):
"""Lookup item - QR scanner."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg='white')
# Main container
self.main_container = tk.Frame(self, bg='white')
self.main_container.pack(fill='both', expand=True)
# Video feed frame (left side)
self.video_frame = tk.Frame(self.main_container, bg='white')
self.video_frame.pack(side='left', fill='both', expand=True, padx=20, pady=20)
# Instruction frame (right side)
self.instruction_frame = tk.Frame(self.main_container, bg='white')
self.instruction_frame.pack(side='right', fill='both', expand=True, padx=20, pady=20)
# Setup the video feed
self.video = tk.Label(self.video_frame, bg='white')
self.video.pack(fill='both', expand=True)
# Setup the instruction
self.instruction = tk.Label(self.instruction_frame,
text="Hold the QR code up to the camera",
font=("Georgia", 36),
bg='white', fg='black',
wraplength=500,
justify='center')
self.instruction.pack(fill='both', expand=True)
# Deferred import for navigation
def go_home():
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
# Cancel button
self.cancel_button = tk.Button(self.instruction_frame,
text="Cancel",
command=go_home,
height=3,
width=30,
bg='white', fg='black',
font=GlobalVars.BUTTON_FONT)
self.cancel_button.pack(pady=20)
self.qreader = QReader()
# Prefer the external NexiGo USB webcam when available
self.cap = cv2.VideoCapture(get_preferred_camera_index(CONFIG["camera"]["preferred_name"]))
self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
self.frame_count = 0
self.update_frame()
def update_frame(self):
ret, frame = self.cap.read()
self.frame_count += 1
if ret:
# Process every 3rd frame for QR detection
if self.frame_count % 3 == 0:
# Convert to grayscale for QR detection
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
decoded_text = self.qreader.detect_and_decode(image=gray)
if decoded_text:
if isinstance(decoded_text, tuple) and len(decoded_text) > 0:
qr_value = next((item for item in decoded_text if item is not None), None)
if qr_value:
GlobalVars.qr_code_value = qr_value
print(f"QR code scanned: {GlobalVars.qr_code_value}")
self.cap.release()
from kiosk.screens.lookup import Screen12
self.master.switch_frame(Screen12)
return
# Display the original frame
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = Image.fromarray(rgb_frame)
imgtk = ImageTk.PhotoImage(image=image)
self.video.imgtk = imgtk
self.video.configure(image=imgtk)
self.after(10, self.update_frame)
def destroy(self):
if self.cap.isOpened():
self.cap.release()
super().destroy()

126
kiosk/screens/completion.py Normal file
View File

@ -0,0 +1,126 @@
"""Completion screen (Screen10 - Thank you)."""
import tkinter as tk
import os
from PIL import Image, ImageTk
import cv2
import numpy as np
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen10(tk.Frame):
"""Thank you / completion screen."""
def __init__(self, master):
GlobalVars.selected_user = None # Reset the selected user
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Center content vertically and horizontally
container = tk.Frame(self, bg=BG_COLOR)
container.pack(expand=True)
RoundedLabel(container, text="Thank you!", bg='white', font=('Georgia', 48)).pack(pady=(0, 20))
# Video player frame
self.video_frame = tk.Frame(container, width=640, height=400, bg='black')
self.video_frame.pack(pady=20)
self.video_frame.pack_propagate(False)
# Video label for displaying frames
self.video_label = tk.Label(self.video_frame, bg='black')
self.video_label.pack(fill='both', expand=True)
# Determine which video to play
self.video_file = self._get_video_file()
self.cap = None
self.running = False
self.photo = None # Keep reference to prevent garbage collection
if self.video_file and os.path.exists(self.video_file):
self.cap = cv2.VideoCapture(self.video_file)
self.running = True
self._update_frame()
# Button container for side-by-side layout
button_frame = tk.Frame(container, bg=BG_COLOR)
button_frame.pack(pady=10)
tk.Button(button_frame, text="Done", command=self._cleanup_and_done,
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
tk.Button(button_frame, text="printer problems?", command=self._cleanup_and_failed,
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=10)
def _get_video_file(self):
"""Determine which video to play based on print type and config."""
base_path = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if GlobalVars.print_type == "sticker":
return os.path.join(base_path, "sticker.mp4")
elif GlobalVars.print_type == "ribbon":
has_cutter = CONFIG.get("ribbon", {}).get("has_cutter", False)
if has_cutter:
return os.path.join(base_path, "ribbon-no-cut.mp4")
else:
return os.path.join(base_path, "ribbon-cut.mp4")
return None
def _update_frame(self):
"""Update video frame using OpenCV."""
if not self.running or self.cap is None:
return
ret, frame = self.cap.read()
if not ret:
# Loop: reset to beginning
self.cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
ret, frame = self.cap.read()
if not ret:
return
# Convert BGR to RGB
frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
# Get original dimensions
h, w = frame.shape[:2]
target_w, target_h = 640, 400 # Larger video area
# Calculate scale to fit while preserving aspect ratio
scale = min(target_w / w, target_h / h)
new_w, new_h = int(w * scale), int(h * scale)
# Resize preserving aspect ratio
frame = cv2.resize(frame, (new_w, new_h))
# Create black background and center the frame (letterboxing)
canvas = np.zeros((target_h, target_w, 3), dtype=np.uint8)
x_offset = (target_w - new_w) // 2
y_offset = (target_h - new_h) // 2
canvas[y_offset:y_offset+new_h, x_offset:x_offset+new_w] = frame
frame = canvas
# Convert to PIL Image then to PhotoImage
img = Image.fromarray(frame)
self.photo = ImageTk.PhotoImage(image=img)
self.video_label.config(image=self.photo)
# Schedule next frame update (~30fps = 33ms)
if self.running:
self.after(33, self._update_frame)
def _cleanup_and_done(self):
"""Stop video and go to home screen."""
self.running = False
if self.cap:
self.cap.release()
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
def _cleanup_and_failed(self):
"""Stop video and go to print failed screen."""
self.running = False
if self.cap:
self.cap.release()
from kiosk.screens.print_flow import PrintFailedScreen
self.master.switch_frame(PrintFailedScreen)

View File

@ -0,0 +1,145 @@
"""Confirmation screens (Screen6, Screen11, ScreenRibbonSize)."""
import tkinter as tk
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen6(tk.Frame):
"""I understand screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
def go_to_screen3():
from kiosk.screens.camera import Screen3
master.switch_frame(Screen3)
tk.Button(self, text="I Understand", command=go_to_screen3, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
class Screen11(tk.Frame):
"""Sticker or tag selection screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Instructions
self.label = RoundedLabel(self, text="Which type of tag would you like to design?",
wraplength=400,
font=GlobalVars.TEXT_FONT,
bg='white')
self.label.pack(pady=50)
# Button functions
def select_ribbon():
GlobalVars.print_type = 'ribbon'
master.switch_frame(ScreenRibbonSize)
def select_sticker():
GlobalVars.print_type = 'sticker'
from kiosk.screens.drawing import Screen4
master.switch_frame(Screen4)
# Buttons
tk.Button(self, text="Sticker", command=select_sticker, height=4, width=39, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
tk.Button(self, text="Ribbon tag", command=select_ribbon, height=4, width=39, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
class ScreenRibbonSize(tk.Frame):
"""Select ribbon size screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Get ribbon config
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
self.printable_width = self.ribbon_width - 50 # 25px margin each side
# Title
title_label = RoundedLabel(self, text="Select your ribbon size:",
font=GlobalVars.TEXT_FONT, bg='white', padx=40, wraplength=0)
title_label.pack(pady=30)
# Container for the three size options
options_frame = tk.Frame(self, bg=BG_COLOR)
options_frame.pack(pady=20)
# Size options with multipliers
sizes = [
('Small', 0.5, 'Half-height drawing'),
('Medium', 1.0, 'Square drawing'),
('Large', 1.5, 'Tall drawing')
]
for size_name, multiplier, description in sizes:
self.create_size_option(options_frame, size_name, multiplier, description, master)
def create_size_option(self, parent, size_name, multiplier, description, master):
# Frame for each option
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
option_frame.pack(side='left', padx=20)
# Calculate dimensions for this size (based on printable width)
drawing_height = int(self.printable_width * multiplier)
qr_scale = self.printable_width // 21
qr_size = 21 * qr_scale
# Total height includes top/bottom margins
total_height = self.margin + drawing_height + qr_size + self.margin
# Create preview canvas (scaled down for display)
preview_scale = 0.3
preview_width = int(self.ribbon_width * preview_scale)
preview_height = int(total_height * preview_scale)
margin_preview = int(self.margin * preview_scale)
drawing_preview_height = int(drawing_height * preview_scale)
qr_preview_size = int(qr_size * preview_scale)
side_margin_preview = int(25 * preview_scale) # 25px side margins
canvas = tk.Canvas(option_frame, width=preview_width, height=preview_height,
bg='white', highlightthickness=2, highlightbackground='gray')
canvas.pack(pady=10)
# Draw top margin area
canvas.create_rectangle(0, 0, preview_width, margin_preview,
fill='#f5f5f5', outline='')
# Draw drawing area (with side margins indicated)
canvas.create_rectangle(side_margin_preview, margin_preview,
preview_width - side_margin_preview, margin_preview + drawing_preview_height,
fill='#e0e0e0', outline='black')
canvas.create_text(preview_width // 2, margin_preview + drawing_preview_height // 2,
text="Drawing", font=('Georgia', 10))
# Draw QR indicator (centered)
qr_x = (preview_width - qr_preview_size) // 2
qr_y = margin_preview + drawing_preview_height
canvas.create_rectangle(qr_x, qr_y,
qr_x + qr_preview_size, qr_y + qr_preview_size,
fill='#808080', outline='black')
canvas.create_text(preview_width // 2, qr_y + qr_preview_size // 2,
text="QR", font=('Georgia', 8), fill='white')
# Draw bottom margin area
canvas.create_rectangle(0, qr_y + qr_preview_size, preview_width, preview_height,
fill='#f5f5f5', outline='')
# Size name and dimensions button (show printable dimensions)
button_text = f"{size_name}\n{self.printable_width}×{drawing_height}px"
btn = tk.Button(option_frame, text=button_text,
command=lambda s=size_name.lower(): self.select_size(s, master),
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
btn.pack(pady=10)
# Description label (hidden)
# desc_label = tk.Label(option_frame, text=description,
# font=('Georgia', 12), bg='white')
# desc_label.pack()
def select_size(self, size, master):
GlobalVars.ribbon_size = size
from kiosk.screens.drawing import Screen8
master.switch_frame(Screen8)

View File

@ -0,0 +1,36 @@
"""Description entry screen (Screen5)."""
import tkinter as tk
from kiosk.config import BG_COLOR
from kiosk.state import GlobalVars
from kiosk.utils import sanitize_for_ssb
from kiosk.widgets import RoundedLabel
class Screen5(tk.Frame):
"""Typed description entry screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Adding the information label
self.info_label = RoundedLabel(self, text="Please enter a description of your item (maybe some stories of its history or why it's important to you or identifying features). You may can use Markdown formatting. Once posted this text is on the internet FOREVER.", font=GlobalVars.TEXT_FONT, wraplength=900, bg='white')
self.info_label.pack(pady=40)
# Adding the text entry field
self.info_entry = tk.Text(self, height=10, width=50, font=("Georgia", 16))
self.info_entry.pack(pady=10)
# Adding the done button
self.done_button = tk.Button(self, text="Done", command=self.save_info_and_switch, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT)
self.done_button.pack(pady=10)
# Setting the focus to the text entry field
self.info_entry.focus_set()
def save_info_and_switch(self):
GlobalVars.info_text = self.info_entry.get("1.0", "end-1c")
GlobalVars.info_text = sanitize_for_ssb(GlobalVars.info_text)
from kiosk.screens.confirmation import Screen11
self.master.switch_frame(Screen11)

271
kiosk/screens/drawing.py Normal file
View File

@ -0,0 +1,271 @@
"""Drawing screens (Screen4 for sticker, Screen8 for ribbon)."""
import tkinter as tk
from tkinter import Canvas, filedialog
from PIL import Image, ImageTk
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel, DrawingMixin
class Screen4(tk.Frame, DrawingMixin):
"""Draw a sticker."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Configure column minsizes
self.grid_columnconfigure(0, minsize=622)
self.grid_columnconfigure(1, minsize=100)
self.grid_columnconfigure(2, minsize=100)
# Creating a frame for the left side of the screen for drawing and instructions
self.left_frame = tk.Frame(self, bg=BG_COLOR)
self.left_frame.grid(row=0, column=0, padx=2, pady=(10, 30))
# Frame for the tools
self.right_frame = tk.Frame(self, bg=BG_COLOR)
self.right_frame.grid(row=0, column=2, padx=70)
# Add instructions
self.label = RoundedLabel(self.left_frame, text="You may now draw your sticker! Anything you draw inside the QR box will get covered by a QR code", wraplength=597, font=GlobalVars.TEXT_FONT, bg='white')
self.label.pack(pady=(20, 10))
# QR Size selection buttons
self.qr_size_frame = tk.Frame(self.left_frame, bg=BG_COLOR)
self.qr_size_frame.pack(pady=(5, 15))
tk.Label(self.qr_size_frame, text="QR Size:", font=GlobalVars.BUTTON_FONT, bg=BG_COLOR).pack(side='left', padx=5)
for name, label in [('small', 'Small'), ('medium', 'Medium'), ('large', 'Large')]:
tk.Button(self.qr_size_frame, text=label,
command=lambda n=name: self.set_qr_size(n),
height=1, width=8, bg='peach puff',
font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
# Creating the Canvas for drawing
self.canvas = Canvas(self.left_frame, width=597, height=360, bg='white')
self.canvas.bind("<B1-Motion>", self.draw_line)
self.image_on_canvas = None
self.canvas.pack(pady=(20, 40))
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
# Initialize drawing via mixin (grid_size=1 for stickers)
self.init_drawing(597, 360, grid_size=1)
self.draw_size = 3 # Override default size for stickers
self.imported_img = None
# QR size positions and defaults
# SSB message IDs = 52 bytes → Version 6 QR → 41 modules
# QR pixel size = 41 * scale. Positions calculated with ~20px margins.
# X positions shifted left by 53 to account for narrower canvas (scan-tag on right edge at merge)
self.qr_positions = {
'small': (454, 217, 3), # 123px QR
'medium': (331, 94, 6), # 246px QR
'large': (249, 12, 8) # 328px QR
}
self.qr_x, self.qr_y, self.qr_scale = self.qr_positions['small']
self.master.QRX = self.qr_x
self.master.QRY = self.qr_y
self.master.QRscale = self.qr_scale
self.add_qr_box()
# Create frames for pen size and color tools
self.pen_size_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
self.pen_size_frame.pack(pady=(0, 20))
self.pen_color_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
self.pen_color_frame.pack(pady=(0, 20))
# Pen size label
tk.Label(self.pen_size_frame, text="Pen Size", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).pack()
# Add Draw Size buttons
pen_sizes = [(".", 1), ("*", 2), ("", 3), ("", 4), ("", 5)]
for i, (text, size) in enumerate(pen_sizes):
tk.Button(self.pen_size_frame, text=text, command=lambda s=size: self.set_draw_size(s),
height=2, width=5, bg='peach puff').pack(pady=2)
# Pen color label
tk.Label(self.pen_color_frame, text="Pen Color", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).pack()
# Creating color buttons
colors = ['black', 'gray', 'white']
for color in colors:
tk.Button(self.pen_color_frame, height=2, width=5, bg=color,
command=lambda c=color: self.set_draw_color(c)).pack(pady=2)
# Add Clear Drawing Button
tk.Button(self.right_frame, text="Clear Drawing", command=self.clear_drawing,
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
# Frame for Done and Import Image buttons (horizontal row)
self.action_buttons_frame = tk.Frame(self.right_frame, bg=BG_COLOR)
self.action_buttons_frame.pack(pady=10)
# Done button (left)
tk.Button(self.action_buttons_frame, text="Done", command=self.next,
height=2, width=8, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
# Import Image Button (right)
tk.Button(self.action_buttons_frame, text="Import", command=self.import_image,
height=2, width=8, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=5)
# Adding a home button
master.add_home_button(self)
# Define the info_label to display QRX, QRY, and QRscale values
self.info_label = tk.Label(self.right_frame, text="", bg=BG_COLOR, font=GlobalVars.TEXT_FONT)
self.info_label.pack(pady=5)
def clear_drawing(self):
# Override to add QR box after clearing
DrawingMixin.clear_drawing(self)
self.imported_img = None
self.add_qr_box()
def add_qr_box(self):
"""Draw QR placeholder box using current size settings."""
qr_size = 41 * self.qr_scale # 41 modules for Version 6 QR (52-byte SSB message ID)
# Draw QR placeholder
self.canvas.create_rectangle(self.qr_x, self.qr_y,
self.qr_x + qr_size, self.qr_y + qr_size,
outline='black', fill='white', tags='qr_box')
self.canvas.create_text(self.qr_x + qr_size/2, self.qr_y + qr_size/2,
text="QR", fill="black", tags='qr_box')
def set_qr_size(self, size_name):
"""Update QR size and redraw preview."""
self.qr_x, self.qr_y, self.qr_scale = self.qr_positions[size_name]
self.master.QRX = self.qr_x
self.master.QRY = self.qr_y
self.master.QRscale = self.qr_scale
# Just delete and redraw the QR box, not the whole canvas
self.canvas.delete('qr_box')
self.add_qr_box()
def next(self):
self.drawing.save("drawing.png")
from kiosk.screens.print_flow import Screen13
self.master.switch_frame(Screen13)
def import_image(self):
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.jpeg *.png")])
if not file_path:
return
self.canvas.delete("all")
img = Image.open(file_path)
img = img.convert('1')
if img.size[0] > 597 or img.size[1] > 360:
img = img.crop((0, 0, 597, 360))
self.drawing.paste(img)
self.imported_img = ImageTk.PhotoImage(img)
self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
self.add_qr_box()
self.info_label.config(text="") # Clear any old info
class Screen8(tk.Frame, DrawingMixin):
"""Draw a ribbon tag."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Get ribbon width from config and calculate dimensions based on selected size
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
self.printable_width = self.ribbon_width - 50 # 25px margin each side
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
width = self.printable_width
height = int(self.printable_width * multiplier)
# Adaptive display scaling based on size
# Target max display height ~700px to fit comfortably on screen
max_display_height = 700
ideal_scale = max_display_height / height
scale_factor = min(2, max(1, int(ideal_scale))) # Clamp between 1x and 2x
# Display dimensions
display_width = width * scale_factor
display_height = height * scale_factor
# Main container to hold all columns
main_container = tk.Frame(self, bg=BG_COLOR)
main_container.place(relx=0.5, rely=0.5, anchor='center')
# Column 1: Start Over button and instruction text
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
col1_frame.pack(side='left', padx=(15, 25), anchor='n', pady=20)
# Start Over button (replaces home button) - Screen8
tk.Button(col1_frame, text="Start Over", command=master.show_warning_dialog,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 20))
# Instruction label
self.label = RoundedLabel(col1_frame, text="Draw your\nribbon :)",
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
self.label.pack(pady=5)
# Column 2: Drawing canvas
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
col2_frame.pack(side='left', padx=(10, 30))
# Canvas for drawing
self.canvas = Canvas(col2_frame, width=display_width, height=display_height, bg='white')
self.canvas.bind("<B1-Motion>", self.draw_line)
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
self.canvas.pack()
# Initialize drawing via mixin
self.init_drawing(width, height, grid_size=scale_factor)
# Right panel: Pen tools (grid layout) + action buttons
right_panel = tk.Frame(main_container, bg=BG_COLOR)
right_panel.pack(side='left', padx=15)
# Pen tools grid (labels align with buttons)
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
pen_grid.pack()
# Pen Size label - spans vertically alongside pen size buttons
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
# Pen size buttons (5 buttons in grid column 1)
pen_sizes = [(".", 1), ("*", 2), ("", 3), ("", 4), ("", 5)]
for i, (text, size) in enumerate(pen_sizes):
tk.Button(pen_grid, text=text, command=lambda s=size: self.set_draw_size(s),
height=2, width=5, bg='peach puff').grid(row=i, column=1, pady=2)
# Pen Color label - spans vertically alongside color buttons
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
# Pen color buttons (3 buttons in grid column 1)
colors = ['black', 'gray', 'white']
for i, color in enumerate(colors):
tk.Button(pen_grid, height=2, width=5, bg=color,
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
# Action buttons below the grid
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
action_frame.pack(pady=(20, 0))
# Import Image Button
tk.Button(action_frame, text="Import Image", command=self.import_image,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
# Clear Drawing Button
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
# Done button
tk.Button(action_frame, text="Done", command=self.next,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
def import_image(self):
self.import_image_base()
def next(self):
self.drawing.save("drawing.png")
from kiosk.screens.print_flow import Screen13
self.master.switch_frame(Screen13)

67
kiosk/screens/home.py Normal file
View File

@ -0,0 +1,67 @@
"""Home screen (Screen0)."""
import tkinter as tk
from tkinter import font as tkfont
from PIL import Image, ImageTk
import os
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen0(tk.Frame):
"""Home screen with main navigation options."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
title_font = tkfont.Font(size=42, family='Helvetica') # 30% bigger
# Split the screen into two frames
left_frame = tk.Frame(self, bg=BG_COLOR)
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40) # 40px padding on the right side
left_frame.grid(row=0, column=0, sticky='nsew')
right_frame.grid(row=0, column=1, sticky='nsew')
self.grid_columnconfigure(0, weight=1, minsize=800) # For left frame
self.grid_columnconfigure(1, weight=1) # For right frame
# Title and buttons on the left side - using custo.png image (scaled to 1/4)
logo_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'custo.png')
logo_image = Image.open(logo_path)
new_size = (logo_image.width // 4, logo_image.height // 4)
logo_image = logo_image.resize(new_size, Image.LANCZOS)
self.logo_photo = ImageTk.PhotoImage(logo_image)
title_label = tk.Label(left_frame, image=self.logo_photo, bg=BG_COLOR)
title_label.pack(side='top', pady=50)
# Info text below logo
info_label = RoundedLabel(left_frame, text="this is a touchscreen kiosk\n\n more information on the project at\n\nwww.cust.ooo",
bg='white', font=GlobalVars.TEXT_FONT, wraplength=650, justify='center')
info_label.pack(side='top', pady=20)
# Deferred imports to avoid circular dependencies
def go_to_generate_tag():
from kiosk.screens.intro import HaveYouGeneratedTagScreen
master.switch_frame(HaveYouGeneratedTagScreen)
def go_to_lookup():
from kiosk.screens.camera import Screen14
master.switch_frame(Screen14)
def go_to_no_qr():
from kiosk.screens.no_qr_flow import NoQRRibbonSizeScreen
master.switch_frame(NoQRRibbonSizeScreen)
tk.Button(right_frame, text="Generate Tag", command=go_to_generate_tag, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
tk.Button(right_frame, text="Lookup Item", command=go_to_lookup, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
# No QR Ribbon button (only show if enabled in config)
if CONFIG.get("show_no_qr_ribbon", 0) == 1:
tk.Button(right_frame, text="No QR Ribbon", command=go_to_no_qr, height=4, width=75, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='top', pady=30)
# Create the quit button (only show in developer mode)
if CONFIG.get("developer_mode", False):
tk.Button(right_frame, text="Quit", command=self.quit_program, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)
def quit_program(self):
self.master.destroy()

172
kiosk/screens/intro.py Normal file
View File

@ -0,0 +1,172 @@
"""Introduction screens (HaveYouGeneratedTagScreen, InfoPage1-3)."""
import tkinter as tk
from PIL import Image, ImageTk
import os
from kiosk.config import BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class HaveYouGeneratedTagScreen(tk.Frame):
"""Have you generated a tag before?"""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Center container
container = tk.Frame(self, bg=BG_COLOR)
container.place(relx=0.5, rely=0.5, anchor='center')
# Question label
label = RoundedLabel(container, text="have you made a tag on this kiosk before?",
font=GlobalVars.TEXT_FONT, bg='white', padx=20, pady=10, wraplength=0)
label.pack(pady=50)
# Deferred imports
def go_to_screen1():
from kiosk.screens.ssb_selection import Screen1
master.switch_frame(Screen1)
def go_to_info1():
master.switch_frame(InfoPage1)
# Buttons
tk.Button(container, text="yea", command=go_to_screen1,
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
tk.Button(container, text="not yet!", command=go_to_info1,
height=3, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
class InfoPage1(tk.Frame):
"""Info Page 1 - Custo introduction with tags.jpg"""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Split the screen into two frames
left_frame = tk.Frame(self, bg=BG_COLOR)
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40)
left_frame.grid(row=0, column=0, sticky='nsew')
right_frame.grid(row=0, column=1, sticky='nsew')
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# Left side: tags.jpg image
image_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'tags.jpg')
img = Image.open(image_path)
# Scale to fit nicely (max 300px height)
max_height = 400
if img.height > max_height:
ratio = max_height / img.height
new_size = (int(img.width * ratio), max_height)
img = img.resize(new_size, Image.LANCZOS)
self.photo = ImageTk.PhotoImage(img)
img_label = tk.Label(left_frame, image=self.photo, bg=BG_COLOR)
img_label.pack(expand=True)
# Right side: Custo introduction text
intro_text = """This kiosk is called Custo (pronounced KOOSTO)
It is here to foster and document the relationships between people and things
It can print stickers and ribbons to affix to an item you care about."""
text_label = RoundedLabel(right_frame, text=intro_text,
font=GlobalVars.TEXT_FONT, bg='white',
wraplength=500, justify='left', padx=20, pady=20)
text_label.pack(expand=True)
# Bottom: Forward button only
nav_frame = tk.Frame(self, bg=BG_COLOR)
nav_frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=20)
tk.Button(nav_frame, text="Forward →", command=lambda: master.switch_frame(InfoPage2),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='right', padx=40)
class InfoPage2(tk.Frame):
"""Info Page 2 - Take care of each other with hard.jpg"""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Split the screen into two frames
left_frame = tk.Frame(self, bg=BG_COLOR)
right_frame = tk.Frame(self, bg=BG_COLOR, padx=40)
left_frame.grid(row=0, column=0, sticky='nsew')
right_frame.grid(row=0, column=1, sticky='nsew')
self.grid_columnconfigure(0, weight=1)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# Left side: hard.jpg image
image_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'hard.jpg')
img = Image.open(image_path)
# Scale to fit nicely (max 300px height)
max_height = 500
if img.height > max_height:
ratio = max_height / img.height
new_size = (int(img.width * ratio), max_height)
img = img.resize(new_size, Image.LANCZOS)
self.photo = ImageTk.PhotoImage(img)
img_label = tk.Label(left_frame, image=self.photo, bg=BG_COLOR)
img_label.pack(expand=True)
# Right side: Take care of each other text
care_text = """Items can live long lives! Let's take care of each other while we're alive.
by tagging an item you are saying to it, 'you matter, I will take care of you'"""
text_label = RoundedLabel(right_frame, text=care_text,
font=GlobalVars.TEXT_FONT, bg='white',
wraplength=500, justify='left', padx=20, pady=20)
text_label.pack(expand=True)
# Bottom: Back and Forward buttons
nav_frame = tk.Frame(self, bg=BG_COLOR)
nav_frame.grid(row=1, column=0, columnspan=2, sticky='ew', pady=20)
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage1),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
tk.Button(nav_frame, text="Forward →", command=lambda: master.switch_frame(InfoPage3),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='right', padx=40)
class InfoPage3(tk.Frame):
"""Info Page 3 - Mindful use, permanent storage, no shitposting"""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Center container for text
container = tk.Frame(self, bg=BG_COLOR)
container.place(relx=0.5, rely=0.4, anchor='center')
# Mindful use text
mindful_text = """Please be mindful while operating the kiosk.
All generated items are stored permanently and replicated across the Scuttlebutt network. Once published, it cannot be deleted.
Please do not create imaginary items or shitpost.
The QR codes contain a Scuttlebutt messageID, scanning it works best at www.cust.ooo or any Scuttlebutt client.
"""
text_label = RoundedLabel(container, text=mindful_text,
font=GlobalVars.TEXT_FONT, bg='white',
wraplength=700, justify='center', padx=30, pady=30)
text_label.pack()
# Deferred import
def go_to_screen1():
from kiosk.screens.ssb_selection import Screen1
master.switch_frame(Screen1)
# Bottom: Back and Forward buttons
nav_frame = tk.Frame(self, bg=BG_COLOR)
nav_frame.place(relx=0.5, rely=0.9, anchor='center')
tk.Button(nav_frame, text="← Back", command=lambda: master.switch_frame(InfoPage2),
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='left', padx=40)
tk.Button(nav_frame, text="Forward →", command=go_to_screen1,
height=2, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side='right', padx=40)

139
kiosk/screens/lookup.py Normal file
View File

@ -0,0 +1,139 @@
"""Lookup screen (Screen12 - after QR scanned)."""
import tkinter as tk
from tkinter import ttk
import re
import os
import json
from PIL import Image, ImageTk
import addtoDB
from kiosk.config import BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen12(tk.Frame):
"""Display content after QR scanned for lookup."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
print(f"Initializing Screen12 with QR code value: {GlobalVars.qr_code_value}")
if GlobalVars.qr_code_value is None:
self.display_error("Error: QR code value is None")
return
try:
qr_code_str = GlobalVars.qr_code_value if isinstance(GlobalVars.qr_code_value, str) else GlobalVars.qr_code_value.decode('utf-8')
print(f"Attempting to get message content for: {qr_code_str}")
message_content, image_path, replies = addtoDB.get_message_content(qr_code_str)
print(f"Received message content: {message_content}")
print(f"Received image path: {image_path}")
print(f"Received replies: {replies}")
if message_content is None:
self.display_error(f"Error: Failed to retrieve message content for {qr_code_str}")
else:
self.display_content(message_content, image_path, replies)
except Exception as e:
self.display_error(f"An error occurred while processing message: {str(e)}")
def display_error(self, message):
print(f"Displaying error: {message}")
error_frame = tk.Frame(self, bg=BG_COLOR)
error_frame.pack(expand=True, fill='both', padx=20, pady=20)
RoundedLabel(error_frame, text=message, bg='white', font=GlobalVars.TEXT_FONT, wraplength=500).pack(pady=50)
# Add "Go Back" button for error cases
def go_home():
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
tk.Button(error_frame, text="Go Back", command=go_home,
height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
def get_alias(self, user_id):
try:
with open('users.json') as f:
users = json.load(f)
for user in users:
if user['id'] == user_id:
return user.get('alias', '')
except FileNotFoundError:
return ''
return ''
def display_content(self, message_content, image_path, replies):
# Main content frame
content_frame = tk.Frame(self, bg=BG_COLOR)
content_frame.pack(expand=True, fill='both', padx=20, pady=10)
# Left column: Image
left_column = tk.Frame(content_frame, bg=BG_COLOR)
left_column.pack(side='left', fill='both', expand=False)
if image_path and os.path.exists(image_path):
try:
img = Image.open(image_path)
img.thumbnail((550, 550)) # Slightly smaller than before
photo = ImageTk.PhotoImage(img)
img_label = tk.Label(left_column, image=photo, bg=BG_COLOR)
img_label.image = photo # Keep a reference
img_label.pack(expand=True, fill='both')
print(f"Displayed image: {image_path}")
except Exception as e:
print(f"Error displaying image {image_path}: {e}")
elif image_path:
print(f"Image file not found: {image_path}")
else:
print("No image path provided")
# Right column: Scrollable text
right_column = tk.Frame(content_frame, bg=BG_COLOR)
right_column.pack(side='right', fill='both', padx=10, expand=True)
canvas = tk.Canvas(right_column, bg=BG_COLOR)
scrollbar = ttk.Scrollbar(right_column, orient="vertical", command=canvas.yview)
scrollable_frame = tk.Frame(canvas, bg=BG_COLOR)
scrollable_frame.bind(
"<Configure>",
lambda e: canvas.configure(scrollregion=canvas.bbox("all"))
)
canvas.create_window((0, 0), window=scrollable_frame, anchor="nw")
canvas.configure(yscrollcommand=scrollbar.set)
# Display original message
text_content = message_content.get('content', {}).get('text', '')
# Remove markdown image syntax
text_content = re.sub(r'!\[.*?\]\(.*?\)', '', text_content).strip()
tk.Label(scrollable_frame, text=text_content, wraplength=650, justify='left', bg='white', font=("Georgia", 28)).pack(pady=5, padx=(5, 0))
# Display replies
if replies:
tk.Label(scrollable_frame, text="Replies:", wraplength=650, justify='left', bg='white', font=("Georgia", 24, "bold")).pack(pady=(20, 5), padx=(5, 0))
for reply in replies:
author_id = reply.get('value', {}).get('author', 'Unknown')
author_alias = self.get_alias(author_id)
author_display = f"{author_alias} ({author_id})" if author_alias else author_id
reply_text = reply.get('value', {}).get('content', {}).get('text', '')
tk.Label(scrollable_frame, text=f"{author_display}:", wraplength=650, justify='left', bg='white', font=("Georgia", 20, "bold")).pack(pady=(10, 0), padx=(5, 0))
tk.Label(scrollable_frame, text=reply_text, wraplength=650, justify='left', bg='white', font=("Georgia", 18)).pack(pady=(0, 10), padx=(5, 0))
canvas.pack(side="left", fill="both", expand=True)
scrollbar.pack(side="right", fill="y")
# Configure the scrollbar style to make it larger
style = ttk.Style()
style.configure("Vertical.TScrollbar", arrowsize=48, width=48)
# Add "Done" button for successful content display
def go_home():
from kiosk.screens.home import Screen0
self.master.switch_frame(Screen0)
tk.Button(self, text="Done", command=go_home,
height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(side="bottom", pady=20)

426
kiosk/screens/no_qr_flow.py Normal file
View File

@ -0,0 +1,426 @@
"""No QR ribbon flow screens."""
import tkinter as tk
from tkinter import Canvas, filedialog
import subprocess
import traceback
from PIL import Image, ImageTk
import tozpl
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel, DrawingMixin
class NoQRRibbonSizeScreen(tk.Frame):
"""No QR Ribbon - Size Selection."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Get ribbon config
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
self.printable_width = self.ribbon_width - 50 # 25px margin each side
# Title
title_label = RoundedLabel(self, text="Select your ribbon size:",
font=GlobalVars.TEXT_FONT, bg='white')
title_label.pack(pady=30)
# Container for size options
options_frame = tk.Frame(self, bg=BG_COLOR)
options_frame.pack(pady=20)
# Size options with multipliers
sizes = [
('Small', 0.5, 'Half-height drawing'),
('Medium', 1.0, 'Square drawing'),
('Large', 1.5, 'Tall drawing')
]
for size_name, multiplier, description in sizes:
self.create_size_option(options_frame, size_name, multiplier, description, master)
# Add Import Image option
self.create_import_option(options_frame, master)
def create_import_option(self, parent, master):
"""Create the Import Image option column"""
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
option_frame.pack(side='left', padx=20)
# Create preview canvas (show a simplified "variable height" preview)
preview_scale = 0.3
preview_width = int(self.ribbon_width * preview_scale)
# Use medium size for base preview height
base_height = int((self.margin + self.printable_width + self.margin) * preview_scale)
margin_preview = int(self.margin * preview_scale)
side_margin_preview = int(25 * preview_scale)
canvas = tk.Canvas(option_frame, width=preview_width, height=base_height,
bg='white', highlightthickness=2, highlightbackground='gray')
canvas.pack(pady=10)
# Draw top margin
canvas.create_rectangle(0, 0, preview_width, margin_preview,
fill='#f5f5f5', outline='')
# Draw image area (dashed to indicate variable)
drawing_height = base_height - 2 * margin_preview
canvas.create_rectangle(side_margin_preview, margin_preview,
preview_width - side_margin_preview, margin_preview + drawing_height,
fill='#d0e8d0', outline='black', dash=(4, 2))
canvas.create_text(preview_width // 2, margin_preview + drawing_height // 2,
text="Your\nImage", font=('Georgia', 10))
# Draw bottom margin
canvas.create_rectangle(0, margin_preview + drawing_height, preview_width, base_height,
fill='#f5f5f5', outline='')
# Import button
button_text = f"Import Image\n{self.printable_width}px wide"
btn = tk.Button(option_frame, text=button_text,
command=lambda: master.switch_frame(NoQRImportImageScreen),
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
btn.pack(pady=10)
# Description
desc_label = tk.Label(option_frame, text="Variable height",
font=('Georgia', 12), bg='white')
desc_label.pack()
def create_size_option(self, parent, size_name, multiplier, description, master):
# Frame for each option
option_frame = tk.Frame(parent, bg=BG_COLOR, padx=20)
option_frame.pack(side='left', padx=20)
# Calculate dimensions for this size (based on printable width)
drawing_height = int(self.printable_width * multiplier)
# Total height includes top/bottom margins (no QR)
total_height = self.margin + drawing_height + self.margin
# Create preview canvas (scaled down for display)
preview_scale = 0.3
preview_width = int(self.ribbon_width * preview_scale)
preview_height = int(total_height * preview_scale)
margin_preview = int(self.margin * preview_scale)
drawing_preview_height = int(drawing_height * preview_scale)
side_margin_preview = int(25 * preview_scale) # 25px side margins
canvas = tk.Canvas(option_frame, width=preview_width, height=preview_height,
bg='white', highlightthickness=2, highlightbackground='gray')
canvas.pack(pady=10)
# Draw top margin area
canvas.create_rectangle(0, 0, preview_width, margin_preview,
fill='#f5f5f5', outline='')
# Draw drawing area (with side margins indicated)
canvas.create_rectangle(side_margin_preview, margin_preview,
preview_width - side_margin_preview, margin_preview + drawing_preview_height,
fill='#e0e0e0', outline='black')
canvas.create_text(preview_width // 2, margin_preview + drawing_preview_height // 2,
text="Drawing", font=('Georgia', 10))
# Draw bottom margin area
canvas.create_rectangle(0, margin_preview + drawing_preview_height, preview_width, preview_height,
fill='#f5f5f5', outline='')
# Size name and dimensions button (show printable dimensions)
button_text = f"{size_name}\n{self.printable_width}×{drawing_height}px"
btn = tk.Button(option_frame, text=button_text,
command=lambda s=size_name.lower(): self.select_size(s, master),
height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT)
btn.pack(pady=10)
# Description label
desc_label = tk.Label(option_frame, text=description,
font=('Georgia', 12), bg='white')
desc_label.pack()
def select_size(self, size, master):
GlobalVars.ribbon_size = size
master.switch_frame(NoQRDrawingScreen)
class NoQRDrawingScreen(tk.Frame, DrawingMixin):
"""No QR Ribbon - Drawing Screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Get ribbon width from config and calculate dimensions based on selected size
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
printable_width = ribbon_width - 50 # 25px margin each side
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
width = printable_width
height = int(printable_width * multiplier)
# Adaptive display scaling based on size
max_display_height = 700
ideal_scale = max_display_height / height
scale_factor = min(2, max(1, int(ideal_scale)))
# Display dimensions
display_width = width * scale_factor
display_height = height * scale_factor
# Main container to hold all columns
main_container = tk.Frame(self, bg=BG_COLOR)
main_container.place(relx=0.5, rely=0.5, anchor='center')
# Column 1: Start Over button and instruction text
col1_frame = tk.Frame(main_container, bg=BG_COLOR)
col1_frame.pack(side='left', padx=(15, 25), anchor='n', pady=20)
# Start Over button - NoQRDrawingScreen
tk.Button(col1_frame, text="Start Over", command=master.show_warning_dialog,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=(0, 20))
# Instruction label
self.label = RoundedLabel(col1_frame, text="Draw your\nribbon :)",
wraplength=150, font=GlobalVars.TEXT_FONT, bg='white')
self.label.pack(pady=5)
# Column 2: Drawing canvas
col2_frame = tk.Frame(main_container, bg=BG_COLOR)
col2_frame.pack(side='left', padx=(10, 30))
# Canvas for drawing
self.canvas = Canvas(col2_frame, width=display_width, height=display_height, bg='white')
self.canvas.bind("<B1-Motion>", self.draw_line)
self.canvas.bind("<ButtonRelease-1>", self.reset_last_draw)
self.canvas.pack()
# Initialize drawing via mixin
self.init_drawing(width, height, grid_size=scale_factor)
# Right panel: Pen tools + action buttons
right_panel = tk.Frame(main_container, bg=BG_COLOR)
right_panel.pack(side='left', padx=15)
# Pen tools grid
pen_grid = tk.Frame(right_panel, bg=BG_COLOR)
pen_grid.pack()
# Pen Size label
tk.Label(pen_grid, text="Pen\nSize", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
row=0, column=0, rowspan=5, padx=(0, 10), sticky='n', pady=(5, 0))
# Pen size buttons
pen_sizes = [(".", 1), ("*", 2), ("", 3), ("", 4), ("", 5)]
for i, (text, size) in enumerate(pen_sizes):
tk.Button(pen_grid, text=text, command=lambda s=size: self.set_draw_size(s),
height=2, width=5, bg='peach puff').grid(row=i, column=1, pady=2)
# Pen Color label
tk.Label(pen_grid, text="Pen\nColor", font=GlobalVars.TEXT_FONT, bg=BG_COLOR).grid(
row=6, column=0, rowspan=3, padx=(0, 10), sticky='n', pady=(15, 0))
# Pen color buttons
colors = ['black', 'gray', 'white']
for i, color in enumerate(colors):
tk.Button(pen_grid, height=2, width=5, bg=color,
command=lambda c=color: self.set_draw_color(c)).grid(row=6+i, column=1, pady=2)
# Action buttons
action_frame = tk.Frame(right_panel, bg=BG_COLOR)
action_frame.pack(pady=(20, 0))
# Import Image Button
tk.Button(action_frame, text="Import Image", command=self.import_image,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
# Clear Drawing Button
tk.Button(action_frame, text="Clear Drawing", command=self.clear_drawing,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
# Print button (goes directly to print)
tk.Button(action_frame, text="Print", command=self.go_to_print,
height=2, width=14, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
def import_image(self):
self.import_image_base()
def go_to_print(self):
self.drawing.save("drawing.png")
self.master.switch_frame(NoQRPrintScreen)
class NoQRImportImageScreen(tk.Frame):
"""No QR Ribbon - Import Image Screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Get ribbon config
self.ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
self.margin = CONFIG.get("ribbon", {}).get("margin", 50)
self.printable_width = self.ribbon_width - 50 # 25px margin each side
# Main container
self.container = tk.Frame(self, bg=BG_COLOR)
self.container.place(relx=0.5, rely=0.5, anchor='center')
# Title
self.title_label = tk.Label(self.container, text="Import an image for your ribbon",
font=GlobalVars.TEXT_FONT, bg=BG_COLOR)
self.title_label.pack(pady=20)
# Preview area (will show scaled image)
self.preview_frame = tk.Frame(self.container, bg=BG_COLOR)
self.preview_frame.pack(pady=20)
self.preview_label = tk.Label(self.preview_frame, text="No image selected",
font=('Georgia', 14), bg='white', width=40, height=15)
self.preview_label.pack()
# Info label for dimensions
self.info_label = tk.Label(self.container, text="",
font=('Georgia', 12), bg=BG_COLOR)
self.info_label.pack(pady=10)
# Buttons frame
buttons_frame = tk.Frame(self.container, bg=BG_COLOR)
buttons_frame.pack(pady=20)
# Select Image button
self.select_btn = tk.Button(buttons_frame, text="Select Image",
command=self.select_image,
height=2, width=15, bg='peach puff',
font=GlobalVars.BUTTON_FONT)
self.select_btn.pack(side='left', padx=10)
# Print button (disabled until image selected)
self.print_btn = tk.Button(buttons_frame, text="Print",
command=self.go_to_print,
height=2, width=15, bg='peach puff',
font=GlobalVars.BUTTON_FONT, state='disabled')
self.print_btn.pack(side='left', padx=10)
# Cancel button
self.cancel_btn = tk.Button(buttons_frame, text="Cancel",
command=lambda: master.switch_frame(NoQRRibbonSizeScreen),
height=2, width=15, bg='peach puff',
font=GlobalVars.BUTTON_FONT)
self.cancel_btn.pack(side='left', padx=10)
# Store the processed image
self.processed_image = None
self.preview_photo = None
# Auto-open file dialog
self.after(100, self.select_image)
def select_image(self):
file_path = filedialog.askopenfilename(
filetypes=[("Image files", "*.jpg *.jpeg *.png *.bmp *.gif")]
)
if not file_path:
return
# Open and process the image
img = Image.open(file_path)
# Calculate new dimensions maintaining aspect ratio
original_width, original_height = img.size
aspect_ratio = original_height / original_width
new_width = self.printable_width
new_height = int(self.printable_width * aspect_ratio)
# Resize maintaining aspect ratio
img = img.resize((new_width, new_height), Image.LANCZOS)
# Convert to 1-bit black/white
img = img.convert('1')
# Store the processed image
self.processed_image = img
# Update info label
self.info_label.config(text=f"Size: {new_width} x {new_height} pixels")
# Create preview (scaled for display)
max_preview_height = 400
max_preview_width = 500
preview_scale = min(max_preview_width / new_width, max_preview_height / new_height, 1.0)
preview_width = int(new_width * preview_scale)
preview_height = int(new_height * preview_scale)
preview_img = img.resize((preview_width, preview_height), Image.NEAREST)
self.preview_photo = ImageTk.PhotoImage(preview_img)
# Update preview label to show image
self.preview_label.config(image=self.preview_photo, text="", width=preview_width, height=preview_height)
# Enable print button
self.print_btn.config(state='normal')
def go_to_print(self):
if self.processed_image:
self.processed_image.save("drawing.png")
# Set ribbon_size to 'import' to signal variable height
GlobalVars.ribbon_size = 'import'
self.master.switch_frame(NoQRPrintScreen)
class NoQRPrintScreen(tk.Frame):
"""No QR Ribbon - Print Screen (no QR, no SSB, no photo, no description)."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Create a container to hold the widgets
container = tk.Frame(self)
container.place(relx=0.5, rely=0.5, anchor='center')
# Instructions
RoundedLabel(container, text="Ready to print your ribbon!", wraplength=600, font=GlobalVars.TEXT_FONT, bg='white').grid(row=0, column=0, columnspan=2)
# Print button
tk.Button(container, text="Print", command=self.print_ribbon, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).grid(row=2, column=0, pady=20)
def print_ribbon(self):
# Get config values
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
margin = CONFIG.get("ribbon", {}).get("margin", 50)
printable_width = ribbon_width - 50 # 25px side margins
# Load the drawing
drawing = Image.open("drawing.png")
drawing_width, drawing_height = drawing.size
# For fixed sizes, use multiplier; for imports, use actual image dimensions
if GlobalVars.ribbon_size != 'import':
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
drawing_height = int(printable_width * multiplier)
# Total label height: just margins + drawing (no QR, no scan-tag)
total_height = margin + drawing_height + margin
# Create full-width image with margins
merged_image = Image.new('L', (ribbon_width, total_height), "white")
# Paste drawing 25px in from left, margin down from top
merged_image.paste(drawing, (25, margin))
merged_image.save("merged_image.png")
# Get the ZPL code for the image
zpl_code = tozpl.print_to_zpl("merged_image.png",
print_width=ribbon_width,
label_length=total_height)
# Save the ZPL
with open("to_print.zpl", "w") as file:
file.write(zpl_code)
# Print to ribbon printer
GlobalVars.last_print_printer = "ribbon"
try:
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["ribbon"]} -o raw to_print.zpl', shell=True, stdout=subprocess.PIPE)
except:
print('traceback.format_exc():\n%s' % traceback.format_exc())
from kiosk.screens.completion import Screen10
self.master.switch_frame(Screen10)

View File

@ -0,0 +1,29 @@
"""Placeholder screens (Screen7, Screen9) - not implemented."""
import tkinter as tk
from kiosk.config import BG_COLOR
from kiosk.state import GlobalVars
class Screen7(tk.Frame):
"""Create user - not implemented."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Assume there's a method to manage the text entry
self.info_label = tk.Label(self, text="Hiii sorry this hasn't been implemented yet!", font=("Georgia", 16), wraplength=500, bg='white')
self.info_label.pack()
class Screen9(tk.Frame):
"""Text update - placeholder."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
def go_to_screen10():
from kiosk.screens.completion import Screen10
master.switch_frame(Screen10)
tk.Button(self, text="Done", command=go_to_screen10, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=10)

243
kiosk/screens/print_flow.py Normal file
View File

@ -0,0 +1,243 @@
"""Print flow screens (Screen13, PrintFailedScreen)."""
import tkinter as tk
import subprocess
import traceback
import os
from PIL import Image
import qrcode
import tozpl
import addtoDB
from kiosk.config import CONFIG, BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen13(tk.Frame):
"""Time to print screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Create a container to hold the widgets
container = tk.Frame(self)
container.place(relx=0.5, rely=0.5, anchor='center')
# instructions
RoundedLabel(container, text="Wonderful! It is now time to post your item to Scuttlebutt and to print your tag. You can still cancel by hitting Start Over if you like.", wraplength=600, font=GlobalVars.TEXT_FONT, bg='white').grid(row=0, column=0, columnspan=2)
# buttons
master.add_home_button(self)
tk.Button(container, text="Print", command=self.printy, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).grid(row=2, column=0, pady=20)
def printy(self):
"""Go ahead and print the thing."""
# Specify the path to your image file
path_to_image = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'freeze_frame.jpg')
# Get QR data from the main application
QRX = self.master.QRX
QRY = self.master.QRY
QRscale = self.master.QRscale
# make ssb post
key = addtoDB.addToSSB(path_to_image, GlobalVars.info_text, 1)
# ssb give! (make sure we have a UID to give to first)
if GlobalVars.selected_user and GlobalVars.selected_user.strip() != "":
nothing = addtoDB.addToSSB(GlobalVars.selected_user, key, 2)
# Calculate QR scale based on print type
if GlobalVars.print_type == "ribbon":
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
printable_width = ribbon_width - 50 # 25px margin each side
# First pass: determine actual QR version needed for this data
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=1, # Minimal size just to determine version
border=0,
)
qr.add_data(key)
qr.make(fit=True)
# Calculate module count: 4 * version + 17
module_count = 4 * qr.version + 17
# Calculate pixel-perfect box_size to fit printable width
QRscale = printable_width // module_count
# Second pass: generate QR at correct size
qr = qrcode.QRCode(
version=qr.version, # Use determined version
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=QRscale,
border=0,
)
qr.add_data(key)
qr.make(fit=False) # Don't allow further upgrades
img = qr.make_image()
else:
# Sticker path - use existing QRscale from master
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_H,
box_size=QRscale,
border=0,
)
qr.add_data(key)
qr.make(fit=True)
img = qr.make_image()
whereToSaveQR = 'qr.png'
img.convert('1').save(whereToSaveQR)
# compose image for tag
drawing = Image.open("drawing.png") # drawing
qr_img = Image.open("qr.png").convert("L") # qr
#### merge em
## if sticker
if GlobalVars.print_type == "sticker":
## if we didn't custom set X/Y, set to defaults
if QRX is None and QRY is None:
QRX = 454
QRY = 217
# Load and rotate scan-tag 90 degrees clockwise
scan_tag = Image.open("scan-tag.png").convert("L")
scan_tag_rotated = scan_tag.rotate(-90, expand=True) # -90 = clockwise
merged_image = Image.new('L', (675, 375), "white")
merged_image.paste(drawing, (0, 8))
merged_image.paste(qr_img, (QRX, QRY + 8))
# Paste rotated scan-tag right after drawing, vertically centered
scan_tag_x = 597 # Right edge of drawing
scan_tag_y = (375 - scan_tag_rotated.height) // 2 # Center vertically
merged_image.paste(scan_tag_rotated, (scan_tag_x, scan_tag_y))
merged_image.save("merged_image.png")
# if ribbon
if GlobalVars.print_type == "ribbon":
# Get config values
ribbon_width = CONFIG.get("ribbon", {}).get("width", 450)
margin = CONFIG.get("ribbon", {}).get("margin", 50)
printable_width = ribbon_width - 50 # 25px side margins
multiplier = {'small': 0.5, 'medium': 1.0, 'large': 1.5}.get(GlobalVars.ribbon_size, 1.0)
drawing_height = int(printable_width * multiplier)
# Load scan-tag image
scan_tag = Image.open("scan-tag.png").convert("L")
scan_tag_height = scan_tag.height
# Use actual QR dimensions (already correctly sized from generation)
qr_size = qr_img.width
# Total label height includes top/bottom margins and scan-tag
total_height = margin + drawing_height + scan_tag_height + qr_size + margin
# Create full-width image with margins
merged_image = Image.new('L', (ribbon_width, total_height), "white")
# Paste drawing 25px in from left, margin down from top
merged_image.paste(drawing, (25, margin))
# Paste scan-tag centered below drawing
scan_tag_x = (ribbon_width - scan_tag.width) // 2
merged_image.paste(scan_tag, (scan_tag_x, margin + drawing_height))
# Center QR horizontally within the full ribbon width, below scan-tag
qr_x = (ribbon_width - qr_size) // 2
merged_image.paste(qr_img, (qr_x, margin + drawing_height + scan_tag_height))
merged_image.save("merged_image.png")
# Get the ZPL code for the image
if GlobalVars.print_type == "ribbon":
zpl_code = tozpl.print_to_zpl("merged_image.png",
print_width=ribbon_width,
label_length=total_height)
else:
zpl_code = tozpl.print_to_zpl("merged_image.png")
# save the zpl
# Open the file in write mode
with open("to_print.zpl", "w") as file:
# Write the string to the file
file.write(zpl_code)
# print to sticker printer
if GlobalVars.print_type == "sticker":
GlobalVars.last_print_printer = "sticker"
try:
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["sticker"]} -o raw to_print.zpl', shell=True, stdout=subprocess.PIPE, )
except:
print('traceback.format_exc():\n%s' % traceback.format_exc())
exit()
# or print to tag printer:
if GlobalVars.print_type == "ribbon":
GlobalVars.last_print_printer = "ribbon"
try:
result = subprocess.Popen(f'lp -d {CONFIG["printers"]["ribbon"]} -o raw to_print.zpl', shell=True, stdout=subprocess.PIPE, )
except:
print('traceback.format_exc():\n%s' % traceback.format_exc())
exit()
from kiosk.screens.completion import Screen10
self.master.switch_frame(Screen10) # Switching to Screen10 after Done
class PrintFailedScreen(tk.Frame):
"""Print failed screen with re-print option."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
# Content positioned higher
container = tk.Frame(self, bg=BG_COLOR)
container.pack(pady=(50, 0))
# Troubleshooting instructions
troubleshoot_text = """if print failed:
1. un-hook the red nylon cord and carefully pull out printer.
2. there are teal latches on either side of the printer: pull them towards you and lift the lid all the way open.
3. make sure there is enough resin and printable nylon/stickers and they are lined up straight
4. make sure the resin is not torn. If it is it may need to be re-attached to the uptake spool
5. close printer and press the button on top of printer to stop the green light from flashing
6. put printer back, re-hook the nylon cord and try printing again.
if you're still having trouble you could try searching the internet for Zebra GX430T troubleshooting
AND OR
please email maintenance@cust.ooo
thanks!"""
RoundedLabel(container, text=troubleshoot_text, bg='white', font=('Georgia', 22),
wraplength=900, justify='left').pack(pady=10)
# Re-print button
tk.Button(container, text="Re-print", command=self._reprint,
height=2, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=5)
# Done button
def go_home():
from kiosk.screens.home import Screen0
master.switch_frame(Screen0)
tk.Button(container, text="Done", command=go_home,
height=2, width=30, bg='light gray', font=GlobalVars.BUTTON_FONT).pack(pady=5)
def _reprint(self):
"""Re-send the last print job."""
if GlobalVars.last_print_printer and os.path.exists("to_print.zpl"):
try:
printer_name = CONFIG["printers"][GlobalVars.last_print_printer]
subprocess.Popen(f'lp -d {printer_name} -o raw to_print.zpl',
shell=True, stdout=subprocess.PIPE)
except:
print('traceback.format_exc():\n%s' % traceback.format_exc())
# Go back to thank you screen
from kiosk.screens.completion import Screen10
self.master.switch_frame(Screen10)

View File

@ -0,0 +1,266 @@
"""Scuttlebutt selection screens (Screen1, ScreenWhyScuttlebutt, Screen2)."""
import tkinter as tk
from tkinter import ttk, messagebox
import subprocess
import json
import traceback
from kiosk.config import BG_COLOR
from kiosk.state import GlobalVars
from kiosk.widgets import RoundedLabel
class Screen1(tk.Frame):
"""Do you have SSB?"""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
# Create the label widget with the text
label = RoundedLabel(self, text="Would you like to associate your item with a Scuttlebutt account?", font=GlobalVars.TEXT_FONT, bg='white', wraplength=600)
label.pack(pady=90)
# Deferred imports
def go_to_screen2():
master.switch_frame(Screen2)
def go_to_screen3():
from kiosk.screens.camera import Screen3
master.switch_frame(Screen3)
def go_to_why():
master.switch_frame(ScreenWhyScuttlebutt)
tk.Button(self, text="Yes, and I already have an account", command=go_to_screen2, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
tk.Button(self, text="No thanks", command=go_to_screen3, height=3, width=50, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
tk.Button(self, text="Why would I want to do that?", command=go_to_why, height=3, width=30, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
class ScreenWhyScuttlebutt(tk.Frame):
"""Why Scuttlebutt explanation screen."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
master.add_home_button(self)
label = RoundedLabel(self, text="Items associated with a Scuttlebutt account can have their ownership cryptographically verified. This effectively makes the item an NFT. For more info on how to make a Scuttlebutt account and have it visible on the kiosk visit www.cust.ooo/ssb", font=GlobalVars.TEXT_FONT, bg='white', wraplength=600)
label.pack(pady=90)
tk.Button(self, text="OK", command=lambda: master.switch_frame(Screen1), height=3, width=15, bg='peach puff', font=GlobalVars.BUTTON_FONT).pack(pady=20)
class Screen2(tk.Frame):
"""Find yourself in list of SSB users."""
def __init__(self, master):
tk.Frame.__init__(self, master, bg=BG_COLOR)
self.selected_label = None
self.filtered_users = []
# Create a new frame at the top for the label and text box
self.top_frame = tk.Frame(self, bg=BG_COLOR)
self.top_frame.pack(side="top", fill="x", pady=(60, 10))
# Add a label with text wrapping
self.label = RoundedLabel(self.top_frame,
text="slowly type your public key or alias one letter at a time to find yourself in the list then click on your key to select it.",
font=GlobalVars.TEXT_FONT,
wraplength=800,
bg='white')
self.label.pack(side="top", pady=(0, 10))
# Add text box to the top frame
self.entry = tk.Entry(self.top_frame, font=GlobalVars.TEXT_FONT)
self.entry.bind('<KeyRelease>', lambda e: self.update_users_list())
self.entry.pack(side="top", fill="x", padx=20)
# Focus on the entry box
self.entry.focus_set()
# Create container for user list
self.container = tk.Frame(self, bg=BG_COLOR)
self.container.pack(fill='both', expand=True, padx=20, pady=10)
# Create a canvas for the user list
self.canvas = tk.Canvas(self.container, bg=BG_COLOR)
self.scrollbar = ttk.Scrollbar(self.container, orient="vertical", command=self.canvas.yview)
self.scrollable_frame = tk.Frame(self.canvas, bg=BG_COLOR)
self.scrollable_frame.bind(
"<Configure>",
lambda e: self.canvas.configure(scrollregion=self.canvas.bbox("all"))
)
self.canvas.create_window((0, 0), window=self.scrollable_frame, anchor="nw")
self.canvas.configure(yscrollcommand=self.scrollbar.set)
self.canvas.pack(side="left", fill="both", expand=True)
self.scrollbar.pack(side="right", fill="y")
# Configure the scrollbar style to make it larger
style = ttk.Style()
style.configure("Vertical.TScrollbar", arrowsize=48, width=48)
# Create a frame for action buttons at the bottom
self.button_frame = tk.Frame(self, bg=BG_COLOR)
self.button_frame.pack(side="bottom", fill="x", pady=10)
# The 'Refresh List' button
self.refresh_button = tk.Button(self.button_frame, text="Refresh List", command=self.refresh_users,
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
self.refresh_button.pack(side="left", padx=(20, 10))
# Deferred import for navigation
def go_to_screen3():
from kiosk.screens.camera import Screen3
master.switch_frame(Screen3)
# The 'Done' button to navigate to next screen
self.done_button = tk.Button(self.button_frame, text="Done", command=go_to_screen3,
height=2, width=20, bg='peach puff', font=GlobalVars.BUTTON_FONT)
self.done_button.pack(side="right", padx=(10, 20))
# Initialize users list from users.json
self.users = self.get_users_from_file()
self.update_users_list()
master.add_home_button(self)
def update_users_list(self):
search_text = self.entry.get().lower()
self.filtered_users = [user for user in self.users if search_text in user['id'].lower() or search_text in self.unescape_unicode(user.get('alias', '')).lower()]
self.display_users()
# Preserve selection after filtering
if GlobalVars.selected_user:
for widget in self.scrollable_frame.winfo_children():
if isinstance(widget, tk.Frame):
id_label = widget.winfo_children()[-1]
if id_label['text'] == GlobalVars.selected_user:
self.highlight_frame(widget)
self.selected_label = widget
break
def display_users(self):
for widget in self.scrollable_frame.winfo_children():
widget.destroy()
for index, user in enumerate(self.filtered_users):
try:
frame = tk.Frame(self.scrollable_frame, bg=BG_COLOR)
frame.pack(fill='x', expand=True, pady=2)
alias = self.unescape_unicode(user.get('alias', ''))
id = user.get('id', '')
alias_label = tk.Label(frame, text=alias, font=('Georgia', 14, 'bold'), width=20, anchor='w', bg='white')
alias_label.pack(side='left', padx=(0, 10))
id_label = tk.Label(frame, text=id, font=('Georgia', 14), anchor='w', bg='white')
id_label.pack(side='left', expand=True, fill='x')
frame.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
alias_label.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
id_label.bind('<Button-1>', lambda e, u=user, f=frame: self.on_user_clicked(f, u))
if GlobalVars.selected_user == id:
self.highlight_frame(frame)
self.selected_label = frame
except Exception as e:
print(f"Error displaying user {index + 1}: {str(e)}")
print(f"User data: {user}")
print(traceback.format_exc())
self.scrollable_frame.update_idletasks()
self.canvas.configure(scrollregion=self.canvas.bbox("all"))
def on_user_clicked(self, frame, user):
# Unhighlight the previously selected user
if self.selected_label is not None and self.widget_exists(self.selected_label):
self.unhighlight_frame(self.selected_label)
# Update the selected user
GlobalVars.selected_user = user['id']
self.selected_label = frame
# Highlight the newly selected user
self.highlight_frame(frame)
self.update_done_button_state()
# Print debug information
print(f"User clicked: {user['id']}")
print(f"Selected label: {self.selected_label}")
print(f"Global selected user: {GlobalVars.selected_user}")
def highlight_frame(self, frame):
frame.configure(bg="light blue")
for child in frame.winfo_children():
child.configure(bg="light blue")
print(f"Highlighted frame: {frame}")
def unhighlight_frame(self, frame):
frame.configure(bg=BG_COLOR)
for child in frame.winfo_children():
child.configure(bg=BG_COLOR)
print(f"Unhighlighted frame: {frame}")
def scroll_to_widget(self, widget):
self.canvas.update_idletasks()
self.canvas.yview_moveto(widget.winfo_y() / self.scrollable_frame.winfo_height())
def update_done_button_state(self):
if GlobalVars.selected_user:
self.done_button.configure(state="normal")
else:
self.done_button.configure(state="disabled")
def widget_exists(self, widget):
try:
widget.winfo_exists()
return True
except tk.TclError:
return False
def refresh_users(self):
try:
self.users = self.get_scuttlebutt_users()
self.update_users_list()
self.save_users_to_file(self.users)
messagebox.showinfo("Success", "User list has been refreshed and updated with aliases.")
except Exception as e:
print(f"An error occurred while refreshing the user list: {e}")
messagebox.showerror("Error", "An error occurred while refreshing the user list. Please try again later.")
def get_scuttlebutt_users(self):
try:
result = subprocess.run(['node', 'scuttlebot.js'], capture_output=True, text=True, timeout=60)
if result.returncode == 0:
users = json.loads(result.stdout)
return users
else:
print(f"Command failed with error: {result.stderr}")
return []
except subprocess.TimeoutExpired:
print("Command timed out after 60 seconds")
return []
except json.JSONDecodeError:
print("Failed to parse JSON output")
return []
except Exception as e:
print(f"An unexpected error occurred: {str(e)}")
return []
def get_users_from_file(self):
try:
with open('users.json') as f:
users = json.load(f)
return users
except FileNotFoundError:
return []
def save_users_to_file(self, users):
with open('users.json', 'w') as f:
json.dump(users, f)
@staticmethod
def unescape_unicode(s):
return s.encode('utf-8').decode('unicode_escape')

13
kiosk/state.py Normal file
View File

@ -0,0 +1,13 @@
"""Global state container for the kiosk application."""
class GlobalVars:
"""Shared state container for kiosk data."""
qr_code_value = None
print_type = "neither"
selected_user = None
ribbon_size = None # 'small', 'medium', or 'large'
info_text = None
BUTTON_FONT = None
TEXT_FONT = None
last_print_printer = None # "sticker" or "ribbon" - for re-print functionality

94
kiosk/utils.py Normal file
View File

@ -0,0 +1,94 @@
"""Utility functions for the kiosk application."""
import os
import re
import glob
import cv2
def get_preferred_camera_index(preferred_name_fragment=None):
"""
Try to find a stable camera index for an external webcam.
Strategy (Linux):
- Look in /dev/v4l/by-id, which has stable, human-readable symlinks.
- If `preferred_name_fragment` is provided, pick the first device whose
name contains that fragment.
- Otherwise, prefer devices whose name looks like an external USB webcam.
- Fall back to the lowest working index (usually the built-in camera).
You can customize behaviour by passing a string like "Logitech" or by
changing the heuristics below.
"""
by_id_dir = "/dev/v4l/by-id"
try:
if os.path.isdir(by_id_dir):
paths = sorted(glob.glob(os.path.join(by_id_dir, "*")))
if paths:
# Optionally filter by a user-specified name fragment
if preferred_name_fragment:
name_fragment = preferred_name_fragment.lower()
candidate_paths = [
p for p in paths if name_fragment in os.path.basename(p).lower()
]
if not candidate_paths:
candidate_paths = paths
else:
candidate_paths = paths
# Heuristic: prefer names that look like external USB webcams
# (often contain "usb-" but are not clearly an integrated camera)
usb_like = [
p
for p in candidate_paths
if "usb-" in os.path.basename(p).lower()
]
# If that filter gives us something, use it, otherwise use all
candidate_paths = usb_like or candidate_paths
for path in candidate_paths:
target = os.path.realpath(path) # e.g. /dev/video2
m = re.search(r"video(\d+)", target)
if not m:
continue
idx = int(m.group(1))
cap = cv2.VideoCapture(idx)
if cap.isOpened():
cap.release()
print(f"Selected camera index {idx} from {path}")
return idx
cap.release()
except Exception as e:
print(f"Error while trying to select preferred camera: {e}")
# Fallback: probe a small range of indices and pick the first that works
for idx in range(0, 6):
cap = cv2.VideoCapture(idx)
if cap.isOpened():
cap.release()
print(f"Falling back to first working camera index: {idx}")
return idx
cap.release()
# Absolute fallback if nothing opens
print("No working camera found; defaulting to index 0")
return 0
def sanitize_for_ssb(text):
"""Sanitize text for safe passage through shell and JSON."""
# Order matters: escape backslashes first
text = text.replace('\\', '\\\\')
# Escape double quotes for JSON and shell
text = text.replace('"', '\\"')
# Escape dollar signs to prevent shell variable expansion
text = text.replace('$', '\\$')
# Escape backticks to prevent shell command substitution
text = text.replace('`', '\\`')
# Escape newlines for JSON
text = text.replace('\n', '\\n')
# Escape carriage returns
text = text.replace('\r', '')
# Escape tabs
text = text.replace('\t', ' ')
return text

View File

@ -0,0 +1,6 @@
"""Widget components for the kiosk application."""
from .rounded_label import RoundedLabel
from .scrolled_frame import VirtualScrolledFrame
from .drawing_mixin import DrawingMixin
__all__ = ['RoundedLabel', 'VirtualScrolledFrame', 'DrawingMixin']

Binary file not shown.

View File

@ -0,0 +1,124 @@
"""Drawing functionality mixin for kiosk screens."""
from PIL import Image, ImageTk, ImageDraw
from tkinter import filedialog
class DrawingMixin:
"""Mixin providing drawing canvas functionality for kiosk screens."""
def init_drawing(self, width, height, grid_size=1):
"""Initialize drawing state. Call this in __init__ after creating self.canvas."""
self.original_width = width
self.original_height = height
self.grid_size = grid_size
self.drawing = Image.new('1', (width, height), 1)
self.draw = ImageDraw.Draw(self.drawing)
self.last_draw = None
self.draw_color = 'black'
self.draw_size = 1
def draw_line(self, event):
"""Handle mouse drag to draw lines."""
x, y = event.x // self.grid_size, event.y // self.grid_size
if self.last_draw:
points = self._get_points_on_line(*self.last_draw, x, y)
for px, py in points:
if self.draw_color == 'gray':
self._draw_dithered_point(px, py)
else:
self._draw_point(px, py)
self.last_draw = (x, y)
def _get_points_on_line(self, x0, y0, x1, y1):
"""Bresenham's line algorithm for smooth drawing."""
points = []
dx = abs(x1 - x0)
dy = abs(y1 - y0)
sx = 1 if x0 < x1 else -1
sy = 1 if y0 < y1 else -1
err = dx - dy
while True:
points.append((x0, y0))
if x0 == x1 and y0 == y1:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x0 += sx
if e2 < dx:
err += dx
y0 += sy
return points
def _draw_point(self, x, y):
"""Draw a solid point at coordinates."""
color = 0 if self.draw_color == 'black' else 1
for dx in range(self.draw_size):
for dy in range(self.draw_size):
self.canvas.create_rectangle(
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
fill=self.draw_color, outline=''
)
self.draw.point((x+dx, y+dy), fill=color)
def _draw_dithered_point(self, x, y):
"""Draw a dithered (gray) point using checkerboard pattern."""
for dx in range(self.draw_size):
for dy in range(self.draw_size):
if (x+dx+y+dy) % 2 == 0:
self.canvas.create_rectangle(
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
fill='black', outline=''
)
self.draw.point((x+dx, y+dy), fill=0)
else:
self.canvas.create_rectangle(
(x+dx)*self.grid_size, (y+dy)*self.grid_size,
(x+dx+1)*self.grid_size, (y+dy+1)*self.grid_size,
fill='white', outline=''
)
self.draw.point((x+dx, y+dy), fill=1)
def reset_last_draw(self, event):
self.last_draw = None
def set_draw_color(self, color):
self.draw_color = color
def set_draw_size(self, size):
self.draw_size = size
def clear_drawing(self):
self.canvas.delete("all")
self.drawing = Image.new('1', (self.original_width, self.original_height), 1)
self.draw = ImageDraw.Draw(self.drawing)
def import_image_base(self):
"""Base import image functionality. Returns file path or None."""
file_path = filedialog.askopenfilename(filetypes=[("Image files", "*.jpg *.jpeg *.png")])
if not file_path:
return None
img = Image.open(file_path)
img = img.convert('1')
if img.size != (self.original_width, self.original_height):
img = img.resize((self.original_width, self.original_height), Image.LANCZOS)
self.drawing = img
self.draw = ImageDraw.Draw(self.drawing)
# Display the image at scaled size
display_width = self.original_width * self.grid_size
display_height = self.original_height * self.grid_size
display_img = img.resize((display_width, display_height), Image.NEAREST)
self.imported_img = ImageTk.PhotoImage(display_img)
self.canvas.delete("all")
self.canvas.create_image(0, 0, image=self.imported_img, anchor='nw')
return file_path

View File

@ -0,0 +1,60 @@
"""Rounded label widget using Canvas."""
import tkinter as tk
from kiosk.config import BG_COLOR
class RoundedLabel(tk.Canvas):
"""A label with rounded corners using Canvas."""
def __init__(self, parent, text="", font=None, bg='white', fg='black',
wraplength=400, justify='center', padx=15, pady=15, radius=20, **kwargs):
# Use a temporary label to calculate text dimensions
temp_label = tk.Label(parent, text=text, font=font, wraplength=wraplength, justify=justify)
temp_label.update_idletasks()
text_width = temp_label.winfo_reqwidth()
text_height = temp_label.winfo_reqheight()
temp_label.destroy()
# Calculate canvas dimensions
width = text_width + 2 * padx
height = text_height + 2 * pady
# Initialize canvas with background matching the app background
super().__init__(parent, width=width, height=height,
highlightthickness=0, bg=BG_COLOR, **kwargs)
# Draw rounded rectangle
self._draw_rounded_rect(0, 0, width, height, radius, bg)
# Add text
self.create_text(width // 2, height // 2, text=text, font=font, fill=fg,
width=wraplength, justify=justify)
def _draw_rounded_rect(self, x1, y1, x2, y2, radius, fill):
"""Draw a rounded rectangle on the canvas."""
# Ensure radius isn't larger than half the smallest dimension
radius = min(radius, (x2 - x1) // 2, (y2 - y1) // 2)
# Draw the rounded rectangle using arcs and rectangles
# Top-left corner
self.create_arc(x1, y1, x1 + 2*radius, y1 + 2*radius,
start=90, extent=90, fill=fill, outline=fill)
# Top-right corner
self.create_arc(x2 - 2*radius, y1, x2, y1 + 2*radius,
start=0, extent=90, fill=fill, outline=fill)
# Bottom-left corner
self.create_arc(x1, y2 - 2*radius, x1 + 2*radius, y2,
start=180, extent=90, fill=fill, outline=fill)
# Bottom-right corner
self.create_arc(x2 - 2*radius, y2 - 2*radius, x2, y2,
start=270, extent=90, fill=fill, outline=fill)
# Fill the center rectangles
# Center horizontal
self.create_rectangle(x1 + radius, y1, x2 - radius, y2,
fill=fill, outline=fill)
# Left vertical
self.create_rectangle(x1, y1 + radius, x1 + radius, y2 - radius,
fill=fill, outline=fill)
# Right vertical
self.create_rectangle(x2 - radius, y1 + radius, x2, y2 - radius,
fill=fill, outline=fill)

View File

@ -0,0 +1,44 @@
"""Virtual scrolled frame widget."""
import tkinter as tk
from tkinter import ttk
from kiosk.config import BG_COLOR
class VirtualScrolledFrame(ttk.Frame):
"""A scrollable frame widget using canvas."""
def __init__(self, parent, *args, **kw):
ttk.Frame.__init__(self, parent, *args, **kw)
# Create a canvas object and a vertical scrollbar for scrolling it
self.vscrollbar = ttk.Scrollbar(self, orient=tk.VERTICAL)
self.canvas = tk.Canvas(self, yscrollcommand=self.vscrollbar.set, bg=BG_COLOR)
self.vscrollbar.config(command=self.canvas.yview)
# Reset the view
self.canvas.xview_moveto(0)
self.canvas.yview_moveto(0)
# Create a frame inside the canvas which will be scrolled with it
self.interior = tk.Frame(self.canvas, bg=BG_COLOR)
self.interior_id = self.canvas.create_window(0, 0, window=self.interior, anchor=tk.NW)
# Pack the widgets
self.vscrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
# Bind events to the Canvas
self.canvas.bind('<Configure>', self._configure_canvas)
self.interior.bind('<Configure>', self._configure_interior)
def _configure_interior(self, event):
# Update the scrollbars to match the size of the inner frame
size = (self.interior.winfo_reqwidth(), self.interior.winfo_reqheight())
self.canvas.config(scrollregion="0 0 %s %s" % size)
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
# Update the canvas's width to fit the inner frame
self.canvas.config(width=self.interior.winfo_reqwidth())
def _configure_canvas(self, event):
if self.interior.winfo_reqwidth() != self.canvas.winfo_width():
# Update the inner frame's width to fill the canvas
self.canvas.itemconfigure(self.interior_id, width=self.canvas.winfo_width())

BIN
qr.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 B

View File

@ -26,22 +26,30 @@ then
#add blob
blobID=$(cat $itemImagePath | ssb-server blobs.add) || exit 1
# publish the item
ssb-server publish . <<BLAAB
{
"type":"post",
"text":"![photo.jpg]($blobID)\\n\\n$itemDescription\\n\\n a #custodisco item ",
"custodisco":"true",
"nft": "mint",
"mentions": [
{
"name": "photo.jpg",
"type": "image/jpeg",
"link": "$blobID"
}
]
}
BLAAB
# Build the full text and escape for JSON using jq
fullText="![photo.jpg]($blobID)
$itemDescription
a #custodisco item "
# publish the item using jq to safely build JSON
jq -n \
--arg text "$fullText" \
--arg blobID "$blobID" \
'{
type: "post",
text: $text,
custodisco: "true",
nft: "mint",
mentions: [
{
name: "photo.jpg",
type: "image/jpeg",
link: $blobID
}
]
}' | ssb-server publish .
fi
@ -51,17 +59,19 @@ then
account=$2
messageID=$3
ssb-server publish . <<BLAAB
{
"type":"post",
"text":"This item is stewarded by $account",
"custodisco":"true",
"nft": "give",
"target": "$account",
"root": "$messageID",
"branch": "$messageID"
}
BLAAB
# Use jq to safely build JSON with proper escaping
jq -n \
--arg account "$account" \
--arg messageID "$messageID" \
'{
type: "post",
text: ("This item is stewarded by " + $account),
custodisco: "true",
nft: "give",
target: $account,
root: $messageID,
branch: $messageID
}' | ssb-server publish .
fi

0
touch Normal file
View File