Compare commits

..

No commits in common. "main" and "v0.1.0" have entirely different histories.
main ... v0.1.0

24 changed files with 82 additions and 1442 deletions

Binary file not shown.

Binary file not shown.

View File

@ -1,30 +0,0 @@
{
"Mode": "Broadcaster",
"ScreenWidth": 800,
"ScreenHeight": 600,
"ShowGrid": false,
"Font": {
"Name": "Modeseven",
"Path": "assets/fonts/Modeseven.ttf",
"Size": 18
},
"Colours": {
"Header": "blue",
"Footer": "red",
"Background": "black",
"TextPrimary": "white",
"TextAccent": "yellow",
"Clock": "yellow"
},
"Header": {
"SelectedPage": "P100",
"CurrentPage": "100",
"ServiceName": "Telefact",
"ServiceFG": "yellow",
"ServiceBG": "red"
}
}

View File

@ -1,132 +0,0 @@
{
"Feeds": {
"CBC": [
{
"Name": "CBC Top Stories",
"Url": "https://www.cbc.ca/cmlink/rss-topstories",
"Category": "National",
"Enabled": true
},
{
"Name": "CBC World",
"Url": "https://www.cbc.ca/cmlink/rss-world",
"Category": "International",
"Enabled": true
},
{
"Name": "CBC Politics",
"Url": "https://www.cbc.ca/cmlink/rss-politics",
"Category": "Politics",
"Enabled": true
},
{
"Name": "CBC Business",
"Url": "https://www.cbc.ca/cmlink/rss-business",
"Category": "Economy",
"Enabled": true
},
{
"Name": "CBC Health",
"Url": "https://www.cbc.ca/cmlink/rss-health",
"Category": "Health",
"Enabled": true
},
{
"Name": "CBC Technology & Science",
"Url": "https://www.cbc.ca/cmlink/rss-technology",
"Category": "Technology",
"Enabled": true
},
{
"Name": "CBC Arts & Entertainment",
"Url": "https://www.cbc.ca/cmlink/rss-arts",
"Category": "Arts",
"Enabled": true
},
{
"Name": "CBC Sports",
"Url": "https://www.cbc.ca/cmlink/rss-sports",
"Category": "Sports",
"Enabled": true
},
{
"Name": "CBC Local Ottawa",
"Url": "https://www.cbc.ca/cmlink/rss-canada-ottawa",
"Category": "Local",
"Region": "Ottawa",
"Enabled": true
},
{
"Name": "CBC Local Toronto",
"Url": "https://www.cbc.ca/cmlink/rss-canada-toronto",
"Category": "Local",
"Region": "Toronto",
"Enabled": true
},
{
"Name": "CBC Local Montreal",
"Url": "https://www.cbc.ca/cmlink/rss-canada-montreal",
"Category": "Local",
"Region": "Montreal",
"Enabled": true
},
{
"Name": "CBC Local Vancouver",
"Url": "https://www.cbc.ca/cmlink/rss-canada-britishcolumbia",
"Category": "Local",
"Region": "Vancouver",
"Enabled": true
},
{
"Name": "CBC Local Calgary",
"Url": "https://www.cbc.ca/cmlink/rss-canada-calgary",
"Category": "Local",
"Region": "Calgary",
"Enabled": true
},
{
"Name": "CBC Local Edmonton",
"Url": "https://www.cbc.ca/cmlink/rss-canada-edmonton",
"Category": "Local",
"Region": "Edmonton",
"Enabled": true
},
{
"Name": "CBC Local Winnipeg",
"Url": "https://www.cbc.ca/cmlink/rss-canada-manitoba",
"Category": "Local",
"Region": "Winnipeg",
"Enabled": true
},
{
"Name": "CBC Local Halifax",
"Url": "https://www.cbc.ca/cmlink/rss-canada-novascotia",
"Category": "Local",
"Region": "Halifax",
"Enabled": true
},
{
"Name": "CBC Local St. John's",
"Url": "https://www.cbc.ca/cmlink/rss-canada-newfoundland",
"Category": "Local",
"Region": "St. John's",
"Enabled": true
}
],
"WeatherNetwork": [
{
"Name": "WeatherNetwork National Weather",
"Url": "",
"Category": "Weather",
"Enabled": false
},
{
"Name": "WeatherNetwork Regional Feed",
"Url": "",
"Category": "Weather",
"Enabled": false
}
],
"Other": []
}
}

View File

@ -1,61 +0,0 @@
# Telefact — Changelog
Project: Telefact
Repository: Leak Technologies
Language: Python (Tkinter Implementation)
Version Line: v0.1.x (Foundational Builds)
Grid Layout: 40×24 Teletext-Compatible
------------------------------------------------------------
## [v0.1.2] — Header Finalization & Renderer Upgrade (2025-11-03)
### Added
- Full background rendering support in TelefactRenderer
- Each cell now supports independent background colour
- Renderer draws proper CRT-style safe-area margins
- TelefactHeader finalized
- Centered "Telefact" block with blue background
- White page numbers (P100 / 100)
- Yellow live-updating clock (%b%d %H:%M:%S)
- Accurate Ceefax-style alignment maintained across full grid width
- Config.json updated with Header section for colour and text configuration
- Updated TelefactFrame to support (char, fg, bg) tuples
### Fixed
- Misaligned header text and spacing between elements
- Incorrect handling of colour blocks for service provider
- Missing current_page display now restored
### Structure
telefact/
├── src/
│ ├── core/
│ │ ├── telefact_frame.py
│ │ ├── telefact_header.py
│ │ └── telefact_formatter.py
│ ├── telefact_renderer.py
│ └── config_manager.py
├── docs/
│ ├── README.md
│ └── CHANGELOG.md
### Next Planned (v0.1.3)
- Introduce footer renderer with subpage counter and colored background band
- Add configuration-driven page rotation logic
- Implement "page tuning" animation for realism (Ceefax-style search effect)
------------------------------------------------------------
## [v0.1.1] — Header Alignment Prototype (2025-11-02)
- Implemented basic header text alignment
- Added real-time clock display without background colour support
- Began refinement of grid-safe text spacing
------------------------------------------------------------
## [v0.1.0] — Initial Python Framework (2025-11-01)
- Core grid model (TelefactFrame) defines 40×24 matrix
- Base TelefactRenderer with safe-area calibration
- Preliminary test layout with header, body, and footer
- Initial config.json support for font, size, and colour

View File

