Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# A native plotting widget for Textual apps

[Textual](https://www.textualize.io/) is an excellent Python framework for building applications in the terminal, or on the web. This library provides a plot widget which your app can use to plot all kinds of quantitative data. So, no pie charts, sorry. The widget support scatter plots and line plots, and can also draw using _high-resolution_ characters like unicode half blocks, quadrants and 8-dot Braille characters. It may still be apparent that these are drawn using characters that take up a full block in the terminal, especially when plot series overlap. However, the use of these characters can reduce the line thickness and improve the resolution tremendously.
[Textual](https://www.textualize.io/) is an excellent Python framework for building applications in the terminal, or on the web. This library provides a plot widget which your app can use to plot all kinds of quantitative data. So, no pie charts, sorry, but we do have a treemap! The widget support scatter plots and line plots, and can also draw using _high-resolution_ characters like unicode half blocks, quadrants and 8-dot Braille characters. It may still be apparent that these are drawn using characters that take up a full block in the terminal, especially when plot series overlap. However, the use of these characters can reduce the line thickness and improve the resolution tremendously.

## Screenshots

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ license = "MIT"
license-files = ["LICENSE"]
requires-python = ">=3.10"
dependencies = [
"distinctipy>=1.3.0",
"numpy>=2.2.1",
"squarify>=0.4.3",
"textual>=1.0.0",
"textual-hires-canvas>=0.14.0",
]
Expand Down
3 changes: 2 additions & 1 deletion src/textual_plot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
DurationFormatter,
NumericAxisFormatter,
)
from textual_plot.plot_widget import HiResMode, LegendLocation, PlotWidget
from textual_plot.plot_widget import HiResMode, LegendLocation, PlotWidget, ValueDisplay

__all__ = [
"AxisFormatter",
Expand All @@ -12,4 +12,5 @@
"LegendLocation",
"NumericAxisFormatter",
"PlotWidget",
"ValueDisplay",
]
46 changes: 46 additions & 0 deletions src/textual_plot/color_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Color utilities for treemap and plot styling."""

from __future__ import annotations

import colorsys
import re

import distinctipy


def parse_style_to_rgb(style: str) -> tuple[float, float, float] | None:
"""Parse rgb(r,g,b) style string to (r,g,b) tuple in 0-1 range."""
m = re.match(r"rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)", style, re.I)
if m:
return (int(m.group(1)) / 255, int(m.group(2)) / 255, int(m.group(3)) / 255)
return None


def adjust_luminance(
rgb: tuple[float, float, float], factor: float
) -> tuple[float, float, float]:
"""Adjust luminance of RGB (0-1) by factor. factor>1 lightens, factor<1 darkens."""
h, lightness, s = colorsys.rgb_to_hls(rgb[0], rgb[1], rgb[2])
lightness = max(0, min(1, lightness * factor))
return colorsys.hls_to_rgb(h, lightness, s)


def tint_with_hue(
child_rgb: tuple[float, float, float],
parent_rgb: tuple[float, float, float],
) -> tuple[float, float, float]:
"""Apply parent's hue to child color; keep child's saturation and value for distinction."""
ph, ps, pv = colorsys.rgb_to_hsv(parent_rgb[0], parent_rgb[1], parent_rgb[2])
ch, cs, cv = colorsys.rgb_to_hsv(child_rgb[0], child_rgb[1], child_rgb[2])
r, g, b = colorsys.hsv_to_rgb(ph, cs, cv)
return (r, g, b)


def rgb_too_close(a: tuple[float, float, float], b: tuple[float, float, float]) -> bool:
"""True if a and b are too similar (perceptual distance)."""
return distinctipy.color_distance(a, b) < 0.02


def rgb_style(rgb: tuple[float, float, float]) -> str:
"""Convert (r,g,b) 0-1 to rgb(r,g,b) style string."""
return f"rgb({int(rgb[0] * 255)},{int(rgb[1] * 255)},{int(rgb[2] * 255)})"
115 changes: 115 additions & 0 deletions src/textual_plot/demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,117 @@ def action_cycle_hires_mode(self) -> None:
self.plot()


class TreemapPlot(Container):
BINDINGS = [("h", "cycle_hires_mode", "HiRes")]

_hires_mode = itertools.cycle([None, HiResMode.BRAILLE])
hires_mode = next(_hires_mode)

def compose(self) -> ComposeResult:
yield PlotWidget()

def on_mount(self) -> None:
self.plot()

def plot(self) -> None:
plot = self.query_one(PlotWidget)
plot.clear()

values = [500, 433, 280, 195, 165, 120, 95, 78, 62, 48, 35, 28, 25, 18, 12]
labels = [
"Electronics",
"Clothing",
"Home",
"Sports",
"Books",
"Toys",
"Garden",
"Auto",
"Health",
"Beauty",
"Office",
"Food",
"Pets",
"Music",
"Art",
]
plot.treemap(
values,
labels=labels,
padding=1,
hires_mode=self.hires_mode,
)
plot.show_legend()

def action_cycle_hires_mode(self) -> None:
self.hires_mode = next(self._hires_mode)
self.plot()


class NestedTreemapPlot(Container):
"""Nested treemap: click to zoom in, Escape to zoom out, arrows to select."""

BINDINGS = [("h", "cycle_hires_mode", "HiRes")]

_hires_mode = itertools.cycle([None, HiResMode.BRAILLE])
hires_mode = next(_hires_mode)

def compose(self) -> ComposeResult:
yield PlotWidget()

def on_mount(self) -> None:
self.plot()

def plot(self) -> None:
plot = self.query_one(PlotWidget)
plot.clear()
# Nested: show_nested=True draws full hierarchy with luminance variance
plot.treemap(
[
{
"label": "Electronics",
"children": [
{
"label": "Phones",
"children": [
{"label": "iPhone", "value": 55},
{"label": "Android", "value": 35},
{"label": "Other", "value": 10},
],
},
{"label": "Laptops", "value": 50},
{"label": "Tablets", "value": 25},
{"label": "Accessories", "value": 25},
],
},
{
"label": "Clothing",
"children": [
{"label": "Shirts", "value": 80},
{"label": "Pants", "value": 60},
{"label": "Shoes", "value": 40},
],
},
{
"label": "Home",
"children": [
{"label": "Furniture", "value": 90},
{"label": "Decor", "value": 45},
{"label": "Kitchen", "value": 30},
],
},
],
padding=1,
hires_mode=self.hires_mode,
show_nested=True,
)
plot.show_legend()

def action_cycle_hires_mode(self) -> None:
self.hires_mode = next(self._hires_mode)
self.plot()


class DemoApp(App[None]):
AUTO_FOCUS = "SinePlot > PlotWidget"

Expand All @@ -350,6 +461,10 @@ def compose(self) -> ComposeResult:
yield ErrorBarPlot()
with TabPane("Bar plot", id="barplot"):
yield BarPlot()
with TabPane("Treemap", id="treemap"):
yield TreemapPlot()
with TabPane("Nested Treemap", id="nested_treemap"):
yield NestedTreemapPlot()

def on_mount(self) -> None:
self.theme = "tokyo-night"
Expand Down
Loading