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

LinkLabel Proposal #801

Open
UncleGoogle opened this issue Jan 26, 2020 · 20 comments
Open

LinkLabel Proposal #801

UncleGoogle opened this issue Jan 26, 2020 · 20 comments
Labels
enhancement New features, or improvements to existing features. not quite right The idea or PR has been reviewed, but more work is needed.

Comments

@UncleGoogle
Copy link
Contributor

UncleGoogle commented Jan 26, 2020

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
Copy link
Member

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
Copy link
Contributor Author

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

@UncleGoogle
Copy link
Contributor Author

UncleGoogle commented Feb 1, 2020

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:

  • what (color/style)
  • where (range of text lenght)

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>')
  1. 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?

  1. 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
Copy link
Member

Thanks for that research. A few comments:

  • The argument against concatenating multiple label widgets is that inter-word kerning (especially word spacing) won't be honored (or won't be done well). Cocoa, for example, will slightly compress the kerning of a font if there is not quite enough space for a piece of text, rather than truncate the text; however, if the widget is broken up into several sub-widgets, that math can't take place. It also becomes slightly complex - in your example, there's no space between Hello and World. Do you include the space in the "hello" widget, or the "world" widget?

    That said, this might be something we do internally on platforms that don't have viable native support for rich labels. Cocoa and GTK have native support, so we just have to expose what is already there; however, if it turns out that Winforms RichTextBox isn't viable (more on this later), we could always fall back to exposing a single widget as the toga interface, but internally use multiple native Winforms Label widgets as you've described.

  • I'd suggest the best approach from a markup perspective will be to accept an extreme subset of HTML (the sort of strict subset that Github applies to markdown - <b>, <i>, <u>, <strike>, <em> and <a> would be the obvious subset), with all other tags and style attributes being ignored (possibly with a warning that they're being ignored). Python has XML parser tooling baked in; writing a minimal HTML parser to convert text into pango/cocoa/winforms text markup instructions should be relatively straightforward. This also removes the need for a second "HTML Label" widget - it's just a label, and it happens to support HTML.

  • The Winforms Rich Text widget looks potentially interesting - however, I'm not yet convinced we'll be able to use it (at least, not for Label - there's been a MultilineLabel widget proposed where it might be appropriate - see widget to display multi-line text #766). 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 - the availability of scrollbars as part of the widget suggests it will tend towards scrolling behavior. Some testing may be needed to determine whether it is possible to us RichTextBox as forced single line, no scroll widget that reports a fixed height and width.

@UncleGoogle
Copy link
Contributor Author

UncleGoogle commented Feb 2, 2020

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:

  • how to disable mouse selection (this would disable scrolling; one way is to disable whole control, but it breaks any interactions)
  • how to get size of inner text (then we can adjust size of whole label that is required, or trim text)

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
Copy link
Member

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
Copy link
Member

samschott commented Apr 23, 2020

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
Copy link
Member

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

@freakboy3742
Copy link
Member

@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)?

@freakboy3742 freakboy3742 added enhancement New features, or improvements to existing features. not quite right The idea or PR has been reviewed, but more work is needed. up-for-grabs labels Apr 25, 2020
@samschott
Copy link
Member

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
Copy link
Member

@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
Copy link
Member

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
Copy link
Member

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
Copy link
Member

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
Copy link

goanpeca commented Jul 9, 2020

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
Copy link
Member

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
Copy link
Member

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
Copy link
Member

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

@massenz
Copy link

massenz commented Apr 5, 2021

[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
Copy link
Member

@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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New features, or improvements to existing features. not quite right The idea or PR has been reviewed, but more work is needed.
Projects
None yet
Development

No branches or pull requests

5 participants