@ -1,119 +0,0 @@
# Telefact — Python Edition
Telefact is a modern re-creation of a Ceefax-style Teletext broadcasting system, written in Python using the Tkinter library.
It emulates the visual and structural behaviour of broadcast Teletext systems, maintaining a strict 40×24 character grid layout.
This version focuses on the "Broadcaster" mode, replicating the look and timing of early digital information services such as BBC Ceefax and Channel 4 Oracle.
------------------------------------------------------------
VERSION INFORMATION
------------------------------------------------------------
Version: v0.1.2
Language: Python 3.13
Interface: Tkinter (standard library)
Target: Desktop application (Linux / Windows)
Resolution: 40×24 Teletext grid
Font: Modeseven (Teletext recreation)
------------------------------------------------------------
PROJECT GOALS
------------------------------------------------------------
- Faithfully reproduce the look and feel of classic Teletext pages.
- Maintain accurate grid alignment for all text and graphics.
- Provide a configurable header, footer, and body layout.
- Simulate real broadcast elements such as page numbers and timestamps.
- Establish the foundation for future expansion into dynamic page rotation and live content updates.
------------------------------------------------------------
CURRENT FEATURES
------------------------------------------------------------
- 40×24 Teletext grid rendering via TelefactRenderer.
- Safe-area margins to simulate CRT overscan.
- Dynamic Ceefax-style header displaying:
- Selected page number (e.g., P100)
- Service provider name block (e.g., Telefact)
- Current page number (e.g., 100)
- Real-time clock (updates every second)
- Configurable colours and font settings via config.json.
- Keyboard shortcuts:
- Q or ESC to exit the application.
------------------------------------------------------------
SOURCE STRUCTURE
------------------------------------------------------------
telefact/
├── src/
│ ├── core/
│ │ ├── telefact_frame.py
│ │ ├── telefact_header.py
│ │ └── telefact_formatter.py
│ ├── telefact_renderer.py
│ └── config_manager.py
├── docs/
│ ├── README.md
│ └── CHANGELOG.md
├── assets/
│ └── fonts/
│ ├── Modeseven.ttf
│ ├── EuropeanTeletext.ttf
│ └── EuropeanTeletextNuevo.ttf
└── main.py
------------------------------------------------------------
CONFIGURATION OVERVIEW
------------------------------------------------------------
The configuration file is located at:
config/config.json
Example:
{
"Mode": "Broadcaster",
"ScreenWidth": 800,
"ScreenHeight": 600,
"Font": {
"Name": "Modeseven",
"Path": "assets/fonts/Modeseven.ttf",
"Size": 18
},
"ShowGrid": false,
"Colours": {
"Header": "blue",
"Footer": "red",
"Background": "black",
"TextPrimary": "white",
"TextAccent": "yellow",
"Clock": "yellow"
},
"Header": {
"ServiceName": "Telefact",
"ServiceFG": "yellow",
"ServiceBG": "blue",
"SelectedPage": "P100",
"CurrentPage": "100"
}
}
------------------------------------------------------------
ROADMAP
------------------------------------------------------------
v0.1.3 — Footer System and Subpage Counter
- Implement footer band with coloured background and subpage indicator.
- Integrate page tuning animation (Ceefax-style).
- Add configuration options for rotation speed and page timing.
v0.2.0 — Page System Expansion
- Introduce multiple page definitions with rotation logic.
- Implement Teletext page index and feed loading.
- Begin adding dynamic content from local cache and static feeds.
v0.3.0 — Broadcaster Automation
- Simulate channel loop broadcasting through scheduled pages.
- Add simple playback manager for testing page timing.
------------------------------------------------------------
AUTHOR AND COPYRIGHT
------------------------------------------------------------
Developed by Stu Leak
Leak Technologies (2025)
All rights reserved.
Fonts: Modeseven and European Teletext by their respective creators.

58
main.py
View File

@ -1,64 +1,32 @@
# ============================================================ """
# File: /telefact/main.py Telefact minimal runner
# Description: - Opens a 4:3 800×600 window
# Entry point for the Telefact Broadcaster mode. - Renders header (blue band) and footer (red band)
# Initializes the Tkinter window, loads configuration, - No formatter yet; thats next.
# and renders the Teletext grid with dynamic header/footer. """
# ============================================================
import tkinter as tk import tkinter as tk
from src.config_manager import ConfigManager
from src.telefact_renderer import TelefactRenderer from src.telefact_renderer import TelefactRenderer
from src.core.telefact_frame import TelefactFrame from src.core.telefact_frame import TelefactFrame
from src.core.telefact_formatter import TelefactFormatter
from src.core.telefact_header import TelefactHeader
from src.core.telefact_footer import TelefactFooter
def main(): def main():
# --- Load configuration ---
config = ConfigManager().config
# --- Initialize Tkinter window ---
root = tk.Tk() root = tk.Tk()
root.title(f"Telefact — Broadcaster ({config['Mode']})") root.title("Telefact — Broadcaster Prototype")
# --- Create renderer --- renderer = TelefactRenderer(root, width=800, height=600, show_grid=False)
renderer = TelefactRenderer(
root,
width=config["ScreenWidth"],
height=config["ScreenHeight"],
show_grid=config.get("ShowGrid", False),
font_path=config["Font"]["Path"],
font_size=config["Font"]["Size"],
)
# --- Prepare frame ---
frame = TelefactFrame() frame = TelefactFrame()
# --- Header --- # put a couple test glyphs so you can confirm alignment quickly
header = TelefactHeader(frame, config) for i, ch in enumerate("TELEFACT"):
header.render() frame.set_cell(2 + i, 2, ch, "yellow")
header.update_time(root, renderer) for i, ch in enumerate("BROADCASTER BASE"):
frame.set_cell(2 + i, 4, ch, "white")
# --- Body content ---
formatter = TelefactFormatter(frame)
formatter.add_body_line(1, "Welcome to the Telefact Broadcaster base.", align="center", color="white")
formatter.add_body_line(3, "Press Q or ESC to exit.", align="center", color="cyan")
# --- Footer (replaces old PAGE 100 TELEFACT) ---
footer = TelefactFooter(frame, config)
footer.set_footer("Telefact: The world at your fingertips", align="left", fg="yellow", bg="blue")
# --- Render everything ---
renderer.render(frame) renderer.render(frame)
# --- Bind exit keys ---
root.bind("<Escape>", lambda e: root.quit()) root.bind("<Escape>", lambda e: root.quit())
root.bind("q", lambda e: root.quit()) root.bind("q", lambda e: root.quit())
root.mainloop() root.mainloop()
if __name__ == "__main__": if __name__ == "__main__":
main() main()

