Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 509ff92722 | |||
| ddc5576ec1 | |||
| 211ebfcd01 | |||
| 64f0e74105 | |||
| 76ade4bf0b |
|
|
@ -1,16 +1,30 @@
|
||||||
{
|
{
|
||||||
"Mode": "Broadcaster",
|
"Mode": "Broadcaster",
|
||||||
|
|
||||||
"ScreenWidth": 800,
|
"ScreenWidth": 800,
|
||||||
"ScreenHeight": 600,
|
"ScreenHeight": 600,
|
||||||
|
"ShowGrid": false,
|
||||||
|
|
||||||
"Font": {
|
"Font": {
|
||||||
"Name": "Modeseven",
|
"Name": "Modeseven",
|
||||||
"Path": "assets/fonts/Modeseven.ttf",
|
"Path": "assets/fonts/Modeseven.ttf",
|
||||||
"Size": 18
|
"Size": 18
|
||||||
},
|
},
|
||||||
"ShowGrid": false,
|
|
||||||
"Colours": {
|
"Colours": {
|
||||||
"Header": "blue",
|
"Header": "blue",
|
||||||
"Footer": "red",
|
"Footer": "red",
|
||||||
"Background": "black"
|
"Background": "black",
|
||||||
|
"TextPrimary": "white",
|
||||||
|
"TextAccent": "yellow",
|
||||||
|
"Clock": "yellow"
|
||||||
|
},
|
||||||
|
|
||||||
|
"Header": {
|
||||||
|
"SelectedPage": "P100",
|
||||||
|
"CurrentPage": "100",
|
||||||
|
"ServiceName": "Telefact",
|
||||||
|
"ServiceFG": "yellow",
|
||||||
|
"ServiceBG": "red"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
132
config/feeds.json
Normal file
132
config/feeds.json
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
{
|
||||||
|
"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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
61
docs/CHANGELOG.md
Normal file
61
docs/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# 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
|
||||||
119
docs/README.md
Normal file
119
docs/README.md
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
# 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.
|
||||||
41
main.py
41
main.py
|
|
@ -1,33 +1,64 @@
|
||||||
|
# ============================================================
|
||||||
|
# File: /telefact/main.py
|
||||||
|
# Description:
|
||||||
|
# Entry point for the Telefact Broadcaster mode.
|
||||||
|
# Initializes the Tkinter window, loads configuration,
|
||||||
|
# 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.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
|
config = ConfigManager().config
|
||||||
|
|
||||||
|
# --- Initialize Tkinter window ---
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title(f"Telefact — Broadcaster ({config['Mode']})")
|
root.title(f"Telefact — Broadcaster ({config['Mode']})")
|
||||||
|
|
||||||
|
# --- Create renderer ---
|
||||||
renderer = TelefactRenderer(
|
renderer = TelefactRenderer(
|
||||||
root,
|
root,
|
||||||
width=config["ScreenWidth"],
|
width=config["ScreenWidth"],
|
||||||
height=config["ScreenHeight"],
|
height=config["ScreenHeight"],
|
||||||
show_grid=config.get("ShowGrid", False),
|
show_grid=config.get("ShowGrid", False),
|
||||||
font_path=config["Font"]["Path"]
|
font_path=config["Font"]["Path"],
|
||||||
|
font_size=config["Font"]["Size"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# --- Prepare frame ---
|
||||||
frame = TelefactFrame()
|
frame = TelefactFrame()
|
||||||
for i, ch in enumerate("TELEFACT"):
|
|
||||||
frame.set_cell(2 + i, 2, ch, "yellow")
|
|
||||||
for i, ch in enumerate("BROADCASTER BASE"):
|
|
||||||
frame.set_cell(2 + i, 4, ch, "white")
|
|
||||||
|
|
||||||
|
# --- Header ---
|
||||||
|
header = TelefactHeader(frame, config)
|
||||||
|
header.render()
|
||||||
|
header.update_time(root, renderer)
|
||||||
|
|
||||||
|
# --- 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()
|
||||||
|
|
|
||||||
Binary file not shown.
135
src/config/feed_loader.py
Normal file
135
src/config/feed_loader.py
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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}")
|
||||||
BIN
src/core/__pycache__/telefact_footer.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/telefact_footer.cpython-313.pyc
Normal file
Binary file not shown.
BIN
src/core/__pycache__/telefact_formatter.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/telefact_formatter.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
src/core/__pycache__/telefact_header.cpython-313.pyc
Normal file
BIN
src/core/__pycache__/telefact_header.cpython-313.pyc
Normal file
Binary file not shown.
|
|
@ -0,0 +1,71 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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",
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
"""
|
||||||
|
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 1–22)."""
|
||||||
|
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()
|
||||||
|
|
@ -1,42 +1,67 @@
|
||||||
"""
|
# ============================================================
|
||||||
Telefact Frame (model)
|
# File: /telefact/src/core/telefact_frame.py
|
||||||
- Defines a 40×24 grid and logical regions.
|
# Description:
|
||||||
- No rendering here—pure data model.
|
# Telefact Frame (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, color_name)
|
# grid[row][col] -> (char, fg_color, bg_color)
|
||||||
self.grid = [[(" ", "white") for _ in range(self.cols)] for _ in range(self.rows)]
|
self.grid = [
|
||||||
|
[(" ", "white", "black") for _ in range(self.cols)]
|
||||||
|
for _ in range(self.rows)
|
||||||
|
]
|
||||||
|
|
||||||
# basic cell ops
|
# --------------------------------------------------------
|
||||||
def set_cell(self, col: int, row: int, char: str, color: str = "white"):
|
# Basic cell operations
|
||||||
|
# --------------------------------------------------------
|
||||||
|
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], color)
|
self.grid[row][col] = (char[0], fg_color, bg_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")
|
return (" ", "white", "black")
|
||||||
|
|
||||||
def clear(self, char: str = " ", color: str = "white"):
|
def clear(self, char: str = " ", fg_color: str = "white", bg_color: str = "black"):
|
||||||
|
"""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, color)
|
self.grid[r][c] = (char, fg_color, bg_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)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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))
|
||||||
31
src/core/terminal_colors.py
Normal file
31
src/core/terminal_colors.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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}"
|
||||||
262
src/rss/rss_feedHandler.py
Normal file
262
src/rss/rss_feedHandler.py
Normal file
|
|
@ -0,0 +1,262 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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']}")
|
||||||
166
src/rss/rss_feedIndex.py
Normal file
166
src/rss/rss_feedIndex.py
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
# ============================================================
|
||||||
|
# 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}")
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
"""
|
# ============================================================
|
||||||
Telefact Renderer (Tkinter)
|
# File: /telefact/src/telefact_renderer.py
|
||||||
- Renders a 40×24 Telefact grid within a safe-area margin.
|
# Description:
|
||||||
- No header/footer bands; for layout calibration only.
|
# 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 os
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
@ -11,15 +12,18 @@ 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",
|
||||||
"red": "#FF0000",
|
"red": "#FF0000",
|
||||||
"magenta": "#FF00FF",
|
"magenta": "#FF00FF",
|
||||||
"green": "#00FF00",
|
"green": "#00FF00",
|
||||||
"cyan": "#00FFFF",
|
"cyan": "#00FFFF",
|
||||||
"yellow": "#FFFF00",
|
"yellow": "#FFFF00",
|
||||||
"white": "#FFFFFF",
|
"white": "#FFFFFF",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,27 +43,43 @@ class TelefactRenderer:
|
||||||
self.height = height
|
self.height = height
|
||||||
self.show_grid = show_grid
|
self.show_grid = show_grid
|
||||||
|
|
||||||
# Safe area for 40×24 grid (simulate CRT overscan)
|
# Teletext logical dimensions
|
||||||
self.cols, self.rows = 40, 24
|
self.cols, self.rows = 40, 24
|
||||||
self.margin_x = 24
|
|
||||||
self.margin_y = 24
|
|
||||||
self.inner_w = width - self.margin_x * 2
|
|
||||||
self.inner_h = height - self.margin_y * 2
|
|
||||||
|
|
||||||
self.cell_w = self.inner_w // self.cols
|
# --- Dynamic centered layout ---
|
||||||
self.cell_h = self.inner_h // self.rows
|
# Define safe area target margins
|
||||||
self.w = self.inner_w
|
safe_margin_x = 24
|
||||||
self.h = self.inner_h
|
safe_margin_y = 24
|
||||||
|
|
||||||
# Colours
|
# 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 = {
|
self.colors = {
|
||||||
"Background": "black",
|
"Background": "black",
|
||||||
"Grid": "#303030"
|
"Grid": "#303030",
|
||||||
}
|
}
|
||||||
if colors:
|
if colors:
|
||||||
self.colors.update(colors)
|
self.colors.update(colors)
|
||||||
|
|
||||||
# Canvas covers the whole window
|
# Canvas setup
|
||||||
self.canvas = tk.Canvas(
|
self.canvas = tk.Canvas(
|
||||||
root,
|
root,
|
||||||
width=self.width,
|
width=self.width,
|
||||||
|
|
@ -72,8 +92,9 @@ class TelefactRenderer:
|
||||||
# Font setup
|
# Font setup
|
||||||
self.font = self._load_font(font_path, font_size)
|
self.font = self._load_font(font_path, font_size)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _load_font(self, font_path: str | None, font_size: int) -> tuple:
|
def _load_font(self, font_path: str | None, font_size: int) -> tuple:
|
||||||
|
"""Loads Teletext font or falls back to Courier New."""
|
||||||
if font_path and os.path.exists(font_path):
|
if font_path and os.path.exists(font_path):
|
||||||
try:
|
try:
|
||||||
font_name = os.path.splitext(os.path.basename(font_path))[0]
|
font_name = os.path.splitext(os.path.basename(font_path))[0]
|
||||||
|
|
@ -91,23 +112,42 @@ class TelefactRenderer:
|
||||||
print(f"[warn] Font path not found: {font_path}. Using Courier New.")
|
print(f"[warn] Font path not found: {font_path}. Using Courier New.")
|
||||||
return ("Courier New", font_size, "bold")
|
return ("Courier New", font_size, "bold")
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def _gx(self, col: int) -> int:
|
def _gx(self, col: int) -> float:
|
||||||
|
"""Grid X coordinate (precise, centered)."""
|
||||||
return self.margin_x + col * self.cell_w
|
return self.margin_x + col * self.cell_w
|
||||||
|
|
||||||
def _gy(self, row: int) -> int:
|
def _gy(self, row: int) -> float:
|
||||||
|
"""Grid Y coordinate (precise, centered)."""
|
||||||
return self.margin_y + row * self.cell_h
|
return self.margin_y + row * self.cell_h
|
||||||
|
|
||||||
def _draw_char(self, col: int, row: int, char: str, color: str) -> None:
|
# ------------------------------------------------------------------
|
||||||
"""Draw a single glyph."""
|
def _draw_cell(self, col: int, row: int, char: str, fg: str, bg: str) -> None:
|
||||||
x = self._gx(col) + 2
|
"""Draw a full cell (background + glyph)."""
|
||||||
y = self._gy(row) + self.cell_h // 2
|
x = self._gx(col)
|
||||||
self.canvas.create_text(
|
y = self._gy(row)
|
||||||
x, y, anchor="w", text=char, font=self.font, fill=PALETTE.get(color, color)
|
|
||||||
|
# Background rectangle
|
||||||
|
self.canvas.create_rectangle(
|
||||||
|
x, y, x + self.cell_w, y + self.cell_h,
|
||||||
|
fill=PALETTE.get(bg, bg),
|
||||||
|
outline=PALETTE.get(bg, bg)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Foreground glyph
|
||||||
|
if char.strip():
|
||||||
|
self.canvas.create_text(
|
||||||
|
x + 2,
|
||||||
|
y + self.cell_h / 2,
|
||||||
|
anchor="w",
|
||||||
|
text=char,
|
||||||
|
font=self.font,
|
||||||
|
fill=PALETTE.get(fg, fg),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def _draw_grid(self) -> None:
|
def _draw_grid(self) -> None:
|
||||||
"""Debug overlay for exact cell spacing."""
|
"""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(
|
||||||
|
|
@ -119,24 +159,23 @@ class TelefactRenderer:
|
||||||
self.margin_x, y, self.margin_x + self.w, y, fill=self.colors["Grid"]
|
self.margin_x, y, self.margin_x + self.w, y, fill=self.colors["Grid"]
|
||||||
)
|
)
|
||||||
|
|
||||||
# ----------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
def render(self, frame: TelefactFrame) -> None:
|
def render(self, frame: TelefactFrame) -> None:
|
||||||
"""Render just the text grid; no header/footer bars."""
|
"""Renders the entire 40×24 Telefact grid."""
|
||||||
self.canvas.delete("all")
|
self.canvas.delete("all")
|
||||||
|
|
||||||
# Fill total background
|
# Fill full canvas background
|
||||||
self.canvas.create_rectangle(
|
self.canvas.create_rectangle(
|
||||||
0, 0, self.width, self.height,
|
0, 0, self.width, self.height,
|
||||||
fill=PALETTE.get(self.colors["Background"], self.colors["Background"]),
|
fill=PALETTE.get(self.colors["Background"], self.colors["Background"]),
|
||||||
width=0,
|
width=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Draw Teletext cells
|
# Draw text grid (each cell: bg + fg)
|
||||||
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 = frame.grid[row][col]
|
ch, fg, bg = frame.get_cell(col, row)
|
||||||
if ch.strip():
|
self._draw_cell(col, row, ch, fg, bg)
|
||||||
self._draw_char(col, row, ch, fg)
|
|
||||||
|
|
||||||
if self.show_grid:
|
if self.show_grid:
|
||||||
self._draw_grid()
|
self._draw_grid()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user