#!/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()