491 lines
19 KiB
Python
491 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
import socket
|
|
import time
|
|
import sys
|
|
import os
|
|
import argparse
|
|
import curses
|
|
import datetime
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
# Screen Dimensions
|
|
WIDTH = 400
|
|
HEIGHT = 300
|
|
|
|
# Margins and Layout
|
|
MARGIN_LEFT = 20
|
|
MARGIN_RIGHT = 380
|
|
TOP_LIMIT = 35
|
|
BOTTOM_LIMIT = 265
|
|
|
|
# Precompute destination byte mapping for RLCD hardware buffer
|
|
DEST_BYTE_MAP = []
|
|
for dst_idx in range(15000):
|
|
byte_x = dst_idx // 75
|
|
block_y = dst_idx % 75
|
|
x_base = 2 * byte_x
|
|
y_base = HEIGHT - 1 - 4 * block_y
|
|
|
|
bits_map = []
|
|
for local_y in range(4):
|
|
y = y_base - local_y
|
|
for local_x in range(2):
|
|
x = x_base + local_x
|
|
src_byte_idx = y * 50 + (x // 8)
|
|
src_bit_idx = 7 - (x % 8)
|
|
src_mask = 1 << src_bit_idx
|
|
dst_mask = 1 << (7 - (local_y * 2 + local_x))
|
|
bits_map.append((src_byte_idx, src_mask, dst_mask))
|
|
DEST_BYTE_MAP.append(tuple(bits_map))
|
|
|
|
|
|
def map_to_rlcd_hw_buffer(img_1bit):
|
|
"""Highly optimized byte-level 1-bit monochrome image mapping to Waveshare RLCD buffer."""
|
|
img_bytes = img_1bit.tobytes()
|
|
hw_buffer = bytearray(15000)
|
|
for dst_idx in range(15000):
|
|
val = 0
|
|
for src_idx, src_mask, dst_mask in DEST_BYTE_MAP[dst_idx]:
|
|
if img_bytes[src_idx] & src_mask:
|
|
val |= dst_mask
|
|
hw_buffer[dst_idx] = val
|
|
return hw_buffer
|
|
|
|
|
|
def wrap_text_by_width(text, cursor_pos, font, max_width=360):
|
|
"""
|
|
Wraps text to fit within max_width pixels.
|
|
Returns:
|
|
lines: list of strings (the wrapped lines)
|
|
line_starts: list of starting indices of each line in the raw text
|
|
cursor_line: index of line containing cursor
|
|
cursor_col: index of column in that line (in terms of character index in the line)
|
|
"""
|
|
lines = []
|
|
line_starts = []
|
|
cursor_line = 0
|
|
cursor_col = 0
|
|
|
|
current_char_idx = 0
|
|
paragraphs = text.split('\n')
|
|
|
|
# Create a dummy draw context to measure text lengths
|
|
dummy_im = Image.new("1", (1, 1))
|
|
dummy_draw = ImageDraw.Draw(dummy_im)
|
|
|
|
for p_idx, p in enumerate(paragraphs):
|
|
if not p:
|
|
if current_char_idx <= cursor_pos <= current_char_idx:
|
|
cursor_line = len(lines)
|
|
cursor_col = 0
|
|
lines.append("")
|
|
line_starts.append(current_char_idx)
|
|
current_char_idx += 1 # for the '\n'
|
|
continue
|
|
|
|
words = p.split(' ')
|
|
curr_line = ""
|
|
curr_line_start = current_char_idx
|
|
|
|
for w in words:
|
|
if not curr_line:
|
|
test_line = w
|
|
else:
|
|
test_line = curr_line + " " + w
|
|
|
|
w_len = dummy_draw.textlength(test_line, font=font)
|
|
if w_len <= max_width:
|
|
curr_line = test_line
|
|
else:
|
|
# Wrap current line
|
|
if curr_line_start <= cursor_pos <= curr_line_start + len(curr_line):
|
|
cursor_line = len(lines)
|
|
cursor_col = cursor_pos - curr_line_start
|
|
lines.append(curr_line)
|
|
line_starts.append(curr_line_start)
|
|
|
|
# Check for long words
|
|
w_start = curr_line_start + len(curr_line) + 1 # +1 for space/wrap boundary
|
|
while dummy_draw.textlength(w, font=font) > max_width:
|
|
part_len = 1
|
|
while part_len <= len(w) and dummy_draw.textlength(w[:part_len], font=font) <= max_width:
|
|
part_len += 1
|
|
part = w[:part_len - 1]
|
|
if not part:
|
|
part = w[0]
|
|
part_len = 2
|
|
|
|
if w_start <= cursor_pos <= w_start + len(part):
|
|
cursor_line = len(lines)
|
|
cursor_col = cursor_pos - w_start
|
|
lines.append(part)
|
|
line_starts.append(w_start)
|
|
w_start += len(part)
|
|
w = w[part_len - 1:]
|
|
|
|
curr_line = w
|
|
curr_line_start = w_start
|
|
|
|
if curr_line or p_idx == len(paragraphs) - 1:
|
|
if curr_line_start <= cursor_pos <= curr_line_start + len(curr_line):
|
|
cursor_line = len(lines)
|
|
cursor_col = cursor_pos - curr_line_start
|
|
lines.append(curr_line)
|
|
line_starts.append(curr_line_start)
|
|
current_char_idx = curr_line_start + len(curr_line)
|
|
|
|
current_char_idx += 1 # for the '\n'
|
|
|
|
return lines, line_starts, cursor_line, cursor_col
|
|
|
|
|
|
def render_screen(text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines, dark_mode, show_cursor, status_msg=""):
|
|
# Create 1-bit image (0 = Black background)
|
|
if dark_mode:
|
|
bg_color = 0 # Black background
|
|
text_color = 1 # White text
|
|
accent_color = 1 # White elements
|
|
rule_color = 1 # White dotted lines
|
|
else:
|
|
bg_color = 1 # White background
|
|
text_color = 0 # Black text
|
|
accent_color = 0 # Black elements
|
|
rule_color = 0 # Black dotted lines
|
|
|
|
img = Image.new("1", (WIDTH, HEIGHT), bg_color)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
# Load clean, non-clipping UI font for header/footer
|
|
try:
|
|
ui_font = ImageFont.load_default(size=12)
|
|
except TypeError:
|
|
ui_font = ImageFont.load_default()
|
|
|
|
# 1. Wrap the text and get the cursor location (max_width = 360)
|
|
lines, line_starts, cursor_line, cursor_col = wrap_text_by_width(text, cursor_pos, font, max_width=360)
|
|
|
|
# 2. Adjust scrolling
|
|
if cursor_line >= scroll_top + max_lines:
|
|
scroll_top = cursor_line - max_lines + 1
|
|
elif cursor_line < scroll_top:
|
|
scroll_top = cursor_line
|
|
|
|
# 3. Draw Header Title Bar
|
|
draw.rectangle([0, 0, WIDTH - 1, 28], fill=text_color)
|
|
|
|
# Display note title + date
|
|
today_str = datetime.date.today().strftime("%a, %b %d, %Y").upper()
|
|
header_title = f"KID KEEPER - {today_str}"
|
|
draw.text((15, 7), header_title, fill=bg_color, font=ui_font)
|
|
|
|
# Header stats (word and character count)
|
|
word_count = len(text.split())
|
|
char_count = len(text)
|
|
stats_str = f"W:{word_count} C:{char_count}"
|
|
stats_bbox = draw.textbbox((0, 0), stats_str, font=ui_font)
|
|
stats_w = stats_bbox[2] - stats_bbox[0]
|
|
draw.text((WIDTH - stats_w - 15, 7), stats_str, fill=bg_color, font=ui_font)
|
|
|
|
# 4. Draw Notebook Ruled Paper Lines (aligned with baseline of the font size)
|
|
for i in range(max_lines):
|
|
y_line = TOP_LIMIT + line_spacing * i + char_h + 5
|
|
# Prevent drawing paper lines past bottom limit
|
|
if y_line < BOTTOM_LIMIT + 5:
|
|
for x in range(MARGIN_LEFT, MARGIN_RIGHT, 4):
|
|
draw.point((x, y_line), fill=rule_color)
|
|
|
|
# 5. Draw Visible Text Lines
|
|
visible_lines = lines[scroll_top:scroll_top + max_lines]
|
|
for idx, line_text in enumerate(visible_lines):
|
|
y_pos = TOP_LIMIT + line_spacing * idx + 2
|
|
draw.text((MARGIN_LEFT, y_pos), line_text, fill=text_color, font=font)
|
|
|
|
# 6. Draw Blinking Text Cursor
|
|
if show_cursor:
|
|
cursor_line_text = lines[cursor_line]
|
|
text_before_cursor = cursor_line_text[:cursor_col]
|
|
cursor_x = MARGIN_LEFT + draw.textlength(text_before_cursor, font=font)
|
|
cursor_y = TOP_LIMIT + (cursor_line - scroll_top) * line_spacing + 2
|
|
if MARGIN_LEFT <= cursor_x <= MARGIN_RIGHT:
|
|
# Draw a thick cursor bar (2px width)
|
|
draw.rectangle([cursor_x, cursor_y, cursor_x + 1, cursor_y + char_h + 2], fill=text_color)
|
|
|
|
# 7. Draw Bottom Status Bar
|
|
draw.line((0, BOTTOM_LIMIT + 8, WIDTH - 1, BOTTOM_LIMIT + 8), fill=accent_color)
|
|
|
|
help_text = "Ctrl+S: Save Ctrl+T: Theme Ctrl+F: Font Ctrl+X: Exit"
|
|
draw.text((15, BOTTOM_LIMIT + 12), help_text, fill=text_color, font=ui_font)
|
|
|
|
if status_msg:
|
|
msg_bbox = draw.textbbox((0, 0), status_msg, font=ui_font)
|
|
msg_w = msg_bbox[2] - msg_bbox[0]
|
|
draw.rectangle([WIDTH - msg_w - 20, BOTTOM_LIMIT + 10, WIDTH - 5, HEIGHT - 2], fill=bg_color)
|
|
draw.text((WIDTH - msg_w - 15, BOTTOM_LIMIT + 12), status_msg, fill=text_color, font=ui_font)
|
|
|
|
return img, scroll_top
|
|
|
|
|
|
def load_font(font_path, size):
|
|
"""Safely loads a TTF font and computes its vertical metric using 'Hyg' ascenders/descenders."""
|
|
try:
|
|
if font_path and os.path.exists(font_path):
|
|
font = ImageFont.truetype(font_path, size=size)
|
|
else:
|
|
font = ImageFont.load_default(size=size)
|
|
except Exception:
|
|
font = ImageFont.load_default()
|
|
|
|
# Measure character height using ascenders and descenders
|
|
test_img = Image.new("1", (100, 100))
|
|
test_draw = ImageDraw.Draw(test_img)
|
|
bbox = test_draw.textbbox((0, 0), "Hyg", font=font)
|
|
char_h = bbox[3] - bbox[1]
|
|
if char_h <= 0:
|
|
char_h = 12
|
|
return font, char_h
|
|
|
|
|
|
def run_editor(stdscr, args):
|
|
# --- Curses Initialization ---
|
|
stdscr.nodelay(True) # Non-blocking input
|
|
curses.noecho() # Hide echoing
|
|
stdscr.keypad(True) # Handle arrow keys
|
|
|
|
# Configure kid-friendly font choices
|
|
# Format: (path, size, name)
|
|
font_folder = "fonts"
|
|
if not os.path.exists(font_folder):
|
|
font_folder = os.path.expanduser("~/fonts")
|
|
|
|
font_options = [
|
|
(os.path.join(font_folder, "PatrickHand.ttf"), 22, "Handwritten"),
|
|
(os.path.join(font_folder, "CourierPrime.ttf"), 20, "Typewriter"),
|
|
(None, 14, "Default Monospace")
|
|
]
|
|
font_idx = 0
|
|
|
|
# Load first font
|
|
f_path, f_size, f_name = font_options[font_idx]
|
|
font, char_h = load_font(f_path, f_size)
|
|
|
|
# 2. Load note from file (use date-specific note by default)
|
|
note_text = ""
|
|
if os.path.exists(args.file):
|
|
try:
|
|
with open(args.file, "r") as f:
|
|
note_text = f.read()
|
|
except:
|
|
pass
|
|
|
|
cursor_pos = len(note_text)
|
|
scroll_top = 0
|
|
dark_mode = False
|
|
|
|
# 3. Connect to the ESP32 TCP Stream Server
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(5.0)
|
|
try:
|
|
sock.connect((args.ip, args.port))
|
|
except Exception as e:
|
|
curses.endwin()
|
|
print(f"\nConnection failed to {args.ip}:{args.port} -- {e}")
|
|
print("Please verify the ESP32 is powered on and connected to Wi-Fi.")
|
|
sys.exit(1)
|
|
|
|
sock.setblocking(False)
|
|
|
|
# State variables
|
|
last_blink_time = time.time()
|
|
show_cursor = True
|
|
status_msg = f"Font: {f_name}"
|
|
status_msg_expiry = time.time() + 2.0
|
|
redraw = True
|
|
|
|
while True:
|
|
now = time.time()
|
|
|
|
# Calculate text layout dynamically based on current font height
|
|
line_spacing = char_h + 8
|
|
max_lines_on_screen = (BOTTOM_LIMIT - TOP_LIMIT) // line_spacing
|
|
|
|
# Cursor blink check (toggles every 500ms)
|
|
if now - last_blink_time >= 0.5:
|
|
show_cursor = not show_cursor
|
|
last_blink_time = now
|
|
redraw = True
|
|
|
|
# Expiry check for status message
|
|
if status_msg and now > status_msg_expiry:
|
|
status_msg = ""
|
|
redraw = True
|
|
|
|
# Read all pending characters from curses
|
|
keys = []
|
|
while True:
|
|
try:
|
|
k = stdscr.get_wch()
|
|
keys.append(k)
|
|
except curses.error:
|
|
break
|
|
|
|
if keys:
|
|
redraw = True
|
|
show_cursor = True
|
|
last_blink_time = now
|
|
|
|
for key in keys:
|
|
if isinstance(key, str):
|
|
if key == '\x18': # Ctrl+X: Save and Exit
|
|
status_msg = "Saving..."
|
|
img, _ = render_screen(note_text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines_on_screen, dark_mode, False, status_msg)
|
|
try:
|
|
sock.sendall(map_to_rlcd_hw_buffer(img))
|
|
except:
|
|
pass
|
|
try:
|
|
with open(args.file, "w") as f:
|
|
f.write(note_text)
|
|
except:
|
|
pass
|
|
sock.close()
|
|
return
|
|
|
|
elif key == '\x13': # Ctrl+S: Save Note
|
|
try:
|
|
with open(args.file, "w") as f:
|
|
f.write(note_text)
|
|
status_msg = "Saved!"
|
|
except:
|
|
status_msg = "Err Save"
|
|
status_msg_expiry = now + 2.0
|
|
|
|
elif key == '\x14': # Ctrl+T: Toggle Theme
|
|
dark_mode = not dark_mode
|
|
status_msg = "Theme Dark" if dark_mode else "Theme Light"
|
|
status_msg_expiry = now + 2.0
|
|
|
|
elif key == '\x06': # Ctrl+F: Cycle Fonts
|
|
font_idx = (font_idx + 1) % len(font_options)
|
|
f_path, f_size, f_name = font_options[font_idx]
|
|
font, char_h = load_font(f_path, f_size)
|
|
status_msg = f"Font: {f_name}"
|
|
status_msg_expiry = now + 2.0
|
|
|
|
# Backspace
|
|
elif key in ('\x7f', '\x08'):
|
|
if cursor_pos > 0:
|
|
note_text = note_text[:cursor_pos - 1] + note_text[cursor_pos:]
|
|
cursor_pos -= 1
|
|
|
|
# Enter: Newline
|
|
elif key in ('\r', '\n'):
|
|
note_text = note_text[:cursor_pos] + "\n" + note_text[cursor_pos:]
|
|
cursor_pos += 1
|
|
|
|
# Normal typed characters
|
|
elif ord(key) >= 32:
|
|
note_text = note_text[:cursor_pos] + key + note_text[cursor_pos:]
|
|
cursor_pos += 1
|
|
|
|
elif isinstance(key, int):
|
|
lines, line_starts, cursor_line, cursor_col = wrap_text_by_width(note_text, cursor_pos, font, max_width=360)
|
|
|
|
if key == curses.KEY_UP:
|
|
if cursor_line > 0:
|
|
target_line = cursor_line - 1
|
|
target_col = min(cursor_col, len(lines[target_line]))
|
|
cursor_pos = line_starts[target_line] + target_col
|
|
|
|
elif key == curses.KEY_DOWN:
|
|
if cursor_line < len(lines) - 1:
|
|
target_line = cursor_line + 1
|
|
target_col = min(cursor_col, len(lines[target_line]))
|
|
cursor_pos = line_starts[target_line] + target_col
|
|
|
|
elif key == curses.KEY_RIGHT:
|
|
if cursor_pos < len(note_text):
|
|
cursor_pos += 1
|
|
|
|
elif key == curses.KEY_LEFT:
|
|
if cursor_pos > 0:
|
|
cursor_pos -= 1
|
|
|
|
elif key == curses.KEY_BACKSPACE:
|
|
if cursor_pos > 0:
|
|
note_text = note_text[:cursor_pos - 1] + note_text[cursor_pos:]
|
|
cursor_pos -= 1
|
|
|
|
if redraw:
|
|
redraw = False
|
|
# Render to RLCD and stream
|
|
img, scroll_top = render_screen(note_text, cursor_pos, scroll_top, font, char_h, line_spacing, max_lines_on_screen, dark_mode, show_cursor, status_msg)
|
|
try:
|
|
raw_bytes = map_to_rlcd_hw_buffer(img)
|
|
sock.sendall(raw_bytes)
|
|
except BlockingIOError:
|
|
pass
|
|
except:
|
|
break
|
|
|
|
# Draw Console UI on terminal screen
|
|
stdscr.erase()
|
|
h, w = stdscr.getmaxyx()
|
|
|
|
# Header
|
|
stdscr.attron(curses.A_REVERSE)
|
|
today_header = datetime.date.today().strftime("%A, %B %d, %Y")
|
|
header_str = f" NOTE KEEPER | {today_header} "
|
|
stdscr.addstr(0, 0, header_str + " " * (w - len(header_str) - 1))
|
|
stdscr.attroff(curses.A_REVERSE)
|
|
|
|
# Note Content
|
|
stdscr.addstr(2, 0, f"--- Note File: {args.file} (Font: {f_name}) ---", curses.A_BOLD)
|
|
|
|
# Wrap text to display in console
|
|
term_lines, _, term_cursor_line, term_cursor_col = wrap_text_by_width(note_text, cursor_pos, font, max_width=360)
|
|
|
|
for idx, line in enumerate(term_lines):
|
|
if 4 + idx < h - 3:
|
|
stdscr.addstr(4 + idx, 2, line)
|
|
|
|
# Footer
|
|
stdscr.attron(curses.A_REVERSE)
|
|
footer_str = " Ctrl+S: Save | Ctrl+T: Theme | Ctrl+F: Font | Ctrl+X: Save & Exit "
|
|
stdscr.addstr(h - 1, 0, footer_str + " " * (w - len(footer_str) - 1))
|
|
stdscr.attroff(curses.A_REVERSE)
|
|
|
|
# Corner stats
|
|
if status_msg:
|
|
stdscr.addstr(h - 2, w - len(status_msg) - 2, f"[{status_msg}]", curses.A_BOLD)
|
|
else:
|
|
stdscr.addstr(h - 2, w - 18, f"Chars: {len(note_text)}")
|
|
|
|
# Position Terminal Cursor
|
|
try:
|
|
stdscr.move(4 + term_cursor_line, 2 + term_cursor_col)
|
|
except:
|
|
pass
|
|
|
|
stdscr.refresh()
|
|
|
|
time.sleep(0.01)
|
|
|
|
|
|
def main():
|
|
# 1. Determine daily default filename: note_YYYY-MM-DD.txt
|
|
today_str = datetime.date.today().strftime("%Y-%m-%d")
|
|
default_filename = f"note_{today_str}.txt"
|
|
|
|
parser = argparse.ArgumentParser(description="Curses Pi Zero Kid Note Taking Streaming App")
|
|
parser.add_argument("--ip", default="192.168.68.123", help="Target ESP32-S3 IP Address (default: 192.168.68.123)")
|
|
parser.add_argument("--port", type=int, default=8081, help="Streaming TCP Port (default: 8081)")
|
|
parser.add_argument("--file", default=default_filename, help=f"Filename to persist notes (default: {default_filename})")
|
|
args = parser.parse_args()
|
|
|
|
curses.wrapper(run_editor, args)
|
|
print(f"\nExited. Note saved to {args.file}. Thank you for using Note Keeper!")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|