Skip to content
Closed
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
151 changes: 151 additions & 0 deletions Python_Engine/Python/src/python_toolkit/bhom/custom_cmaps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
"""Persistence for user-defined colormaps.

Custom colormaps created via the CmapBuilder UI are stored in the colour scale library at
``~/.bhom/custom_cmaps.json`` (see ``python_toolkit.plot.colour_scales``) so they survive
across sessions and can be reloaded by any CmapSelector that opts in via ``allow_custom``.

These functions keep the original list-of-colormaps interface; new code should use
``ColourScaleLibrary`` directly, which keeps thresholds in data units and supports sets.
"""

from typing import List, Tuple

from matplotlib.colors import Colormap, LinearSegmentedColormap

from python_toolkit.plot.colour_scales import (
ColourScaleLibrary,
GradientScale,
ThresholdScale,
)


# ------------------------------------------------------------------
# Internal helpers (mirror the builder's normalisation logic)
# ------------------------------------------------------------------

def _norm(val: float, vmin: float, vmax: float) -> float:
span = vmax - vmin
if span == 0:
return 0.0
return max(0.0, min(1.0, (val - vmin) / span))


def _build_interpolation_cmap(
name: str,
colours: list,
positions: list,
vmin: float,
vmax: float,
) -> LinearSegmentedColormap:
stops = sorted(
((_norm(p, vmin, vmax), c) for p, c in zip(positions, colours)),
key=lambda x: x[0],
)
return LinearSegmentedColormap.from_list(name, stops)


def _build_bins_cmap(
name: str,
colours: list,
lower_bounds: list,
vmin: float,
vmax: float,
) -> LinearSegmentedColormap:
paired = sorted(zip(lower_bounds, colours), key=lambda x: x[0])
sorted_starts = [p[0] for p in paired]
sorted_colours = [p[1] for p in paired]

# End of each bin = start of the next; last bin always ends at vmax
ends = sorted_starts[1:] + [vmax]
norm_starts = [_norm(s, vmin, vmax) for s in sorted_starts]
norm_ends = [_norm(e, vmin, vmax) for e in ends]
norm_starts[0] = 0.0
norm_ends[-1] = 1.0

stops = []
for i, colour in enumerate(sorted_colours):
stops.append((norm_starts[i], colour))
if i < len(sorted_colours) - 1:
stops.append((norm_ends[i], colour))
stops.append((1.0, sorted_colours[-1]))
return LinearSegmentedColormap.from_list(name, stops)


def _as_colormap(scale) -> Tuple[Colormap, Tuple[float, float]]:
"""A 0-1 colormap plus the data range it represents, for CmapSelector previews."""
if isinstance(scale, ThresholdScale):
preview = scale.preview_data()
vmin, vmax = float(preview[0]), float(preview[-1])
return _build_bins_cmap(scale.name, scale.colours, [vmin] + scale.thresholds, vmin, vmax), (vmin, vmax)
positions = [p for p, _ in scale.stops]
colours = [c for _, c in scale.stops]
vmin, vmax = (0.0, 1.0) if scale.relative else (positions[0], positions[-1])
return _build_interpolation_cmap(scale.name, colours, positions, vmin, vmax), (vmin, vmax)


# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def save_custom_cmap(
name: str,
cmap_type: str,
colours: list,
positions: list,
vmin: float,
vmax: float,
) -> None:
"""Save or overwrite a custom colormap definition in the user file.

Args:
name: Display name for the colormap.
cmap_type: ``"interpolation"`` or ``"bins"``.
colours: Ordered list of hex colour strings.
positions: For ``"interpolation"``: stop positions in data units.
For ``"bins"``: lower boundary (start) of each bin in data units.
vmin: Lower bound of the data range.
vmax: Upper bound of the data range.
"""
if cmap_type == "bins":
pairs = sorted(zip(positions, colours))
scale = ThresholdScale(name, [p for p, _ in pairs[1:]], [c for _, c in pairs])
else:
scale = GradientScale(name, list(zip(positions, colours)), relative=False)
ColourScaleLibrary().save(scale, overwrite=True)


def delete_custom_cmap(name: str) -> bool:
"""Delete a single saved colormap by name.

Returns:
``True`` if the entry was found and removed, ``False`` otherwise.
"""
library = ColourScaleLibrary()
if name not in library:
return False
library.delete(name)
return True


def clear_custom_cmaps() -> None:
"""Remove all saved custom colormaps."""
ColourScaleLibrary().clear()


def list_custom_cmap_names() -> list:
"""Return the names of all saved custom colormaps."""
return ColourScaleLibrary().names()


