Files

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()