View File

@ -1,135 +0,0 @@
# ============================================================
# File: src/config/feed_loader.py
# Description:
# Loads and filters RSS feed definitions from /config/feeds.json.
# Currently defaults to the Ottawa region for stable offline
# development. Region aliases and geolocation support remain
# in place for future activation.
# ============================================================
import os
import json
from src.core.terminal_colors import TerminalColors as C
# ------------------------------------------------------------
# Region alias table for common Canadian cities/suburbs
# ------------------------------------------------------------
REGION_ALIASES = {
# Ontario
"cornwall": "Ottawa",
"south glengarry": "Ottawa",
"glengarry": "Ottawa",
"kingston": "Ottawa",
"belleville": "Ottawa",
# Québec (map suburbs to Montréal)
"montreal": "Montreal",
"laval": "Montreal",
"leval": "Montreal", # typo guard
"terrebonne": "Montreal",
"longueuil": "Montreal",
"brossard": "Montreal",
"repentigny": "Montreal",
"l'île-perrot": "Montreal",
"saint-lambert": "Montreal",
# British Columbia
"vancouver": "Vancouver",
"burnaby": "Vancouver",
"surrey": "Vancouver",
"richmond": "Vancouver",
# Alberta
"calgary": "Calgary",
"edmonton": "Edmonton",
# Manitoba
"winnipeg": "Winnipeg",
# Nova Scotia
"halifax": "Halifax",
# Newfoundland and Labrador
"st. john's": "St. John's",
"saint johns": "St. John's",
}
class FeedLoader:
"""Handles loading and filtering RSS feed definitions."""
def __init__(self, config_dir: str = "config", region_override: str | None = None):
self.config_path = os.path.join(config_dir, "feeds.json")
# For now, IP-based detection is disabled.
# Use region_override if provided, otherwise default to Ottawa.
self.region = region_override or "Ottawa"
self.all_feeds = {}
self.active_feeds = []
self._load_json()
self._filter_active_feeds()
# ------------------------------------------------------------
def _load_json(self) -> None:
"""Loads feeds.json and parses all sections."""
if not os.path.exists(self.config_path):
raise FileNotFoundError(f"{C.ERROR}[error]{C.RESET} Missing feeds.json at {self.config_path}")
with open(self.config_path, "r", encoding="utf-8") as file:
try:
data = json.load(file)
except json.JSONDecodeError as e:
raise ValueError(f"{C.ERROR}[error]{C.RESET} Invalid JSON in feeds.json: {e}")
if "Feeds" not in data or not isinstance(data["Feeds"], dict):
raise ValueError(f"{C.ERROR}[error]{C.RESET} feeds.json must contain a 'Feeds' dictionary.")
self.all_feeds = data["Feeds"]
# ------------------------------------------------------------
def _filter_active_feeds(self) -> None:
"""Filters feeds for the detected region and enabled status."""
active = []
for provider, feed_list in self.all_feeds.items():
for feed in feed_list:
if not feed.get("Enabled", False):
continue
feed_region = feed.get("Region")
if feed_region and feed_region != self.region:
continue # Skip other CBC regions
active.append({
"Provider": provider,
"Name": feed.get("Name", "Unnamed Feed"),
"Url": feed.get("Url"),
"Category": feed.get("Category", "General"),
"Region": feed.get("Region", None)
})
self.active_feeds = active
print(f"{C.INFO}[info]{C.RESET} Loaded {len(self.active_feeds)} active feeds for region: {C.SUCCESS}{self.region}{C.RESET}")
# ------------------------------------------------------------
def get_active_feeds(self) -> list[dict]:
"""Returns the list of active region-appropriate feeds."""
return self.active_feeds
def get_region(self) -> str:
"""Returns the currently set region."""
return self.region
# ------------------------------------------------------------
# Example usage (manual testing)
# ------------------------------------------------------------
if __name__ == "__main__":
loader = FeedLoader()
feeds = loader.get_active_feeds()
print(f"{C.INFO}Detected Region:{C.RESET} {C.SUCCESS}{loader.get_region()}{C.RESET}")
for feed in feeds:
print(f"{C.DEBUG}- {feed['Name']} ({feed['Url']}) [{feed['Provider']}] {C.RESET}")

View File

@ -1,22 +0,0 @@
import json
import os
class ConfigManager:
def __init__(self, config_path: str = "config/config.json"):
self.config_path = config_path
self.config = self._load()
def _load(self):
if not os.path.exists(self.config_path):
raise FileNotFoundError(f"Config file not found: {self.config_path}")
with open(self.config_path, "r", encoding="utf-8") as f:
return json.load(f)
def get(self, key, default=None):
"""Return a top-level config key, e.g. 'Font' or 'Mode'."""
return self.config.get(key, default)
def save(self):
"""Write any changes back to disk."""
with open(self.config_path, "w", encoding="utf-8") as f:
json.dump(self.config, f, indent=2)

View File

@ -1,71 +0,0 @@
# ============================================================
# File: /home/stu/Projects/Local REPO/telefact/src/core/telefact_footer.py
# Description:
# Dynamic Telefact footer renderer for broadcaster mode.
# Draws a single bottom-row text band with full background fill.
# ============================================================
from src.core.telefact_frame import TelefactFrame
class TelefactFooter:
"""
Ceefax-style footer (bottom band).
Example:
Telefact: The world at your fingertips
"""
def __init__(self, frame: TelefactFrame, config: dict):
self.frame = frame
self.config = config
# Default colors
colours = config.get("Colours", {})
self.default_bg = colours.get("Footer", "blue")
self.default_fg = colours.get("TextPrimary", "white")
# Reserved row (bottom row = line 24)
self.row = frame.rows - 1
# --------------------------------------------------------
def clear_footer(self, bg: str | None = None):
"""Clears the footer row with solid background color."""
bg_color = bg or self.default_bg
for c in range(self.frame.cols):
self.frame.set_cell(c, self.row, " ", self.default_fg, bg_color)
# --------------------------------------------------------
def set_footer(self, text: str, align: str = "left", fg: str | None = None, bg: str | None = None):
"""Draw footer message with proper background fill."""
fg_color = fg or self.default_fg
bg_color = bg or self.default_bg
self.clear_footer(bg_color)
text = text.strip()
text_len = len(text)
# Alignment
if align == "center":
start_col = (self.frame.cols - text_len) // 2
elif align == "right":
start_col = max(self.frame.cols - text_len - 1, 0)
else:
start_col = 1 # left padding
# Draw each character with full background
for i, ch in enumerate(text):
pos = start_col + i
if 0 <= pos < self.frame.cols:
self.frame.set_cell(pos, self.row, ch, fg_color, bg_color)
# --------------------------------------------------------
def render_test_footer(self):
"""Temporary demo for testing footer display."""
self.set_footer(
"Telefact: The world at your fingertips",
align="left",
fg="yellow",
bg="blue",
)

