Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for icons on buttons #2310

Merged
merged 8 commits into from
Jan 10, 2024
Merged
53 changes: 43 additions & 10 deletions android/src/toga_android/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from decimal import ROUND_UP

from android.graphics import Bitmap, Rect
from android.graphics.drawable import BitmapDrawable
from android.view import View
from android.widget import Button as A_Button
from java import dynamic_proxy
Expand All @@ -25,26 +27,57 @@ def create(self):
self.native.setOnClickListener(TogaOnClickListener(button_impl=self))
self.cache_textview_defaults()

self._icon = None

def get_text(self):
return str(self.native.getText())

def set_text(self, text):
self.native.setText(text)

def get_icon(self):
return self._icon

def set_icon(self, icon):
self._icon = icon
if icon:
# Scale icon to to a 48x48 CSS pixel bitmap.
bitmap = Bitmap.createScaledBitmap(
icon._impl.native,
self.scale_in(48),
self.scale_in(48),
True,
)
drawable = BitmapDrawable(self.native.getContext().getResources(), bitmap)
drawable.setBounds(
Rect(0, 0, drawable.getIntrinsicWidth(), drawable.getIntrinsicHeight())
)
else:
drawable = None

self.native.setCompoundDrawablesRelative(drawable, None, None, None)

def set_enabled(self, value):
self.native.setEnabled(value)

def set_background_color(self, value):
self.set_background_filter(value)

def rehint(self):
self.native.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED,
)
self.interface.intrinsic.width = self.scale_out(
at_least(self.native.getMeasuredWidth()), ROUND_UP
)
self.interface.intrinsic.height = self.scale_out(
self.native.getMeasuredHeight(), ROUND_UP
)
if self._icon:
# Icons aren't considered "inside" the button, so they aren't part of the
# "measured" size. Hardcode a button size of 48x48 pixels with 10px of
# padding (in CSS pixels).
self.interface.intrinsic.width = at_least(68)
self.interface.intrinsic.height = 68
else:
self.native.measure(
View.MeasureSpec.UNSPECIFIED,
View.MeasureSpec.UNSPECIFIED,
)
self.interface.intrinsic.width = self.scale_out(
at_least(self.native.getMeasuredWidth()), ROUND_UP
)
self.interface.intrinsic.height = self.scale_out(
self.native.getMeasuredHeight(), ROUND_UP
)
12 changes: 12 additions & 0 deletions android/tests_backend/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import pytest
from java import jclass

from toga.colors import TRANSPARENT
Expand All @@ -12,6 +13,17 @@ class ButtonProbe(LabelProbe):
# Heavier than sans-serif, but lighter than sans-serif bold
default_font_family = "sans-serif-medium"

def assert_no_icon(self):
return self.native.getCompoundDrawablesRelative()[0] is None

def assert_icon_size(self):
icon = self.native.getCompoundDrawablesRelative()[0]
if icon:
scaled_size = (self.impl.scale_in(48), self.impl.scale_in(48))
assert (icon.getIntrinsicWidth(), icon.getIntrinsicHeight()) == scaled_size
else:
pytest.fail("Icon does not exist")

@property
def background_color(self):
color = super().background_color
Expand Down
1 change: 1 addition & 0 deletions changes/774.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Buttons can now be created with an icon, instead of a text label.
7 changes: 7 additions & 0 deletions cocoa/src/toga_cocoa/icons.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from rubicon.objc import NSSize

from toga_cocoa.libs import NSImage


Expand Down Expand Up @@ -32,3 +34,8 @@ def __init__(self, interface, path):
def __del__(self):
if self.native:
self.native.release()

def _as_size(self, size):
image = self.native.copy()
image.setSize(NSSize(size, size))
return image
20 changes: 17 additions & 3 deletions cocoa/src/toga_cocoa/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
from rubicon.objc import SEL, objc_method, objc_property
from travertino.size import at_least

