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
98 changes: 98 additions & 0 deletions src/pdwidgets/widgets/_raised.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# SPDX-FileCopyrightText: 2024 Brad Barnett
#
# SPDX-License-Identifier: MIT
"""Shared top-lit raised (gradient + rim) face drawing for pdwidgets.

Opt-in visual language matching pydisplay ``roku_graphics._draw_button``:
vertical hi→lo gradient, soft darker rim, pressed lighting invert.
"""

_STYLES = ("flat", "raised")


def normalize_style(style):
"""Return ``\"flat\"`` or ``\"raised\"``; raise ``ValueError`` if invalid."""
if style is None:
return "flat"
if style not in _STYLES:
raise ValueError("style must be 'flat' or 'raised'")
return style


def _channels(c, pal=None):
if pal is not None:
return pal.color_rgb(c)
return (c >> 8) & 0xF8, (c >> 3) & 0xFC, (c << 3) & 0xF8


def _pack(r, g, b, pal=None):
if pal is not None:
return pal.color565(r, g, b)
return ((r & 0xF8) << 8) | ((g & 0xFC) << 3) | (b >> 3)


def shade(c, factor, pal=None):
"""Darken (``factor`` < 1) or lighten (``factor`` > 1) an RGB565 color."""
r, g, b = _channels(c, pal)
r = max(0, min(255, int(r * factor)))
g = max(0, min(255, int(g * factor)))
b = max(0, min(255, int(b * factor)))
return _pack(r, g, b, pal)


def lerp(c1, c2, t, pal=None):
"""Linear interpolate between two RGB565 colors; ``t`` in ``[0, 1]``."""
r1, g1, b1 = _channels(c1, pal)
r2, g2, b2 = _channels(c2, pal)
r = int(r1 + (r2 - r1) * t)
g = int(g1 + (g2 - g1) * t)
b = int(b1 + (b2 - b1) * t)
return _pack(r, g, b, pal)


def raised_face_colors(bg, bg_hi=None, bg_lo=None, rim=None, pal=None):
"""Derive hi / lo / rim from face mid ``bg`` when optional colors omitted."""
hi = bg_hi if bg_hi is not None else shade(bg, 1.12, pal)
lo = bg_lo if bg_lo is not None else shade(bg, 0.78, pal)
rim_c = rim if rim is not None else shade(bg, 0.55, pal)
return hi, lo, rim_c


def fill_raised_round_rect(
fb, x, y, w, h, radius, hi, lo, rim, pressed=False, pal=None
):
"""Fill a top-lit raised round rect; ``pressed`` inverts the gradient."""
if w <= 0 or h <= 0:
return
r = radius
if r < 0:
r = 0
max_r = min(w, h) // 2
if r > max_r:
r = max_r

for j in range(h):
if j < r:
d = r - j
elif j >= h - r:
d = r - (h - 1 - j)
else:
d = 0
if d:
inset = 0
rr = r * r
dd = d * d
s = 0
while (s + 1) * (s + 1) <= rr - dd:
s += 1
inset = r - s
else:
inset = 0
t = j / (h - 1) if h > 1 else 0
# pressed → invert lighting (lo at top, hi at bottom)
c = lerp(lo, hi, t, pal) if pressed else lerp(hi, lo, t, pal)
ww = w - 2 * inset
if ww > 0:
fb.fill_rect(x + inset, y + j, ww, 1, c)

fb.round_rect(x, y, w, h, r, rim, f=False)
98 changes: 88 additions & 10 deletions src/pdwidgets/widgets/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@

from .._constants import ALIGN, ICON_SIZE, PAD, TEXT_SIZE, TEXT_WIDTH
from ..widget import Widget
from ._raised import fill_raised_round_rect, normalize_style, raised_face_colors
from .icon import Icon
from .label import Label


class Button(Widget):
"""Clickable control with optional icon and text label."""