View File

@ -1,76 +0,0 @@
"""
Telefact Formatter
------------------
Handles layout and text placement for header, body, and footer regions
within a 40×24 Telefact frame.
This keeps Teletext-style alignment and line boundaries.
"""
from typing import Literal
from src.core.telefact_frame import TelefactFrame
class TelefactFormatter:
"""Provides structured access to write header, body, and footer text."""
def __init__(self, frame: TelefactFrame):
self.frame = frame
# Define region boundaries (row numbers)
self.header_row = 0
self.footer_row = frame.rows - 1
self.body_start = 1
self.body_end = self.footer_row - 1
# ------------------------------------------------------------------
# text placement utilities
# ------------------------------------------------------------------
def _place_text(
self,
text: str,
row: int,
align: Literal["left", "center", "right"] = "left",
color: str = "white",
pad_char: str = " ",
) -> None:
"""Place text on a given row with Teletext-style alignment."""
max_width = self.frame.cols
text = text[:max_width] # clip overflow
if align == "left":
col_start = 0
elif align == "center":
col_start = max(0, (max_width - len(text)) // 2)
else: # right
col_start = max(0, max_width - len(text))
# pad to maintain clean grid line
padded = text.ljust(max_width, pad_char)
for i, ch in enumerate(padded):
self.frame.set_cell(col_start + i, row, ch, color)
# ------------------------------------------------------------------
# header/body/footer helpers
# ------------------------------------------------------------------
def set_header(self, text: str, align="center", color="yellow") -> None:
"""Write the header row (row 0)."""
self._place_text(text, self.header_row, align, color)
def add_body_line(
self, line_num: int, text: str, align="left", color="white"
) -> None:
"""Write a line in the body area (rows 122)."""
target_row = self.body_start + line_num
if self.body_start <= target_row <= self.body_end:
self._place_text(text, target_row, align, color)
def set_footer(self, text: str, align="center", color="cyan") -> None:
"""Write the footer row (row 23)."""
self._place_text(text, self.footer_row, align, color)
# ------------------------------------------------------------------
# convenience
# ------------------------------------------------------------------
def clear(self) -> None:
"""Clear the frame (fills with spaces)."""
self.frame.clear()

View File

@ -1,67 +1,42 @@
# ============================================================ """
# File: /telefact/src/core/telefact_frame.py Telefact Frame (model)
# Description: - Defines a 40×24 grid and logical regions.
# Telefact Frame (model) - No rendering herepure data model.
# Defines a 40×24 grid and logical regions for Teletext layout. """
# Each cell now supports both foreground and background colours.
# This file contains no rendering logic — pure data model.
# ============================================================
class TelefactFrame: class TelefactFrame:
def __init__(self, cols: int = 40, rows: int = 24): def __init__(self, cols: int = 40, rows: int = 24):
self.cols = cols self.cols = cols
self.rows = rows self.rows = rows
# Define Teletext-like regions
self.header_rows = 1 self.header_rows = 1
self.footer_rows = 1 self.footer_rows = 1
self.body_rows = self.rows - (self.header_rows + self.footer_rows) self.body_rows = self.rows - (self.header_rows + self.footer_rows)
# grid[row][col] -> (char, fg_color, bg_color) # grid[row][col] -> (char, color_name)
self.grid = [ self.grid = [[(" ", "white") for _ in range(self.cols)] for _ in range(self.rows)]
[(" ", "white", "black") for _ in range(self.cols)]
for _ in range(self.rows)
]
# -------------------------------------------------------- # basic cell ops
# Basic cell operations def set_cell(self, col: int, row: int, char: str, color: str = "white"):
# --------------------------------------------------------
def set_cell(
self,
col: int,
row: int,
char: str,
fg_color: str = "white",
bg_color: str = "black",
):
"""Sets a character and both colours in a given grid cell."""
if 0 <= col < self.cols and 0 <= row < self.rows and char: if 0 <= col < self.cols and 0 <= row < self.rows and char:
self.grid[row][col] = (char[0], fg_color, bg_color) self.grid[row][col] = (char[0], color)
def get_cell(self, col: int, row: int): def get_cell(self, col: int, row: int):
"""Returns (char, fg, bg) for the given grid position."""
if 0 <= col < self.cols and 0 <= row < self.rows: if 0 <= col < self.cols and 0 <= row < self.rows:
return self.grid[row][col] return self.grid[row][col]
return (" ", "white", "black") return (" ", "white")
def clear(self, char: str = " ", fg_color: str = "white", bg_color: str = "black"): def clear(self, char: str = " ", color: str = "white"):
"""Clears the entire frame to a given colour and character."""
for r in range(self.rows): for r in range(self.rows):
for c in range(self.cols): for c in range(self.cols):
self.grid[r][c] = (char, fg_color, bg_color) self.grid[r][c] = (char, color)
# -------------------------------------------------------- # regions
# Region helpers
# --------------------------------------------------------
def header_region(self): def header_region(self):
"""Row indices for header region (top row)."""
return range(0, self.header_rows) return range(0, self.header_rows)
def body_region(self): def body_region(self):
"""Row indices for main content area."""
return range(self.header_rows, self.header_rows + self.body_rows) return range(self.header_rows, self.header_rows + self.body_rows)
def footer_region(self): def footer_region(self):
"""Row indices for footer region (bottom row)."""
return range(self.rows - self.footer_rows, self.rows) return range(self.rows - self.footer_rows, self.rows)

View File

@ -1,114 +0,0 @@
# ============================================================
# File: /telefact/src/core/telefact_header.py
# Description:
# Dynamic Telefact header renderer for broadcaster mode.
# Displays page numbers, centered service name block with
# colored background, and a live timestamp.
# ============================================================
import tkinter as tk
from datetime import datetime
from src.core.telefact_frame import TelefactFrame
class TelefactHeader:
"""
Final layout (Ceefax style):
P100 Telefact 100 Nov03 12:53:10
"""
def __init__(self, frame: TelefactFrame, config: dict):
self.frame = frame
self.config = config
# --- Read config values ---
colours = config.get("Colours", {})
header_cfg = config.get("Header", {})
# Service name & colours
self.service_name = header_cfg.get("ServiceName", "Telefact")
self.service_fg = header_cfg.get("ServiceFG", colours.get("TextAccent", "yellow"))
self.service_bg = header_cfg.get("ServiceBG", colours.get("Header", "blue"))
# Page numbers
self.selected_page = header_cfg.get("SelectedPage", "P100")
self.current_page = header_cfg.get("CurrentPage", "100")
# Colours
self.text_white = colours.get("TextPrimary", "white")
self.time_yellow = colours.get("Clock", "yellow")
# Layout
self.padding = 1
self.time_format = "%b%d %H:%M:%S"
# --------------------------------------------------------
def _clear_header_row(self):
"""Clears only the top row."""
for c in range(self.frame.cols):
self.frame.set_cell(c, 0, " ", self.text_white, "black")
def _draw_text(self, col: int, text: str, color: str) -> int:
"""Draws text from a given column; returns new cursor position."""
for i, ch in enumerate(text):
pos = col + i
if pos < self.frame.cols:
self.frame.set_cell(pos, 0, ch, color, "black")
return col + len(text)
def _draw_service_block(self, text: str, fg: str, bg: str, left_bound: int, right_bound: int):
"""Draw centered service block with full blue background."""
block = f"{' ' * self.padding}{text}{' ' * self.padding}"
total_space = right_bound - left_bound
start_col = left_bound + (total_space // 2) - (len(block) // 2)
start_col = max(start_col, 0)
end_col = start_col + len(block)
# --- Fill background cells properly ---
for c in range(start_col, end_col):
if 0 <= c < self.frame.cols:
self.frame.set_cell(c, 0, " ", bg, bg)
# --- Overlay text with proper foreground and background ---
for i, ch in enumerate(text):
pos = start_col + i + self.padding
if 0 <= pos < self.frame.cols:
self.frame.set_cell(pos, 0, ch, fg, bg)
# --------------------------------------------------------
def render(self):
"""Renders header row with page numbers, service, and clock."""
self._clear_header_row()
# Left: selected page
col_left = 0
col_left = self._draw_text(col_left, self.selected_page, self.text_white)
col_left += 2 # spacing after
# Right: timestamp (always yellow)
time_text = datetime.now().strftime(self.time_format)
right_start = self.frame.cols - len(time_text)
self._draw_text(right_start, time_text, self.time_yellow)
# Mid-right: current page number (white)
current_page_len = len(self.current_page)
current_page_start = right_start - (current_page_len + 3)
self._draw_text(current_page_start, self.current_page, self.text_white)
# Center: service provider (blue block)
self._draw_service_block(
self.service_name,
self.service_fg,
self.service_bg,
left_bound=col_left,
right_bound=current_page_start - 1,
)
# --------------------------------------------------------
def update_time(self, root: tk.Tk, renderer, interval_ms: int = 1000):
"""Updates time every second without redrawing other rows."""
self.render()
renderer.render(self.frame)
root.after(interval_ms, lambda: self.update_time(root, renderer, interval_ms))

View File

@ -1,31 +0,0 @@
# ============================================================
# File: src/core/terminal_colors.py
# Description:
# Provides ANSI color codes for structured console logging.
# Used across all Telefact modules (FeedLoader, RSSHandler,
# Renderer, etc.) for consistent, readable terminal output.
# ============================================================
class TerminalColors:
"""ANSI terminal color codes for clean, readable logs."""
RESET = "\033[0m"
# Core log levels
INFO = "\033[96m" # Cyan
WARN = "\033[93m" # Yellow
ERROR = "\033[91m" # Red
SUCCESS = "\033[92m" # Green
DEBUG = "\033[90m" # Grey
# Optional stylistic colors for subsystem tags
HEADER = "\033[95m" # Magenta
TITLE = "\033[94m" # Blue
DATE = "\033[32m" # Green (for timestamps)
SECTION = "\033[36m" # Cyan for dividers
@staticmethod
def format(text: str, color: str) -> str:
"""Quick helper for inline colored text."""
return f"{color}{text}{TerminalColors.RESET}"

View File

@ -1,262 +0,0 @@
# ============================================================
# File: src/rss/rss_feedHandler.py
# Description:
# Handles RSS feed retrieval, caching, and validation for
# the Telefact broadcaster. Uses FeedLoader for region-aware
# feed selection and stores cached data under Cache/Feeds/<Region>.
# Automatically refreshes feeds only if the cache is older than
# the configured update interval (default: 10 minutes).
# Optimized for CBC RSS: ignores image-only items and non-story types.
# ============================================================
import os
import re
import json
import time
import requests
import xml.etree.ElementTree as ET
from html import unescape
from datetime import datetime
from src.config.feed_loader import FeedLoader
from src.core.terminal_colors import TerminalColors as C
class RSSFeedHandler:
"""Handles downloading, caching, and parsing of RSS feeds."""
def __init__(
self,
cache_dir: str = "Cache/Feeds",
config_dir: str = "config",
cache_duration_minutes: int = 10,
story_limit: int = 6,
):
self.cache_dir = cache_dir
self.cache_duration = cache_duration_minutes * 60 # seconds
self.feed_loader = FeedLoader(config_dir=config_dir)
self.region = self.feed_loader.get_region()
self.story_limit = story_limit
self._ensure_cache_dirs()
# ------------------------------------------------------------
def _ensure_cache_dirs(self) -> None:
"""Ensures regional cache directories exist."""
region_path = os.path.join(self.cache_dir, self.region)
os.makedirs(region_path, exist_ok=True)
# ------------------------------------------------------------
def _get_cache_path(self, feed_name: str) -> str:
"""Generates path to feed cache file."""
safe_name = feed_name.replace(" ", "_").replace("/", "_")
return os.path.join(self.cache_dir, self.region, f"{safe_name}.json")
# ------------------------------------------------------------
def _is_cache_valid(self, path: str) -> bool:
"""Returns True if cache exists and is within valid time."""
if not os.path.exists(path):
return False
age = time.time() - os.path.getmtime(path)
return age < self.cache_duration
# ------------------------------------------------------------
def _fetch_rss(self, url: str) -> str | None:
"""Fetches raw RSS XML from a given URL with retry & spoofed UA."""
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/121.0 Safari/537.36"
)
}
for attempt in range(3):
try:
response = requests.get(url, headers=headers, timeout=20)
if response.status_code == 200:
return response.text
print(f"{C.WARN}[warn]{C.RESET} RSS fetch failed ({response.status_code}) for {url}")
break # non-200 is not retryable
except requests.Timeout:
print(f"{C.WARN}[warn]{C.RESET} Timeout {attempt + 1}/3 for {url}")
except requests.RequestException as e:
print(f"{C.WARN}[warn]{C.RESET} RSS fetch error ({attempt + 1}/3): {e}")
time.sleep(2 ** attempt) # backoff: 1s, 2s, 4s
return None
# ------------------------------------------------------------
def _strip_html(self, text: str) -> str:
"""Removes HTML tags and decodes entities."""
clean = re.sub(r"<img[^>]*>", "", text)
clean = re.sub(r"<[^>]+>", "", clean)
clean = unescape(clean)
return clean.strip()
# ------------------------------------------------------------
def _parse_rss(self, xml_data: str) -> list[dict]:
"""Parses RSS XML into a list of story dictionaries, CBC-optimized."""
try:
root = ET.fromstring(xml_data)
except ET.ParseError as e:
print(f"{C.WARN}[warn]{C.RESET} XML parse error: {e}")
return []
channel = root.find("channel")
if channel is None:
return []
stories = []
for item in channel.findall("item"):
cbc_type = ""
deptid = ""
for child in item:
tag = child.tag.lower()
if tag.endswith("type"):
cbc_type = (child.text or "").strip()
elif tag.endswith("deptid"):
deptid = (child.text or "").strip()
# Skip if not a story
if cbc_type.lower() not in ("story", ""):
continue
title = item.findtext("title", "").strip()
link = item.findtext("link", "").strip()
description = item.findtext("description", "").strip()
pub_date = item.findtext("pubDate", "").strip()
# Remove CDATA wrappers
title = title.replace("<![CDATA[", "").replace("]]>", "").strip()
description = description.replace("<![CDATA[", "").replace("]]>", "").strip()
clean_text = self._strip_html(description)
if not clean_text or len(clean_text) < 20:
continue
summary = clean_text[:500].rstrip()
stories.append({
"Title": title,
"Summary": summary,
"Link": link,
"PubDate": pub_date,
"DeptID": deptid,
"Provider": "CBC",
})
if not stories:
print(f"{C.DEBUG}[debug]{C.RESET} No valid <cbc:type>story</cbc:type> entries found — possible namespace issue.")
else:
print(f"{C.DEBUG}[debug]{C.RESET} Parsed {len(stories)} stories successfully.")
stories = self._sort_stories(stories)
if self.story_limit > 0:
stories = stories[:self.story_limit]
return stories
# ------------------------------------------------------------
def _sort_stories(self, stories: list[dict]) -> list[dict]:
"""Sorts stories by publication date, newest first."""
def parse_date(pubdate: str):
try:
return datetime.strptime(pubdate, "%a, %d %b %Y %H:%M:%S %Z")
except Exception:
return datetime.min
return sorted(stories, key=lambda s: parse_date(s.get("PubDate", "")), reverse=True)
# ------------------------------------------------------------
def _load_cached_feed(self, path: str) -> list[dict]:
"""Loads cached JSON feed data if available."""
try:
with open(path, "r", encoding="utf-8") as file:
return json.load(file)
except Exception as e:
print(f"{C.WARN}[warn]{C.RESET} Could not load cache: {e}")
return []
# ------------------------------------------------------------
def _save_cache(self, path: str, stories: list[dict]) -> None:
"""Saves parsed stories to local cache."""
try:
with open(path, "w", encoding="utf-8") as file:
json.dump(
{"LastUpdated": datetime.utcnow().isoformat(), "Stories": stories},
file,
ensure_ascii=False,
indent=2,
)
except Exception as e:
print(f"{C.WARN}[warn]{C.RESET} Failed to save cache for {path}: {e}")
# ------------------------------------------------------------
def update_feeds(self, force: bool = False) -> None:
"""Fetches and caches all active feeds."""
active_feeds = self.feed_loader.get_active_feeds()
if not active_feeds:
print(f"{C.WARN}[warn]{C.RESET} No active feeds to update.")
return
print(f"{C.INFO}[info]{C.RESET} Updating {len(active_feeds)} feeds for region: {C.SUCCESS}{self.region}{C.RESET}")
for feed in active_feeds:
feed_name = feed["Name"]
feed_url = feed["Url"]
cache_path = self._get_cache_path(feed_name)
if not force and self._is_cache_valid(cache_path):
print(f"{C.DEBUG}[debug]{C.RESET} Cache valid for: {feed_name}")
continue
print(f"{C.INFO}[info]{C.RESET} Fetching: {C.TITLE}{feed_name}{C.RESET}")
xml_data = self._fetch_rss(feed_url)
if not xml_data:
continue
stories = self._parse_rss(xml_data)
if not stories:
print(f"{C.WARN}[warn]{C.RESET} No valid stories found in {feed_name}")
continue
self._save_cache(cache_path, stories)
print(f"{C.SUCCESS}[success]{C.RESET} Cached {len(stories)} stories from {feed_name}")
# ------------------------------------------------------------
def load_cached_feeds(self) -> dict[str, list[dict]]:
"""Loads all cached feeds for the current region."""
region_path = os.path.join(self.cache_dir, self.region)
if not os.path.exists(region_path):
return {}
cached_data = {}
for filename in os.listdir(region_path):
if not filename.endswith(".json"):
continue
path = os.path.join(region_path, filename)
feed_name = filename.replace(".json", "").replace("_", " ")
data = self._load_cached_feed(path)
cached_data[feed_name] = data
return cached_data
# ------------------------------------------------------------
# Example usage (manual test)
# ------------------------------------------------------------
if __name__ == "__main__":
handler = RSSFeedHandler(cache_duration_minutes=10, story_limit=6)
handler.update_feeds()
cached = handler.load_cached_feeds()
for feed, data in cached.items():
print(f"\n{C.SECTION}=== {feed} ==={C.RESET}")
if isinstance(data, dict):
stories = data.get("Stories", [])
else:
stories = data
for story in stories:
print(f"- {story['Title']}")