from toga.colors import TRANSPARENT
from toga.fonts import SYSTEM_DEFAULT_FONT_SIZE
from toga.style.pack import NONE
from toga_cocoa.colors import native_color
from toga_cocoa.libs import (
SEL,
NSBezelStyle,
NSButton,
NSMomentaryPushInButton,
objc_method,
objc_property,
)

from .base import Widget
Expand All @@ -31,6 +29,8 @@ def create(self):
self.native.interface = self.interface
self.native.impl = self

self._icon = None

self.native.buttonType = NSMomentaryPushInButton
self._set_button_style()

Expand All @@ -48,6 +48,7 @@ def _set_button_style(self):
if (
self.interface.style.font_size != SYSTEM_DEFAULT_FONT_SIZE
or self.interface.style.height != NONE
or self._icon is not None
):
self.native.bezelStyle = NSBezelStyle.RegularSquare
else:
Expand All @@ -72,6 +73,19 @@ def get_text(self):
def set_text(self, text):
self.native.title = text

def get_icon(self):
return self._icon

def set_icon(self, icon):
self._icon = icon
if icon:
self.native.image = icon._impl._as_size(32)
else:
self.native.image = None

# Button style is sensitive to whether an icon is being used
self._set_button_style()

def set_background_color(self, color):
if color == TRANSPARENT or color is None:
self.native.bezelColor = None
Expand Down
20 changes: 19 additions & 1 deletion cocoa/tests_backend/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pytest import xfail
from pytest import fail, xfail

from toga.style.pack import NONE
from toga_cocoa.libs import NSBezelStyle, NSButton, NSFont
Expand All @@ -14,6 +14,23 @@ class ButtonProbe(SimpleProbe):
def text(self):
return str(self.native.title)

def assert_no_icon(self):
assert self.native.image is None

def assert_icon_size(self):
icon = self.native.image
if icon:
assert (icon.size.width, icon.size.height) == (32, 32)
else:
fail("Icon does not exist")

@property
def icon_size(self):
if self.native.image:
return (self.native.image.size.width, self.native.image.size.height)
else:
return None

