Implement notetaking app, ST7796 display with touch support, audio beep navigation, and video streaming
This commit is contained in:
+490
@@ -0,0 +1,490 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user