View File

@ -1,166 +0,0 @@
# ============================================================
# File: src/rss/rss_feedIndex.py
# Description:
# Converts cached RSS feed data into Telefact pages.
# Builds index pages (headlines) and paginated subpages
# (story summaries) using the TelefactFrame grid (40×24).
# Designed for use in broadcaster rotation.
# ============================================================
import os
import textwrap
from datetime import datetime, timezone
from src.core.telefact_frame import TelefactFrame
from src.rss.rss_feedHandler import RSSFeedHandler
from src.core.terminal_colors import TerminalColors as C
class RSSFeedIndex:
"""
Generates TelefactFrame pages and subpages from cached RSS feeds.
Produces:
- Index page (list of headlines)
- One or more subpages per story (paginated if long)
"""
def __init__(self, cache_dir: str = "Cache/Feeds", region: str | None = None):
self.handler = RSSFeedHandler(cache_dir=cache_dir)
self.region = region or self.handler.region
self.frames = {} # { page_number: [TelefactFrame, TelefactFrame, ...] }
# layout constants
self.max_width = 40
self.header_rows = 1
self.footer_rows = 1
self.body_start = self.header_rows
self.body_end = 23 # up to line 23 (0-indexed)
self.body_lines = self.body_end - self.body_start
# ------------------------------------------------------------
def _wrap_text(self, text: str) -> list[str]:
"""Breaks plain text into 40-character lines."""
wrapped = textwrap.wrap(text, width=self.max_width)
return wrapped or ["(no content)"]
# ------------------------------------------------------------
def _new_frame(self) -> TelefactFrame:
"""Create a new blank Telefact frame."""
return TelefactFrame()
# ------------------------------------------------------------
def _draw_centered(self, frame: TelefactFrame, row: int, text: str, color: str = "white"):
"""Center text horizontally within the 40-column grid."""
text = text.strip()
if len(text) > self.max_width:
text = text[:self.max_width]
start_col = max(0, (self.max_width - len(text)) // 2)
for i, ch in enumerate(text):
frame.set_cell(start_col + i, row, ch, color)
# ------------------------------------------------------------
def _build_index_page(self, feed_name: str, stories: list[dict]) -> TelefactFrame:
"""Builds a single index page listing headlines."""
frame = self._new_frame()
self._draw_centered(frame, 1, f"{feed_name.upper()} INDEX", "yellow")
# timestamp (UTC)
timestamp = datetime.now(timezone.utc).strftime("%b %d %H:%M UTC")
self._draw_centered(frame, 2, f"Updated {timestamp}", "green")
row = 4
for i, story in enumerate(stories[: self.body_lines - 4]):
title = story.get("Title", "").strip()
if not title:
continue
display = f"{i + 1:02d}. {title[: self.max_width - 5]}"
for j, ch in enumerate(display):
if j < self.max_width:
frame.set_cell(j, row, ch, "white")
row += 1
self._draw_centered(frame, 23, "Telefact: The world at your fingertips", "cyan")
return frame
# ------------------------------------------------------------
def _build_story_subpages(self, story: dict) -> list[TelefactFrame]:
"""Builds one or more subpages for a story (paginated)."""
title = story.get("Title", "").strip()
body = story.get("Summary", "").strip()
pubdate = story.get("PubDate", "").strip()
wrapped_body = self._wrap_text(body)
lines_per_page = self.body_lines - 4 # reserve rows for title/date/footer
chunks = [
wrapped_body[i : i + lines_per_page]
for i in range(0, len(wrapped_body), lines_per_page)
]
subpages = []
total_pages = len(chunks)
for page_num, chunk in enumerate(chunks, start=1):
frame = self._new_frame()
self._draw_centered(frame, 1, title, "yellow")
if pubdate:
self._draw_centered(frame, 2, pubdate, "green")
row = 4
for line in chunk:
for j, ch in enumerate(line[: self.max_width]):
frame.set_cell(j, row, ch, "white")
row += 1
footer_text = f"Page {page_num}/{total_pages}"
self._draw_centered(frame, 23, footer_text, "cyan")
subpages.append(frame)
return subpages
# ------------------------------------------------------------
def build_all_pages(self):
"""
Loads cached RSS feeds and builds Teletext-ready pages.
Returns a dict mapping feed names to their page structures:
{ "Top Stories": {"index": TelefactFrame, "subpages": [frames...]}, ... }
"""
cached = self.handler.load_cached_feeds()
if not cached:
print(f"{C.WARN}[warn]{C.RESET} No cached feeds found.")
return {}
page_map = {}
for feed_name, data in cached.items():
stories = []
if isinstance(data, dict):
stories = data.get("Stories", [])
elif isinstance(data, list):
stories = data
else:
continue
if not stories:
continue
index_frame = self._build_index_page(feed_name, stories)
subpages = []
for story in stories:
subpages.extend(self._build_story_subpages(story))
page_map[feed_name] = {"index": index_frame, "subpages": subpages}
print(f"{C.INFO}[info]{C.RESET} Built {len(page_map)} indexed feeds into TelefactFrames.")
return page_map
# ------------------------------------------------------------
# Manual test harness
# ------------------------------------------------------------
if __name__ == "__main__":
indexer = RSSFeedIndex()
pages = indexer.build_all_pages()
for feed_name, page_data in pages.items():
print(f"\n{C.SUCCESS}Feed:{C.RESET} {feed_name}")
subpage_count = len(page_data['subpages'])
print(f" {C.DEBUG}- Index ready, {subpage_count} story subpages.{C.RESET}")

View File

@ -1,20 +1,13 @@
# ============================================================ """
# File: /telefact/src/telefact_renderer.py Telefact Renderer (Tkinter)
# Description: - Renders a TelefactFrame to a Canvas.
# Telefact Renderer (Tkinter) - Pure Python (stdlib only).
# Renders a 40×24 Telefact grid with full foreground/background """
# colour support, perfectly centered inside the window.
# ============================================================
import os
import tkinter as tk import tkinter as tk
from tkinter import font as tkfont
from typing import Dict from typing import Dict
from src.core.telefact_frame import TelefactFrame from src.core.telefact_frame import TelefactFrame
# ------------------------------------------------------------
# Standard Teletext colour palette (8 basic colours)
# ------------------------------------------------------------
PALETTE: Dict[str, str] = { PALETTE: Dict[str, str] = {
"black": "#000000", "black": "#000000",
"blue": "#0000FF", "blue": "#0000FF",
@ -26,156 +19,79 @@ PALETTE: Dict[str, str] = {
"white": "#FFFFFF", "white": "#FFFFFF",
} }
class TelefactRenderer: class TelefactRenderer:
def __init__( def __init__(self, root: tk.Tk, width: int = 800, height: int = 600, show_grid: bool = False):
self,
root: tk.Tk,
width: int = 880,
height: int = 660,
show_grid: bool = True,
colors: dict | None = None,
font_path: str | None = None,
font_size: int = 18,
):
self.root = root self.root = root
self.width = width self.width = width
self.height = height self.height = height
self.show_grid = show_grid self.show_grid = show_grid
# Teletext logical dimensions # 40×24 grid for Telefact pages
self.cols, self.rows = 40, 24 self.cols, self.rows = 40, 24
self.cell_w = max(1, width // self.cols)
self.cell_h = max(1, height // self.rows)
self.w = self.cell_w * self.cols
self.h = self.cell_h * self.rows
# --- Dynamic centered layout ---
# Define safe area target margins
safe_margin_x = 24
safe_margin_y = 24
# Compute total drawable region
self.inner_w = width - safe_margin_x * 2
self.inner_h = height - safe_margin_y * 2
# Compute exact per-cell dimensions (float precision)
self.cell_w = self.inner_w / self.cols
self.cell_h = self.inner_h / self.rows
# Compute corrected total grid size (may differ slightly due to float math)
total_grid_w = self.cell_w * self.cols
total_grid_h = self.cell_h * self.rows
# --- Center grid on screen ---
self.margin_x = (width - total_grid_w) / 2
self.margin_y = (height - total_grid_h) / 2
# Cache total grid extents
self.w = total_grid_w
self.h = total_grid_h
# Base colours
self.colors = {
"Background": "black",
"Grid": "#303030",
}
if colors:
self.colors.update(colors)
# Canvas setup
self.canvas = tk.Canvas( self.canvas = tk.Canvas(
root, root, width=self.w, height=self.h,
width=self.width, highlightthickness=0, bg=PALETTE["black"]
height=self.height,
highlightthickness=0,
bg=PALETTE.get(self.colors["Background"], self.colors["Background"]),
) )
self.canvas.pack() self.canvas.pack()
# Font setup # simple monospace font; will swap for Teletext font later
self.font = self._load_font(font_path, font_size) self.font = ("Courier New", max(12, self.cell_h - 9), "bold")
# ------------------------------------------------------------------ # ----- low-level drawing helpers -----
def _load_font(self, font_path: str | None, font_size: int) -> tuple: def _gx(self, col: int) -> int:
"""Loads Teletext font or falls back to Courier New.""" return col * self.cell_w
if font_path and os.path.exists(font_path):
try:
font_name = os.path.splitext(os.path.basename(font_path))[0]
try:
self.root.tk.call(
"font", "create", font_name, "-family", font_name, "-size", font_size
)
except tk.TclError:
pass
print(f"[info] Loaded Telefact font: {font_name}")
return (font_name, font_size)
except Exception as e:
print(f"[warn] Could not load custom font ({e}). Falling back to Courier New.")
elif font_path:
print(f"[warn] Font path not found: {font_path}. Using Courier New.")
return ("Courier New", font_size, "bold")
# ------------------------------------------------------------------ def _gy(self, row: int) -> int:
def _gx(self, col: int) -> float: return row * self.cell_h
"""Grid X coordinate (precise, centered)."""
return self.margin_x + col * self.cell_w
def _gy(self, row: int) -> float: def _fill_row(self, row: int, color: str) -> None:
"""Grid Y coordinate (precise, centered)."""
return self.margin_y + row * self.cell_h
# ------------------------------------------------------------------
def _draw_cell(self, col: int, row: int, char: str, fg: str, bg: str) -> None:
"""Draw a full cell (background + glyph)."""
x = self._gx(col)
y = self._gy(row)
# Background rectangle
self.canvas.create_rectangle( self.canvas.create_rectangle(
x, y, x + self.cell_w, y + self.cell_h, self._gx(0), self._gy(row), self._gx(self.cols), self._gy(row + 1),
fill=PALETTE.get(bg, bg), fill=PALETTE.get(color, color), width=0
outline=PALETTE.get(bg, bg)
) )
# Foreground glyph def _draw_char(self, col: int, row: int, char: str, color: str) -> None:
if char.strip(): x = self._gx(col) + 2
y = self._gy(row) + self.cell_h // 2
self.canvas.create_text( self.canvas.create_text(
x + 2, x, y, anchor="w", text=char,
y + self.cell_h / 2, font=self.font, fill=PALETTE.get(color, color)
anchor="w",
text=char,
font=self.font,
fill=PALETTE.get(fg, fg),
) )
# ------------------------------------------------------------------
def _draw_grid(self) -> None: def _draw_grid(self) -> None:
"""Optional grid overlay for visual debugging."""
for c in range(self.cols + 1): for c in range(self.cols + 1):
x = self._gx(c) x = self._gx(c)
self.canvas.create_line( self.canvas.create_line(x, 0, x, self.h, fill="#202020")
x, self.margin_y, x, self.margin_y + self.h, fill=self.colors["Grid"]
)
for r in range(self.rows + 1): for r in range(self.rows + 1):
y = self._gy(r) y = self._gy(r)
self.canvas.create_line( self.canvas.create_line(0, y, self.w, y, fill="#202020")
self.margin_x, y, self.margin_x + self.w, y, fill=self.colors["Grid"]
)
# ------------------------------------------------------------------ # ----- public API -----
def render(self, frame: TelefactFrame) -> None: def render(self, frame: TelefactFrame) -> None:
"""Renders the entire 40×24 Telefact grid.""" """Paint the whole frame to the canvas."""
self.canvas.delete("all") self.canvas.delete("all")
# Fill full canvas background # background
self.canvas.create_rectangle( self.canvas.create_rectangle(0, 0, self.w, self.h,
0, 0, self.width, self.height, fill=PALETTE["black"], width=0)
fill=PALETTE.get(self.colors["Background"], self.colors["Background"]),
width=0,
)
# Draw text grid (each cell: bg + fg) # header/footer colour bands
for r in frame.header_region():
self._fill_row(r, "blue")
for r in frame.footer_region():
self._fill_row(r, "red")
# draw text cells
for row in range(frame.rows): for row in range(frame.rows):
for col in range(frame.cols): for col in range(frame.cols):
ch, fg, bg = frame.get_cell(col, row) ch, fg = frame.grid[row][col]
self._draw_cell(col, row, ch, fg, bg) if ch.strip():
self._draw_char(col, row, ch, fg)
if self.show_grid: if self.show_grid:
self._draw_grid() self._draw_grid()