beeware / toga

A Python native, OS native GUI toolkit.
https://toga.readthedocs.io/en/latest/
BSD 3-Clause "New" or "Revised" License
4.31k stars 668 forks source link

LinkLabel Proposal #801

Open UncleGoogle opened 4 years ago

UncleGoogle commented 4 years ago

First of all I know that toga is not aimed to support widget primitives.

But there is already Label widget that serves for quite low level purposes:

So I think it may be useful as useful is html tag.

Label-inherited POC for Winforms:

from toga_winforms.libs import WinForms
from toga_winforms.widgets.label import Label as WinFormsLabel

class WinformsLinkLabel(WinFormsLabel):
    def create(self):
        self.native = WinForms.LinkLabel()
        self.native.LinkClicked += WinForms.LinkLabelLinkClickedEventHandler(
            self.interface._link_clicked
        )

class LinkLabel(toga.Label):
    def __init__(self, text, link=None, id=None, style=None, factory=None):
        toga.Widget.__init__(self, id=id, style=style, factory=factory)

        self._impl = WinformsLinkLabel(interface=self)
        # self._impl = self.factory.Label(interface=self)

        self.link = link
        self.text = text

    @property
    def link(self):
        if self._link is None:
            return self.text
        return self._link

    @link.setter
    def link(self, link):
        self._link = link

    def _link_clicked(self, el, _):
        webbrowser.open(self.link)

My usecase: a dead simple GUI - with link to my repository. I wanted to avoid strange Button with URL inside (alternatively clickable github icon would be OK but not supported currently #774 outside of native commands palette), or create Group named "About" inside which will be "See on github" action, as it would be the only one command and on Windows it feels strange.

freakboy3742 commented 4 years ago

Thanks for the suggestion! I can definitely agree with your use case - a simple hyperlink in a GUI is a common enough element that it makes sense to support it. The research you've done on the Winforms backend is also really helpful.

The only questions I would have are about implementation.

GTK provides a LinkButton, which has almost exactly the same API that Winforms provides.

However, it also provides support for HTML and links in a standard label (see the "Links" section of this page). macOS, iOS, and Android provide similar capabilities, allowing rich text markup in labels, and (with some configuration), hyperlinks.

So - the question for Toga becomes which API to support? LinkLabel is the easy approach for Toga to implement. However, adding rich text support to the base Label would, I suspect, result in an easier to manage API for end users - it's one less widget to know about, it would allow for multiple links in a single label, as well as other potential markup; and there would be no need to manage the layout issues associated with including a link in the label text. This is especially relevant if something like #766 is added.

However, that somewhat hinges on whether "rich text" approach is possible at all for Winforms. Are you able to dig around the Winforms API and see if there's any options?

UncleGoogle commented 4 years ago

Hey, I'll try to find some time for Winforms research this weekend

UncleGoogle commented 4 years ago

Hey.

That will be rather short check than comprehensive research with examples. I hope it helps.

Toga API

First of all I'm not sure how rich text API we want to expose.

GTK markup you've mentioned:

Markup strings are just a convenient way to set the PangoAttrList on a label;

those: https://developer.gnome.org/pango/stable/pango-Text-Attributes.html#PangoAttribute-struct

are structs that just tells about 2 information:

Maybe its good idea to use in toga as very "granular"? I don't know, to low knowledge, maybe its an overkill.

1) most obvious way would be to use super wise html/markup translation engine

toga.HTMLLabel('<b>Hello</b> <a href="www.wikipedia.org">World</a>')

2) The other way would be to allow label merging:

my_super_label = toga.Label('Hello', font_size=400) + toga.Label('World', link="www.wikipedia.org"))

A bit more verbose and annoying but maybe more simple to code maybe?

3) Another funny way utilizing python strings with setting wise __str__ method but it requires to be recursive (well, box in box...)

toga.Label("{} {}".format(
    toga.Label("Hello"),
    toga.Label("World", link="www.wikipedia.org")
))