@property
def color(self):
xfail("Can't get/set the text color of a button on macOS")
Expand All @@ -32,6 +49,7 @@ def height(self):
if (
self.widget.style.height != NONE
or self.native.font.pointSize != NSFont.systemFontSize
or self.native.image is not None
):
assert self.native.bezelStyle == NSBezelStyle.RegularSquare
else:
Expand Down
6 changes: 3 additions & 3 deletions core/src/toga/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -249,7 +249,7 @@ def __init__(
app_id: str | None = None,
app_name: str | None = None,
*,
icon: IconContent = None,
icon: IconContent | None = None,
author: str | None = None,
version: str | None = None,
home_page: str | None = None,
Expand Down Expand Up @@ -511,7 +511,7 @@ def icon(self) -> Icon:
return self._icon

@icon.setter
def icon(self, icon_or_name: IconContent) -> None:
def icon(self, icon_or_name: IconContent | None) -> None:
if isinstance(icon_or_name, Icon):
self._icon = icon_or_name
else:
Expand Down Expand Up @@ -716,7 +716,7 @@ def __init__(
app_id: str | None = None,
app_name: str | None = None,
*,
icon: IconContent = None,
icon: IconContent | None = None,
author: str | None = None,
version: str | None = None,
home_page: str | None = None,
Expand Down
4 changes: 2 additions & 2 deletions core/src/toga/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ def __init__(
*,
shortcut: str | Key | None = None,
tooltip: str | None = None,
icon: IconContent = None,
icon: IconContent | None = None,
group: Group = Group.COMMANDS,
section: int = 0,
order: int = 0,
Expand Down Expand Up @@ -238,7 +238,7 @@ def icon(self) -> Icon | None:
return self._icon

@icon.setter
def icon(self, icon_or_name: str | Icon):
def icon(self, icon_or_name: IconContent | None):
if isinstance(icon_or_name, Icon) or icon_or_name is None:
self._icon = icon_or_name
else:
Expand Down
2 changes: 1 addition & 1 deletion core/src/toga/icons.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
else:
from typing import TypeAlias

IconContent: TypeAlias = str | Path | toga.Icon | None
IconContent: TypeAlias = str | Path | toga.Icon


class cachedicon:
Expand Down
83 changes: 70 additions & 13 deletions core/src/toga/widgets/button.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
from __future__ import annotations

from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol

import toga
from toga.handlers import wrapped_handler

from .base import Widget

if TYPE_CHECKING:
from toga.icons import IconContent


class OnPressHandler(Protocol):
def __call__(self, widget: Button, **kwargs: Any) -> None:
Expand All @@ -23,7 +27,8 @@ def __call__(self, widget: Button, **kwargs: Any) -> None:
class Button(Widget):
def __init__(
self,
text: str | None,
text: str | None = None,
icon: IconContent | None = None,
id: str | None = None,
style=None,
on_press: OnPressHandler | None = None,
Expand All @@ -32,13 +37,14 @@ def __init__(
"""Create a new button widget.

:param text: The text to display on the button.
:param icon: The icon to display on the button. Can be specified as any valid
:any:`icon content <IconContent>`.
:param id: The ID for the widget.
:param style: A style object. If no style is provided, a default style
will be applied to the widget.
:param on_press: A handler that will be invoked when the button is
pressed.
:param enabled: Is the button enabled (i.e., can it be pressed?).
Optional; by default, buttons are created in an enabled state.
:param style: A style object. If no style is provided, a default style will be
applied to the widget.
:param on_press: A handler that will be invoked when the button is pressed.
:param enabled: Is the button enabled (i.e., can it be pressed?). Optional; by
default, buttons are created in an enabled state.
"""
super().__init__(id=id, style=style)

Expand All @@ -48,7 +54,15 @@ def __init__(
# Set a dummy handler before installing the actual on_press, because we do not want
# on_press triggered by the initial value being set
self.on_press = None
self.text = text

# Set the content of the button - either an icon, or text, but not both.
if icon:
if text is not None:
raise ValueError("Cannot specify both text and an icon")
else:
self.icon = icon
else:
self.text = text

self.on_press = on_press
self.enabled = enabled
Expand All @@ -58,11 +72,17 @@ def text(self) -> str:
"""The text displayed on the button.

``None``, and the Unicode codepoint U+200B (ZERO WIDTH SPACE), will be
interpreted and returned as an empty string. Any other object will be
converted to a string using ``str()``.
interpreted and returned as an empty string. Any other object will be converted
to a string using ``str()``.

Only one line of text can be displayed. Any content after the first newline will
be ignored.

Only one line of text can be displayed. Any content after the first
newline will be ignored.
If the button is currently displaying an icon, and text is assigned, the icon
will be replaced by the new text.

If the button is currently displaying an icon, the empty string will be
returned.
"""
return self._impl.get_text()

Expand All @@ -76,6 +96,43 @@ def text(self, value: str | None) -> None:
value = str(value).split("\n")[0]

self._impl.set_text(value)
self._impl.set_icon(None)
self.refresh()

@property
def icon(self) -> toga.Icon | None:
"""The icon displayed on the button.
mhsmith marked this conversation as resolved.
Show resolved Hide resolved

Can be specified as any valid :any:`icon content <IconContent>`.

If the button is currently displaying text, and an icon is assigned, the text
will be replaced by the new icon.

If ``None`` is assigned as an icon, the button will become a text button with an
empty label.

Returns ``None`` if the button is currently displaying text.
"""
return self._impl.get_icon()

@icon.setter
def icon(self, value: IconContent | None) -> None:
if isinstance(value, toga.Icon):
icon = value
text = ""
elif value is None:
if self.icon is None:
# Already a null icon; nothing changes.
return
else:
icon = None
text = self._impl.get_text()
else:
icon = toga.Icon(value)
text = ""

self._impl.set_icon(icon)
self._impl.set_text(text)
self.refresh()

@property
Expand Down
Loading
Loading