def __init__( # noqa: PLR0913
self,
parent: Widget,
Expand All @@ -36,6 +38,10 @@ def __init__( # noqa: PLR0913
icon_file=None,
icon_color=None,
shadow=0,
style="flat",
bg_hi=None,
bg_lo=None,
rim=None,
):
"""
Initialize a Button widget to display an icon and/or text.
Expand Down Expand Up @@ -63,30 +69,56 @@ def __init__( # noqa: PLR0913
icon_color (int): The color of the icon.
shadow (int): Fake drop-shadow offset in pixels drawn behind the
button in ``color_theme.shadow`` (0 disables; the default).
style (str): ``\"flat\"`` (default solid fill + outline press) or
``\"raised\"`` (top-lit gradient + rim; press inverts lighting).
bg_hi (int): Optional raised highlight color; default shade of ``bg``.
bg_lo (int): Optional raised shade color; default shade of ``bg``.
rim (int): Optional raised rim stroke; default shade of ``bg``.
"""
self.radius = radius
self.pressed_offset = pressed_offset
self.shadow = shadow
self._style = normalize_style(style)
self._bg_hi = bg_hi
self._bg_lo = bg_lo
self._rim = rim
self._pressed = pressed
if w is None and label:
w = (len(label) + 1) * TEXT_WIDTH + 2 * PAD
w = w or ICON_SIZE.LARGE + 2 * PAD
h = h or ICON_SIZE.LARGE + 2 * PAD
bg = bg if bg is not None else parent.color_theme.primary_variant
fg = fg if fg is not None else parent.color_theme.on_primary
super().__init__(parent, x, y, w, h, align, align_to, fg, bg, visible, value, padding)
super().__init__(
parent, x, y, w, h, align, align_to, fg, bg, visible, value, padding
)
face_bg = parent.color_theme.transparent if self._style == "raised" else self.bg
if icon_file:
icon_align = ALIGN.CENTER if not label else ALIGN.LEFT
icon_color = icon_color if icon_color is not None else parent.color_theme.on_primary
icon_color = (
icon_color if icon_color is not None else parent.color_theme.on_primary
)
self.icon = Icon(
self, 0, 0, None, None, icon_align, None, icon_color, self.bg, True, icon_file
self,
0,
0,
None,
None,
icon_align,
None,
icon_color,
face_bg,
True,
icon_file,
)
if label:
if text_height not in TEXT_SIZE:
raise ValueError("Text height must be 8, 14 or 16 pixels.")
label_align = ALIGN.CENTER if not icon_file else ALIGN.OUTER_RIGHT
label_align_to = self.icon if icon_file else self
text_color = text_color if text_color is not None else parent.color_theme.on_primary
text_color = (
text_color if text_color is not None else parent.color_theme.on_primary
)
self.label = Label(
self,
0,
Expand All @@ -96,7 +128,7 @@ def __init__( # noqa: PLR0913
label_align,
label_align_to,
text_color,
self.bg,
face_bg,
True,
label,
None,
Expand All @@ -105,10 +137,46 @@ def __init__( # noqa: PLR0913
else:
self.label = None

@property
def style(self):
"""Face style: ``\"flat\"`` or ``\"raised\"``."""
return self._style

def _register_callbacks(self):
self.add_event_cb(events.MOUSEBUTTONDOWN, self.press)
self.add_event_cb(events.MOUSEBUTTONUP, self.release)

def _draw_face(self, pressed=False):
pa = self.padded_area
if self._style == "raised":
pal = self.display.pal
hi, lo, rim = raised_face_colors(
self.bg, self._bg_hi, self._bg_lo, self._rim, pal=pal
)
fill_raised_round_rect(
self.display.framebuf,
pa.x,
pa.y,
pa.w,
pa.h,
self.radius,
hi,
lo,
rim,
pressed=pressed,
pal=pal,
)
return
self.display.framebuf.round_rect(*pa, self.radius, self.bg, f=True)

def _redraw_content(self):
"""Repaint face and visible children (used for raised press invert)."""
self.draw()
for child in self.children:
if child.visible:
child.draw()
self.display.refresh(self.area)

def draw(self, _=None):
"""
Draw the button background and shape (with an optional drop shadow).
Expand All @@ -127,16 +195,26 @@ def draw(self, _=None):
self.color_theme.shadow,
f=True,
)
self.display.framebuf.round_rect(*pa, self.radius, self.bg, f=True)
self._draw_face(pressed=self._pressed and self._style == "raised")

def press(self, data=None, event=None):
"""Draw pressed-state outline (mouse down handler)."""
"""Draw pressed-state feedback (mouse down handler)."""
self._pressed = True
self.display.framebuf.round_rect(*self.padded_area, self.radius, self.fg, f=False)
if self._style == "raised":
self._redraw_content()
return
self.display.framebuf.round_rect(
*self.padded_area, self.radius, self.fg, f=False
)
self.display.refresh(self.area)

def release(self, data=None, event=None):
"""Restore normal outline (mouse up handler)."""
"""Restore normal face (mouse up handler)."""
self._pressed = False
self.display.framebuf.round_rect(*self.padded_area, self.radius, self.bg, f=False)
if self._style == "raised":
self._redraw_content()
return
self.display.framebuf.round_rect(
*self.padded_area, self.radius, self.bg, f=False
)
self.display.refresh(self.area)
Loading
Loading