Winforms

In Winforms there is RichTextBox

HTML to RTF Converters:

Python RTF Generator:

There is also html-rendering library in C# that supports Winforms. This one looks like mature project. https://github.com/ArthurHub/HTML-Renderer/blob/master/Source/HtmlRenderer.WinForms/HtmlLabel.cs

Some other ideas/discussion (but mostly RichTextBox): https://stackoverflow.com/questions/11311/formatting-text-in-winform-label

freakboy3742 commented 4 years ago

Thanks for that research. A few comments:

UncleGoogle commented 4 years ago

One of the requirements for a text label is that it contains a single line of text, and the widget can report it's minimum size. It's not clear from the API and examples you've linked whether you can force the rich text widget to behave in this way.

I see. I've tried to adjust RichTextBox for this purpose but did't get it. Scrollbars can be disabled but not scrolling property (if label is longer than available space)

class WinformsRichLabel(WinFormsLabel):
    def create(self):
       self.native = WinForms.RichTextBox()
       self.native.set_ScrollBars(0)
       self.native.set_BorderStyle(0)
       self.native.set_ReadOnly(True)
       # self.native.set_WordWrap(False)  # forces single line; as side effect, text can be scrolled by selecting (see video)
       self.native.set_Multiline(False)  # similar effect to wordWrap(False) What difference?
       self.native.set_TabStop(False)  # disable control focusing using tab navigation
       # self.native.set_CanSelect(False)  # there is only getter! :(
       # self.native.set_Enabled(False)  # prevents selection and scrolling effect, but disables all events and text is gray out

class Label(toga.Label):
    def __init__(self, text, link=None, id=None, style=None, factory=None):
        toga.Widget.__init__(self, id=id, style=style, factory=factory)

        self._impl = WinformsRichLabel(interface=self)

# (...) and then after initialization with default sizes...

lbl_style = Pack(font_size=10, text_align="center")
link_label = Label("ve vwer y long asdf asdf asdf asdf aief wejf asldf jaweif asldfj aweif lasdkfjweif jlasfj awei", style=lbl_style)
print('TextLenght:', link_label._impl.native.TextLength)
size = link_label._impl.native.get_MaximumSize()
size.set_Width(100)  # arbitral value. TextLength property could be used if we could translate lenght of text to pixels...
link_label._impl.native.set_MaximumSize(size)  # or just set_PreferredSize(?)

https://i.gyazo.com/b31babdf56b3c8048e4675aaf16303fe.mp4 Or after Enabled is set to False: https://i.gyazo.com/e4a211ce3fa7017c64f2d0fbcd97d7fd.png

So unsolved problems are:

Maybe there is answer here: https://github.com/ArthurHub/HTML-Renderer/blob/master/Source/HtmlRenderer.WinForms/HtmlLabel.cs though I didn't dig into it.

freakboy3742 commented 4 years ago

That's what I was afraid of - if we can't get the size of the underlying text, then we can't use the size of the text in layout calculations.

The HTMLLabel widget looks interesting. If I'm reading that right, it's going back to basics - literally drawing all the component text. That seems slighly overkill under the circumstances, though.

In which case, having the Windows implementation be "concatenation of Winforms.Label" (and Winforms.LinkLabel, as needed) might be the easiest path forward.

As an aside/simplification, you should find that you can assign attributes directly, rather than invoking set_ methods - self.native.set_ReadOnly(True) should be equivalent to self.native.ReadOnly = True.

samschott commented 4 years ago

I have used a hacked-together version of a RichLabel for macOS myself but it is far from perfect. It exploits the possibility to initialise a NSAttributetString from html:

class RichLabel(Widget):
    """A multiline text view with html support. Rehint is only a hack for now.
    Using the layout manager of NSTextView does not work well since it generally returns
    a too small height for a given width."""

    def create(self):
        self._color = None
        self.native = NSTextView.alloc().init()
        self.native.impl = self
        self.native.interface = self.interface

        self.native.drawsBackground = False
        self.native.editable = False
        self.native.selectable = True
        self.native.textContainer.lineFragmentPadding = 0

        self.native.bezeled = False

        # Add the layout constraints
        self.add_constraints()

    def set_html(self, value):
        attr_str = attributed_str_from_html(value, self.native.font, color=self._color)
        self.native.textStorage.setAttributedString(attr_str)
        self.rehint()

    def set_font(self, value):
        if value:
            self.native.font = value._impl.native

    def set_color(self, value):
        if value:
            self._color = native_color(value)

        # update html
        self.set_html(self.interface.html)

    def rehint(self):
        self.interface.intrinsic.width = at_least(self.interface.MIN_WIDTH)
        self.interface.intrinsic.height = at_least(self.interface.MIN_HEIGHT)

The "magic" lies in the function attributed_str_from_html which takes the text colour from a default label if not specified by the user, otherwise the text will always be black regardless of dark mode:

def attributed_str_from_html(raw_html, font=None, color=None):
    """Converts html to a NSAttributed string using the system font family and color."""

    html_value = """
    <span style="font-family: '{0}'; font-size: {1}; color: {2}">
    {3}
    </span>
    """
    font_family = font.fontName if font else 'system-ui'
    font_size = font.pointSize if font else 13
    color = color or NSColor.labelColor
    c = color.colorUsingColorSpace(NSColorSpace.deviceRGBColorSpace)
    c_str = f'rgb({c.redComponent * 255},{c.blueComponent * 255},{c.greenComponent * 255})'
    html_value = html_value.format(font_family, font_size, c_str, raw_html)
    nsstring = NSString(at(html_value))
    data = nsstring.dataUsingEncoding(NSUTF8StringEncoding)
    attr_str = NSMutableAttributedString.alloc().initWithHTML(
        data,
        documentAttributes=None,
    )
    return attr_str
samschott commented 4 years ago

If interested, I can clean up the code and submit a PR.

freakboy3742 commented 4 years ago

@SamSchott Definitely interested in the feature; and the general approach you've taken makes sense on Cocoa.