def load_custom_cmaps() -> List[Tuple[str, Colormap, Tuple[float, float]]]:
"""Load all user-defined colormaps.

Returns:
List of ``(name, colormap, (vmin, vmax))`` tuples. Entries that cannot be read
are reported by the library (logged), not silently skipped.
"""
result = []
for scale in ColourScaleLibrary().scales():
cmap, bounds = _as_colormap(scale)
result.append((scale.name, cmap, bounds))
return result
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .bhom_base_popup import BHoMBasePopup
from .widgets import (
BHoMBaseWidget,
PackingOptions,
Expand Down Expand Up @@ -30,6 +31,7 @@
)

__all__ = [
"BHoMBasePopup",
"BHoMBaseWidget",
"PackingOptions",
"CalendarWidget",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""Lightweight themed Toplevel popup window without the BHoM banner."""

import ctypes
import platform
import tkinter as tk
from tkinter import ttk
from typing import Callable, Optional


class BHoMBasePopup(tk.Toplevel):
"""
A lightweight themed popup window for transient interactions.

Unlike BHoMBaseWindow it has no branded banner and is intended for quick,
modal (or non-modal) popups such as colour pickers, confirmations, and
inline editors. The active ttk theme is inherited automatically from the
root Tk window.
"""

def __init__(
self,
parent: tk.Misc,
title: str = "",
modal: bool = True,
resizable: bool = False,
on_close: Optional[Callable] = None,
center_on_parent: bool = True,
padding: int = 10,
**kwargs,
) -> None:
"""
Args:
parent: Parent widget. The popup is made transient to the root window.
title: Window title text.
modal: Whether the popup grabs input focus and blocks the parent.
resizable: Whether the window can be resized by the user.
on_close: Optional callback invoked *after* the popup is destroyed.
center_on_parent: Centre the popup over the parent root window.
padding: Padding applied to the content frame.
**kwargs: Additional Toplevel options.
"""
root = parent.winfo_toplevel()
super().__init__(root, **kwargs)

self._parent = parent
self._on_close_callback = on_close
self._modal = modal
self._should_center = center_on_parent

self.title(title)
self.transient(root)
self.resizable(resizable, resizable)
self.protocol("WM_DELETE_WINDOW", self.close)

self.content_frame = ttk.Frame(self, padding=padding)
self.content_frame.pack(fill=tk.BOTH, expand=True)

# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------

def show(self, focus_widget: Optional[tk.Widget] = None) -> None:
"""Finalise the popup: size, position, theme, and grab input.

Call this *after* all content has been added to ``content_frame``.

Args:
focus_widget: Widget that receives keyboard focus on open.
"""
self.update_idletasks()

if self._should_center:
self._center_on_parent_window()

self._set_titlebar_theme()

if self._modal:
self.grab_set()

if focus_widget is not None:
focus_widget.focus_set()

def close(self) -> None:
"""Destroy the popup and invoke the on_close callback (if any)."""
if self.winfo_exists():
try:
if self._modal:
self.grab_release()
except tk.TclError:
pass
self.destroy()

if self._on_close_callback:
self._on_close_callback()

# ------------------------------------------------------------------
# Internals
# ------------------------------------------------------------------

def _center_on_parent_window(self) -> None:
"""Position the popup centred over its parent's root window."""
root = self._parent.winfo_toplevel()
self.update_idletasks()

popup_width = self.winfo_reqwidth()
popup_height = self.winfo_reqheight()

parent_x = root.winfo_rootx()
parent_y = root.winfo_rooty()
parent_width = root.winfo_width()
parent_height = root.winfo_height()

x = parent_x + (parent_width - popup_width) // 2
y = parent_y + (parent_height - popup_height) // 2

# Clamp to screen bounds so the popup is always fully visible.
screen_width = self.winfo_screenwidth()
screen_height = self.winfo_screenheight()
x = max(0, min(x, screen_width - popup_width))
y = max(0, min(y, screen_height - popup_height))

self.geometry(f"+{x}+{y}")

def _set_titlebar_theme(self) -> None:
"""Apply dark/light titlebar colouring on Windows via DWM API."""
try:
style = ttk.Style()
bg = style.lookup("TFrame", "background") or ""
use_dark = 1 if self._is_dark_colour(bg) else 0

if platform.system() == "Windows" and ctypes is not None and self.winfo_exists():
hwnd = ctypes.windll.user32.GetParent(self.winfo_id())
if hwnd:
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd,
DWMWA_USE_IMMERSIVE_DARK_MODE,
ctypes.byref(ctypes.c_int(use_dark)),
ctypes.sizeof(ctypes.c_int),
)
except Exception:
pass

@staticmethod
def _is_dark_colour(colour: str) -> bool:
"""Return True when the colour's perceived luminance is below the midpoint."""
colour = (colour or "").strip().lstrip("#")
if len(colour) == 3:
colour = "".join(ch * 2 for ch in colour)
if len(colour) != 6:
return False
try:
r, g, b = int(colour[0:2], 16), int(colour[2:4], 16), int(colour[4:6], 16)
return (0.299 * r + 0.587 * g + 0.114 * b) < 128
except ValueError:
return False
Loading
Loading