Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions plugins.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"version": "1.0.0",
"last_updated": "2026-07-29",
"last_updated": "2026-07-30",
"plugins": [
{
"id": "cricket-scoreboard",
Expand Down Expand Up @@ -804,10 +804,10 @@
"plugin_path": "plugins/ledmatrix-stocks",
"stars": 0,
"downloads": 0,
"last_updated": "2026-07-10",
"last_updated": "2026-07-28",
"verified": true,
"screenshot": "",
"latest_version": "2.4.0",
"latest_version": "2.5.0",
"icon": "fas fa-chart-line"
},
{
Expand Down
4 changes: 2 additions & 2 deletions plugins/ledmatrix-stocks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ generated from it. The most-used keys, with their actual nesting:
| `display.scroll_speed` | `1.0` | Scroll speed multiplier |
| `display.scroll_delay` | `0.02` | Per-step delay (smaller = smoother but more CPU) |
| `display.toggle_chart` | `true` | Show an inline mini-chart per symbol |
| `display.chart_width_percent` | `0.4` | Chart width as a fraction of each symbol's canvas width (`0.1`-`0.7`) |
| `display.chart_height_percent` | `0.667` | Chart height as a fraction of the display height (`0.2`-`1.0`) |
| `display.chart_width_px` | `64` | Mini-chart width in pixels — a fixed size that does not scale with the display width (`8`-`256`) |
| `display.chart_height_px` | `32` | Mini-chart height in pixels — a fixed size that does not scale with the display height; clamped to the panel (`6`-`256`) |
| `display.dynamic_duration` | `true` | Let the controller pick a duration based on content width |
| `display.min_duration` | `30` | Floor for dynamic duration (seconds) |
| `display.max_duration` | `300` | Ceiling for dynamic duration (seconds) |
Expand Down
24 changes: 12 additions & 12 deletions plugins/ledmatrix-stocks/config_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,19 +52,19 @@
"description": "Enable chart display toggle",
"default": true
},
"chart_width_percent": {
"type": "number",
"description": "Width of the mini price chart as a fraction of each stock/crypto entry's canvas width",
"minimum": 0.1,
"maximum": 0.7,
"default": 0.4
"chart_width_px": {
"type": "integer",
"description": "Width of the mini price chart in pixels (fixed size, does not scale with the display width)",
"minimum": 8,
"maximum": 256,
"default": 64
},
"chart_height_percent": {
"type": "number",
"description": "Height of the mini price chart as a fraction of the display height",
"minimum": 0.2,
"maximum": 1.0,
"default": 0.667
"chart_height_px": {
"type": "integer",
"description": "Height of the mini price chart in pixels (fixed size, does not scale with the display height; clamped to the panel)",
"minimum": 6,
"maximum": 256,
"default": 32
},
"dynamic_duration": {
"type": "boolean",
Expand Down
169 changes: 82 additions & 87 deletions plugins/ledmatrix-stocks/display_renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,14 @@ def __init__(self, config: Dict[str, Any], display_width: int, display_height: i
self.toggle_chart = config.get('display', {}).get('toggle_chart', True)
self.stock_gap = int(config.get('display', {}).get('stock_gap', 32))

# Chart size as a fraction of the per-stock canvas width/height.
# Defaults match the previous hardcoded chart_width = width/2.5, chart_height = height/1.5.
# Chart size is a definite pixel value in BOTH dimensions so it stays a
# fixed, compact size no matter how large the panel is. Previously width
# was a fraction of the (display-scaled) canvas and height a fraction of
# the display height, so the chart ballooned on wide chains and on tall
# 4x panels. Both are clamped to the panel so they never overflow.
display_cfg = config.get('display', {})
self.chart_width_percent = min(max(float(display_cfg.get('chart_width_percent', 0.4)), 0.1), 0.7)
self.chart_height_percent = min(max(float(display_cfg.get('chart_height_percent', 0.667)), 0.2), 1.0)
self.chart_width_px = int(min(max(int(display_cfg.get('chart_width_px', 64)), 8), 256))
self.chart_height_px = int(min(max(int(display_cfg.get('chart_height_px', 32)), 6), 256))

# Load colors from customization structure (organized by element: symbol, price, price_delta)
# Support both new format (customization.stocks.*) and old format (top-level) for backwards compatibility
Expand Down Expand Up @@ -176,38 +179,24 @@ def _load_custom_font_from_element_config(self, element_config: Dict[str, Any])
return ImageFont.load_default()

def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image:
"""Create a display image for a single stock or crypto - matching old stock manager layout exactly."""
# Create a wider image for scrolling - adjust width based on chart toggle
# Match old stock_manager: width = int(self.display_manager.matrix.width * (2 if self.toggle_chart else 1.5))
# Canvas grows with chart_width_percent so a larger chart still has room next to the text.
# Ensure dimensions are integers
width_multiplier = (1.5 + self.chart_width_percent * 1.25) if self.toggle_chart else 1.5
width = int(self.display_width * width_multiplier)
"""Create a scrolling display image for a single stock or crypto.

The per-entry canvas is sized to the actual content (logo + text +
fixed-size chart) so there is no dead trailing space between entries and
the chart sits right after the price without ever overlapping it."""
height = int(self.display_height)
image = Image.new('RGB', (width, height), (0, 0, 0))
draw = ImageDraw.Draw(image)

is_crypto = data.get('is_crypto', False)

# Draw large stock/crypto logo on the left
logo_x = 4 # Margin from left edge (used for logo_right even if logo is missing)
logo = self._get_stock_logo(symbol, is_crypto)
if logo:
# Position logo on the left side with minimal spacing - matching old stock_manager
# Ensure positions are integers
logo_y = int((height - logo.height) // 2)
image.paste(logo, (int(logo_x), int(logo_y)), logo)


# Use custom fonts loaded from config
symbol_font = self.symbol_font
price_font = self.price_font
change_font = self.price_delta_font

# Create text elements
display_symbol = symbol.replace('-USD', '') if is_crypto else symbol
symbol_text = display_symbol
price_text = f"${data['price']:.2f}"

# Build change text based on show_change and show_percentage flags
# Get flags from config (stock-specific or crypto-specific)
if is_crypto:
Expand All @@ -216,7 +205,7 @@ def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image
else:
show_change = self.config.get('show_change', True)
show_percentage = self.config.get('show_percentage', True)

# Build change text components
change_parts = []
if show_change:
Expand All @@ -228,89 +217,94 @@ def create_stock_display(self, symbol: str, data: Dict[str, Any]) -> Image.Image
elif 'open' in data and data['open'] > 0:
change_percent = (data['change'] / data['open']) * 100
change_parts.append(f"({change_percent:+.1f}%)")

change_text = " ".join(change_parts) if change_parts else ""

# Get colors based on change
if data['change'] >= 0:
change_color = self.positive_color if not is_crypto else self.crypto_positive_color
else:
change_color = self.negative_color if not is_crypto else self.crypto_negative_color

# Use symbol color for symbol, price color for price
symbol_color = self.symbol_text_color if not is_crypto else self.crypto_symbol_text_color
price_color = self.price_text_color if not is_crypto else self.crypto_price_text_color
# Calculate text dimensions for proper spacing (matching old stock manager)
symbol_bbox = draw.textbbox((0, 0), symbol_text, font=symbol_font)
price_bbox = draw.textbbox((0, 0), price_text, font=price_font)

# Only calculate change_bbox if change_text is not empty

# Measure the text on a scratch canvas so the real canvas can be sized to
# the content (rather than a display-scaled guess that then needs clamping).
mdraw = ImageDraw.Draw(Image.new('RGB', (1, 1)))
symbol_bbox = mdraw.textbbox((0, 0), symbol_text, font=symbol_font)
price_bbox = mdraw.textbbox((0, 0), price_text, font=price_font)
if change_text:
change_bbox = draw.textbbox((0, 0), change_text, font=change_font)
change_bbox = mdraw.textbbox((0, 0), change_text, font=change_font)
change_height = int(change_bbox[3] - change_bbox[1])
else:
change_bbox = (0, 0, 0, 0)
change_height = 0

# Calculate total height needed - adjust gaps based on chart toggle

# Logo (load up front so its width feeds the layout)
logo_x = 4 # Margin from left edge (used for logo_right even if logo is missing)
logo = self._get_stock_logo(symbol, is_crypto)
logo_right = int(logo_x + logo.width) if logo else int(logo_x)
logo_gap = 8 # px between logo right edge and text start

# Text column sits just right of the logo, centered on the widest line.
symbol_width = int(symbol_bbox[2] - symbol_bbox[0])
price_width = int(price_bbox[2] - price_bbox[0])
change_width = int(change_bbox[2] - change_bbox[0]) if change_text else 0
max_text_width = max(symbol_width, price_width, change_width, 1)
column_x = logo_right + logo_gap + (max_text_width // 2)
text_right = column_x + (max_text_width // 2)

# Chart sits just right of the text; size the canvas to exactly fit it.
chart_gap = 6 # px between the price text and the chart
right_margin = 4 # px of breathing room after the content
draw_chart = (self.toggle_chart and 'price_history' in data
and len(data['price_history']) >= 2)
if draw_chart:
chart_x = text_right + chart_gap
content_right = chart_x + self.chart_width_px + right_margin
else:
content_right = text_right + 8 # small right margin
width = int(content_right)

# Real canvas, sized to the content
image = Image.new('RGB', (width, height), (0, 0, 0))
draw = ImageDraw.Draw(image)

# Draw the logo on the left, vertically centered
if logo:
logo_y = int((height - logo.height) // 2)
image.paste(logo, (int(logo_x), logo_y), logo)

# Vertically center the text block - adjust gaps based on chart toggle
# Match old stock_manager: text_gap = 2 if self.toggle_chart else 1
text_gap = 2 if self.toggle_chart else 1
# Only add change height and gap if change is shown
change_gap = text_gap if change_text else 0
symbol_height = int(symbol_bbox[3] - symbol_bbox[1])
price_height = int(price_bbox[3] - price_bbox[1])
total_text_height = symbol_height + price_height + change_height + (text_gap + change_gap) # Account for gaps between elements

# Calculate starting y position to center all text
total_text_height = symbol_height + price_height + change_height + (text_gap + change_gap)
start_y = int((height - total_text_height) // 2)

# Position text column immediately after the logo's right edge
logo_right = int(logo_x + logo.width) if logo else int(logo_x)
logo_gap = 8 # px between logo right edge and text start
symbol_width_tmp = int(symbol_bbox[2] - symbol_bbox[0])
price_width_tmp = int(price_bbox[2] - price_bbox[0])
change_width_tmp = int(change_bbox[2] - change_bbox[0]) if change_text else 0
max_text_width = max(symbol_width_tmp, price_width_tmp, change_width_tmp, 1)
column_x = logo_right + logo_gap + (max_text_width // 2)
if self.toggle_chart:
# Clamp so text does not overlap the mini chart area
chart_width_px = int(width * self.chart_width_percent)
chart_start = width - chart_width_px - 4
column_x = min(column_x, chart_start - (max_text_width // 2) - logo_gap)


# Draw symbol
symbol_width = int(symbol_bbox[2] - symbol_bbox[0])
symbol_x = int(column_x - (symbol_width / 2))
draw.text((symbol_x, start_y), symbol_text, font=symbol_font, fill=symbol_color)

# Draw price
price_width = int(price_bbox[2] - price_bbox[0])
price_x = int(column_x - (price_width / 2))
symbol_height = int(symbol_bbox[3] - symbol_bbox[1])
price_y = int(start_y + symbol_height + text_gap) # Adjusted gap
price_y = int(start_y + symbol_height + text_gap)
draw.text((price_x, price_y), price_text, font=price_font, fill=price_color)

# Draw change with color based on value (only if change_text is not empty)
if change_text:
change_width = int(change_bbox[2] - change_bbox[0])
change_x = int(column_x - (change_width / 2))
price_height = int(price_bbox[3] - price_bbox[1])
change_y = int(price_y + price_height + text_gap) # Adjusted gap
change_y = int(price_y + price_height + text_gap)
draw.text((change_x, change_y), change_text, font=change_font, fill=change_color)

# Draw mini chart on the right only if toggle_chart is enabled
if self.toggle_chart and 'price_history' in data and len(data['price_history']) >= 2:
self._draw_mini_chart(draw, data['price_history'], width, height, change_color)

# When chart is disabled, trim the canvas to actual content width.
# The canvas is created at 1.5x display width for text room, but content
# typically only fills ~half that — the dead space becomes inter-symbol gap.
if not self.toggle_chart:
content_right = column_x + (max_text_width // 2) + 8 # 8px right margin
content_right = min(content_right, width)
if content_right < width:
image = image.crop((0, 0, content_right, height))
# Draw the mini chart immediately to the right of the text column.
if draw_chart:
self._draw_mini_chart(draw, data['price_history'], width, height,
change_color, chart_x=chart_x)

return image

Expand Down Expand Up @@ -464,18 +458,19 @@ def _get_stock_color(self, change: float) -> Tuple[int, int, int]:
return (255, 255, 0) # Yellow for no change

def _draw_mini_chart(self, draw: ImageDraw.Draw, price_history: List[Dict],
width: int, height: int, color: Tuple[int, int, int]) -> None:
"""Draw a mini price chart on the right side of the display."""
width: int, height: int, color: Tuple[int, int, int],
chart_x: Optional[int] = None) -> None:
"""Draw a mini price chart. By default it anchors to the right edge; pass
chart_x to place it explicitly (e.g. right after the price text)."""
if len(price_history) < 2:
return

# Chart dimensions - sized as a configurable fraction of the item canvas
# (self.chart_width_percent / self.chart_height_percent), matching the
# clamp used in create_stock_display so text never overlaps the chart.
# Chart dimensions - definite pixel sizes (self.chart_width_px /
# self.chart_height_px), clamped so they never exceed the panel.
# Ensure all dimensions are integers
chart_width = int(width * self.chart_width_percent)
chart_height = int(height * self.chart_height_percent)
chart_x = int(width - chart_width - 4) # 4px margin from right edge
chart_width = int(self.chart_width_px)
chart_height = int(min(self.chart_height_px, height))
chart_x = int(width - chart_width - 4) if chart_x is None else int(chart_x) # default: 4px from right edge
chart_y = int((height - chart_height) / 2)

# Extract prices - match old stock_manager exactly
Expand Down
10 changes: 8 additions & 2 deletions plugins/ledmatrix-stocks/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "ledmatrix-stocks",
"name": "Stock & Crypto Ticker",
"version": "2.4.0",
"version": "2.5.0",
"description": "Displays stock tickers with prices, changes, and optional charts for stocks and cryptocurrencies. Supports scroll and switch display modes.",
"author": "LEDMatrix Team",
"homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/ledmatrix-stocks",
Expand Down Expand Up @@ -39,6 +39,12 @@
}
],
"versions": [
{
"released": "2026-07-28",
"version": "2.5.0",
"notes": "Stock ticker layout overhaul so entries stay compact on large/4x panels and in vegas mode. The mini-chart is now a definite pixel size (chart_width_px / chart_height_px, default 64x32) instead of a fraction of the display, so it no longer balloons on wide chains or tall panels. The chart sits directly to the right of the price (no large gap), and each entry canvas is sized to its actual content so there is no dead space between tickers. Replaces the chart_width_percent / chart_height_percent config keys.",
"ledmatrix_min": "2.0.0"
},
{
"released": "2026-07-16",
"version": "2.4.0",
Expand Down Expand Up @@ -93,7 +99,7 @@
"ledmatrix_min_version": "2.0.0"
}
],
"last_updated": "2026-07-10",
"last_updated": "2026-07-28",
"stars": 0,
"downloads": 0,
"verified": true,
Expand Down
Loading