I guess my question is whether this needs to be a different widget. MultilineLabel definitely needs to exist (see #766), but that's to add line wrapping behavior to a text widget. Could the standard Label widget support both plain and rich text (possibly by using a .html attribute to trigger parsing and setting HTML content)?

samschott commented 4 years ago

Could the standard Label widget support both plain and rich text (possibly by using a .html attribute to trigger parsing and setting HTML content)?

I think this would indeed be a nicer API. However, the implementation will be a bit more difficult: I have not managed to make hrefs clickable in an NSTextField. Furthermore, if the text is "selectable" and the user selects its, all rich attributes get removed. I have therefore used a NSTextView instead. This is automatically multiline but maybe one can force it to be single line instead and get the appropriate width from the layout manager of the NSTextView.

samschott commented 4 years ago

@freakboy3742 I am trying to put together a PR that introduces simple html rendering capabilities to a Label. IMO, there are two options:

  1. Add an additional html property which, if set, will take precedence over any plain text (similar to the QLabel widget in Qt). This is more explicit but results in a more complex API.
  2. Simply render any html given in the text property. This is simpler and provides a more fluid transition from plain to rich text. The implementation may also be cleaner. It does require the user to escape html code if it should be displayed as plain text.

Any preference?

freakboy3742 commented 4 years ago

I think 2 properties makes sense as a safety mechanism; it won't always be obvious why < and > characters are being eaten if they're used.

The API doesn't need to be that complex, though - if "html" is the canonical representation, any call to .text is a call to escape the HTML content with html.escape, and then set the canonical .html value.

I also wouldn't be opposed to introducing some light tag parsing - stripping out tags that we don't support (essentially, we'd only be preserving <a>,<b>,<em>, and maybe handful of others; and we won't be interpreting "style" tags on HTML; stripping those would make some sense. However, that's somewhat performance dependent - if we can't do that stripping really quickly, it's probably not worth the effort.

samschott commented 4 years ago

The API doesn't need to be that complex, though

Fair point. Also, there is yet another option: have a single text attribute and a text_format attribute which can be for instance "plain", "html" or another markup language such as "markdown" if this should be supported in the future. This works around having two different properties that hold the displayed text while still preventing ambiguity.

I'll look into stripping certain html tags. Python's XML parser may have trouble with self-closing html tags.

freakboy3742 commented 4 years ago

I see what you're saying; however, I think I prefer the clarify of:

my_label.text = 'some plain text'
other_label.html = 'some <b>important</b> text'

vs

my_label.text = 'some plain text'
other_label.text = 'some <b>important</b> text'
other_label.text_format = 'html'

If we were going to add support for other formats, I think I'd rather take an approach that lets end-users define the formatting - e.g., allow the value of text or html to be an object with an __str__ or __html__ attribute, instead of just a string.

goanpeca commented 4 years ago

My 2 cents:

my_label.text = 'some plain text'
other_label.text = 'some <b>important</b> text'
other_label.text_format = 'html'
other_label.rendered_text -> would probably be always HTML no matter the input format?

This would keep the API stable


my_label.text = 'some plain text'
other_label.html = 'some <b>important</b> text'

This may be more concise but adding new formats would imply having new properties that need to be created?

samschott commented 4 years ago

If we were going to add support for other formats, I think I'd rather take an approach that lets end-users define the formatting - e.g., allow the value of text or html to be an object with an str or html attribute, instead of just a string.

This effectively favours html over other markup languages. I still prefer the more egalitarian approach of a text_format property but would be ok with either.

Regarding the interface with the implementation layer, I do think that we should choose html as the markup language to pass all formatted text. AppKit's attributed strings can be generated from html but I am not so sure about Gtk labels with pango markup. Also, reliable conversion from html to RTF seems challenging. Maybe the solution is really to support a very limited subset of html tags and perform the conversion "manually".

freakboy3742 commented 4 years ago

I agree that it favours HTML - however, the fact is: HTML is a favoured format, if only in the sense that it's a markup language that (a) is commonly understood, and (b) is often provided natively as an API for displaying rich text. The use case you're describing is " in, pretty display out" - and it would be highly unusual case where HTML wasn't a supported output for a markup format.

The downside I see to the text_format property is that it is inherently limited to text formats that we support, or we need to open up a plugin interface, which seems like massive overkill for a label. Adding support for __html__ means that any object can be used as a label - including a Markdown() object or ReST() object that knows how to render itself as HTML. All we're committing to is that HTML is a useful interchange format for rich text - which it exactly what it is. And, it doesn't preclude us adding a .markdown attribute in future if we found a particular need or benefit to do so.

I'd also be completely OK with a very limited subset of HTML being supported (hence my earlier comment about parsing and stripping). Even if it was just <b>, <em>, and <a>, we'd be covering most of the use cases for label. The idea that a label is going to be able to make sense of something like <footer> seems like a folly to me. From the earlier discussion, it sounds like this is pretty much what we're going to be doing for Winforms anyway.

samschott commented 4 years ago

What is toga's policy on dependencies? bleach seems to do exactly the type of white-listed html stripping which we need.

massenz commented 3 years ago

[Commenting here as #1237 was closed]

What was the outcome of all this conversation? Given that there has been no activity for more than 6 months, and we still don't have a way to open a URL in Toga, is there any suggestion/workaround for iOS (in particular)?

Thanks!

freakboy3742 commented 3 years ago

@massenz The outcome is "Yes, sure, we'd like this, but someone needs to implement it".

In the meantime, no - there isn't an easy workaround. You might be able to use some of the sample code from this thread in your own app, however.