Skip to content

Commit

Permalink
Add toast overlay (#122)
Browse files Browse the repository at this point in the history
* Move gtk.nim into bindings dir

* Refactor adwaita bindings into a binding module

* Re export bound enums

* Add general toast overlay widget

* Add first stab at adding signal listening to toast

* Fix dismissal handler segfaulting

* Fix memory leak for toast

* Make setters and getters more semantic in their bindings

This way they appear like fields on the Adwaita type.

* Refine example to provide notify button

* Allow more types for timeout

* Disable auto-dismissing toast after timeout

I currently have no solution for the use-after-free
conundrum.
That conundrum is caused by setting a timeout in the
owlkettle layer to dismiss the toast after it's timeout expires.

If you do that and the user dismisses the toast, then the
toast-memory gets free'd after a while by gtk.
If you try to dismiss that toast then after the timeout, you access
memory that was free'd, causing a user-after-free segfault.

I have a feeling gtk should be the one that dismisses these toasts
automatically after the timeout expires,
but I can't get that to work.

* Fix example notify button triggering toasts twice

* Add click handler to toast overlay

* Fix toast auto dismissal issue

* Refine example

* Remove last remaining exception and enable urgent notification examples

* Added getters for Toast properties

* Remove unnecessary nil default assignment

* Add docs

* Make Toast Overlay example compileable without higher adw version

* Update docs

* Refactor AdwToast to use a wrapper type "Toast"

This enables us to provide memory management hooks later.

* Add native approach

Note: This will segfault if you summon a toast,
wait for it to be dismissed
and then try to summon the next one.

The reason for this is that ToastOverlay will free the toast after dismissing it.
If you then swap out the ref that'll trigger the destructor hook,
which will try to unref a pointer to freed memory,
causing a use-after-free error.

* Enable adding a list of toasts

* Update docs

* Comment in hooks again

* Move over toast approach to custom object

That object is only converted to an AdwToast
at the last moment before handing it over
to ToastOverlay.
This way things that belong to gtk are only created when they're
definitely going into to ownership of the gtk library.
Thus no memory leak even conceptually can occur,
as nim-types will automatically be memory managed.

* Fix bugs and example

* Improve docs

* Move to ToastQueue

This way you can just "addToast" onto a queue the same way as you do with textbuffer.
As opposed to assigning the same toast over and over again.

* Refinements

* Remove pointless button

* Make it possible to use procs that are closures for handler fields.

* Better hook usage

* Remove toast from event callback

* Refactor

* Refactor

* Remove unimplemented fields from docs

* Update image

* Remove unnecesasry code from earlier attempt

* Improve example

* Improve docs

* Remove owlkettle import

* Fix exception message

---------

Co-authored-by: Can Lehmann <can.l@posteo.de>
  • Loading branch information
PhilippMDoerner and can-lehmann committed Jun 16, 2024
1 parent 2029586 commit fb0a7a0
Show file tree
Hide file tree
Showing 6 changed files with 300 additions and 0 deletions.
Binary file added docs/assets/examples/toast_overlay.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 32 additions & 0 deletions docs/widgets_adwaita.md
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,38 @@ AboutWindow:
```


## ToastOverlay

```nim
renderable ToastOverlay of BaseWidget
```

Displays messages (toasts) to the user.

Use `newToast` to create a `Toast`.
`Toast` has the following properties that can be assigned to:

- title: The text to display in the toast. Hidden if customTitle is set.
- customTitle: A Widget to display in the toast. Causes title to be hidden if it is set. Only available when compiling for Adwaita version 1.2 or higher.
- buttonLabel: If set, the Toast will contain a button with this string as its text. If not set, the Toast will not contain a button.
- priority: Defines the behaviour of the toast. `ToastPriorityNormal` will put the toast at the end of the queue of toasts to display. `ToastPriorityHigh` will display the toast **immediately**, ignoring any others.
- timeout: The time in seconds after which the toast is dismissed automatically. Disables automatic dismissal if set to 0. Defaults to 5.
- dismissalHandler: An event handler which is called when the toast is dismissed
- clickedHandler: An event handler which is called when the user clicks on the button that appears if `buttonLabel` is defined. Only available when compiling for Adwaita version 1.2 or higher.
- useMarkup: Whether to interpret the title as Pango Markup. Only available when compiling for Adwaita version 1.4 or higher.

###### Fields

- All fields from [BaseWidget](#BaseWidget)
- `child: Widget`
- `toastQueue: ToastQueue` The Toasts to display. Toasts of priority `ToastPriorityNormal` are displayed in First-In-First-Out order, after toasts of priority `ToastPriorityHigh` which are displayed in Last-In-First-Out order.

###### Adders

- All adders from [BaseWidget](#BaseWidget)
- `add`


## SwitchRow

```nim
Expand Down
4 changes: 4 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,10 @@ The `widgets` directory contains examples for how to use different widgets.
<td><a href="https://github.com/can-lehmann/owlkettle/blob/main/examples/widgets/adw/toolbar_view.nim">Toolbar View</a></td>
<td><img alt="Toolbar View" src="../docs/assets/examples/toolbar_view.png" width="622px"></td>
</tr>
<tr>
<td><a href="https://github.com/can-lehmann/owlkettle/blob/main/examples/widgets/adw/toast_overlay.nim">Toast Overlay</a></td>
<td><img alt="Toast Overlay" src="../docs/assets/examples/toast_overlay.png" width="580px"></td>
</tr>
<tr>
<td><a href="https://github.com/can-lehmann/owlkettle/blob/main/examples/widgets/adw/window_title.nim">Window Title</a></td>
<td><img alt="Window Title" src="../docs/assets/examples/window_title.png" width="288px"></td>
Expand Down
90 changes: 90 additions & 0 deletions examples/widgets/adw/toast_overlay.nim
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# MIT License
#
# Copyright (c) 2022 Can Joshua Lehmann
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

import owlkettle
import owlkettle/[playground, adw]
import std/[sequtils, sugar]

viewable App:
buttonLabel: string = "Button"
timeout: int = 3
useMarkup: bool = true ## Enables using markup in title. Only available for Adwaita version 1.4 or higher. Compile for Adwaita version 1.4 or higher with -d:adwMinor=4.
toastQueue: ToastQueue = newToastQueue()

proc buildToast(state: AppState,
title: string = "",
priority: ToastPriority = ToastPriorityNormal): Toast =

let toast = newToast(
title = title,
buttonLabel = state.buttonLabel,
priority = priority,
timeout = state.timeout
)

when AdwVersion >= (1, 2):
toast.clickedHandler = proc() =
echo "Click: ", toast.title

when AdwVersion >= (1, 4):
toast.useMarkup = state.useMarkup

toast.dismissalHandler = proc() =
echo "Dismissed: ", toast.title

return toast

method view(app: AppState): Widget =
result = gui:
Window:
title = "Toast Overlay Example"
defaultSize = (500, 350)

HeaderBar {.addTitlebar.}:
insert(app.toAutoFormMenu(ignoreFields = @["toastQueue"], sizeRequest = (400, 250))){.addRight.}

Box:
orient = OrientY

ToastOverlay:
toastQueue = app.toastQueue

Box:
Box {.hAlign: AlignCenter, vAlign: AlignCenter.}:
orient = OrientX
spacing = 12

Button:
style = [ButtonPill]
text = "Urgent"
proc clicked() =
let toast = buildToast(app, "Urgent Toast", ToastPriorityHigh)
app.toastQueue.add(toast)

Button:
style = [ButtonPill, ButtonSuggested]
text = "Notify"
proc clicked() =
let toast = buildToast(app, "Toast", ToastPriorityNormal)
app.toastQueue.add(toast)

adw.brew(gui(App()))
132 changes: 132 additions & 0 deletions owlkettle/adw.nim
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export adw.ColorScheme
export adw.FlapFoldPolicy
export adw.FoldThresholdPolicy
export adw.FlapTransitionType
export adw.ToastPriority
export adw.ToolbarStyle
export adw.LengthUnit
export adw.CenteringPolicy
Expand Down Expand Up @@ -1292,6 +1293,136 @@ renderable AboutWindow {.since: AdwVersion >= (1, 2).}:
when AdwVersion >= (1, 2):
export AboutWindow

type Toast* = ref object
title*: string
customTitle*: Widget
buttonLabel*: string
priority*: ToastPriority
timeout*: int
dismissalHandler*: proc()
clickedHandler*: proc()
useMarkup*: bool

proc newToast*(
title: string,
buttonLabel: string = "",
priority: ToastPriority = ToastPriorityNormal,
dismissalHandler: proc() = nil,
clickedHandler: proc() = nil,
timeout: int = 5,
useMarkup: bool = false,
customTitle: Widget = nil.Widget
): Toast =
result = Toast(
title: title,
buttonLabel: buttonLabel,
priority: priority,
timeout: timeout,
dismissalHandler: dismissalHandler,
clickedHandler: clickedHandler,
customTitle: customTitle,
useMarkup: useMarkup
)

proc connectSignal(obj: pointer, userCallback: proc() {.closure.}, eventName: string) =
proc callback(obj: pointer, data: ptr EventObj[proc ()]) {.cdecl.} =
let event = unwrapSharedCell(data)
event.callback()
# Disconnect event-handler after Toast was dismissed
g_signal_handler_disconnect(obj, event.handler)

let event = EventObj[proc()]()
let data = allocSharedCell(event)
data.callback = userCallback
data.handler = g_signal_connect(obj, eventName.cstring, callback, data)

proc toGtk(toast: Toast): AdwToast =
result = adw_toast_new(toast.title.cstring)
if toast.buttonLabel != "":
adw_toast_set_button_label(result, toast.buttonLabel.cstring)
adw_toast_set_priority(result, toast.priority)
adw_toast_set_timeout(result, toast.timeout.cuint)

# Set Dismissal Handler
if not toast.dismissalHandler.isNil():
connectSignal(pointer(result), toast.dismissalHandler, "dismissed")

when AdwVersion >= (1, 2):
if not toast.customTitle.isNil():
let customTitleWidget = toast.customTitle.build().unwrapInternalWidget()
adw_toast_set_custom_title(result, customTitleWidget)

# Set Clicked Handler
if not toast.clickedHandler.isNil():
connectSignal(pointer(result), toast.clickedHandler, "button-clicked")
else:
let isUsingCustomTitle = not toast.customTitle.isNil()
if isUsingCustomTitle:
raise newException(LibraryError, "The customTitle field on a Toast instance is not available when compiling for Adwaita versions below 1.2. Compile for Adwaita version 1.2 or higher with -d:adwminor=2 to enable it")

let isUsingClickedHandler = not toast.clickedHandler.isNil()
if isUsingClickedHandler:
raise newException(LibraryError, "The clickedHandler field on a Toast instance is not available when compiling for Adwaita versions below 1.2. Compile for Adwaita version 1.2 or higher with -d:adwminor=2 to enable it")

when AdwVersion >= (1, 4):
adw_toast_set_use_markup(result, toast.useMarkup.cbool)
else:
if toast.useMarkup:
raise newException(LibraryError, "The useMarkup field on a Toast instance is not available when compiling for Adwaita versions below 1.4. Compile for Adwaita version 1.4 or higher with -d:adwminor=4 to enable it")

type ToastQueue* = ref object
toasts: seq[Toast]

proc newToastQueue*(): ToastQueue = ToastQueue()
proc add*(queue: ToastQueue, toast: Toast) =
queue.toasts.add(toast)

proc add*(queue: ToastQueue, toasts: openArray[Toast]) =
for toast in toasts:
queue.add(toast)

proc clear(queue: ToastQueue) = queue.toasts = @[]

renderable ToastOverlay of BaseWidget:
## Displays messages (toasts) to the user.
##
## Use `newToast` to create a `Toast`.
## `Toast` has the following properties that can be assigned to:
##
## - title: The text to display in the toast. Hidden if customTitle is set.
## - customTitle: A Widget to display in the toast. Causes title to be hidden if it is set. Only available when compiling for Adwaita version 1.2 or higher.
## - buttonLabel: If set, the Toast will contain a button with this string as its text. If not set, the Toast will not contain a button.
## - priority: Defines the behaviour of the toast. `ToastPriorityNormal` will put the toast at the end of the queue of toasts to display. `ToastPriorityHigh` will display the toast **immediately**, ignoring any others.
## - timeout: The time in seconds after which the toast is dismissed automatically. Disables automatic dismissal if set to 0. Defaults to 5.
## - dismissalHandler: An event handler which is called when the toast is dismissed
## - clickedHandler: An event handler which is called when the user clicks on the button that appears if `buttonLabel` is defined. Only available when compiling for Adwaita version 1.2 or higher.
## - useMarkup: Whether to interpret the title as Pango Markup. Only available when compiling for Adwaita version 1.4 or higher.

child: Widget
toastQueue: ToastQueue ## The Toasts to display. Toasts of priority `ToastPriorityNormal` are displayed in First-In-First-Out order, after toasts of priority `ToastPriorityHigh` which are displayed in Last-In-First-Out order.

hooks:
beforeBuild:
state.internalWidget = adw_toast_overlay_new()

hooks child:
(build, update):
state.updateChild(state.child, widget.valChild, adw_toast_overlay_set_child)

hooks toastQueue:
(build, update):
state.toastQueue = widget.valToastQueue
if not state.toastQueue.isNil():
for toast in state.toastQueue.toasts:
adw_toast_overlay_add_toast(state.internalWidget, toast.toGtk())
state.toastQueue.clear()

adder add:
if widget.hasChild:
raise newException(ValueError, "Unable to add multiple children to a ToastOverlay. Use a Box widget to display multiple widgets in a ToastOverlay.")
widget.hasChild = true
widget.valChild = child

renderable SwitchRow {.since: AdwVersion >= (1, 4).} of ActionRow:
active: bool

Expand Down Expand Up @@ -1354,6 +1485,7 @@ renderable Banner {.since: AdwVersion >= (1, 3).} of BaseWidget:
when AdwVersion >= (1, 3):
export Banner

export ToastOverlay, Toast
export AdwWindow, WindowTitle, AdwHeaderBar, Avatar, ButtonContent, Clamp, PreferencesGroup, PreferencesRow, ActionRow, ExpanderRow, ComboRow, Flap, SplitButton, StatusPage, PreferencesPage

proc defaultStyleManager*(): StyleManager =
Expand Down
42 changes: 42 additions & 0 deletions owlkettle/bindings/adw.nim
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,19 @@ type
ToolbarRaised
ToolbarRaisedBorder

ToastPriority* = enum
ToastPriorityNormal
ToastPriorityHigh

AdwToast* = distinct pointer

proc isNil*(manager: StyleManager): bool {.borrow.}

proc isNil*(widget: AdwToast): bool {.borrow.}

proc g_signal_connect*(app: AdwToast, signal: cstring, closure, data: pointer): culong =
result = g_signal_connect_data(app.pointer, signal, closure, data, nil, G_CONNECT_AFTER)

{.push importc, cdecl.}
# Adw
proc adw_init*()
Expand Down Expand Up @@ -282,6 +293,37 @@ when AdwVersion >= (1, 2):
proc adw_about_window_add_acknowledgement_section*(window: GtkWidget, name: cstring, people: cstringArray)
proc adw_about_window_add_link*(window: GtkWidget, title: cstring, url: cstring)

# Adw.ToastOverlay
proc adw_toast_overlay_new*(): GtkWidget
proc adw_toast_overlay_add_toast*(self: GtkWidget, toast: AdwToast)
proc adw_toast_overlay_set_child*(self: GtkWidget, child: GtkWidget)

# Adw.Toast
proc adw_toast_new*(title: cstring): AdwToast
proc adw_toast_dismiss*(self: AdwToast)
proc adw_toast_set_action_name*(self: AdwToast, action_name: cstring)
proc adw_toast_get_action_name*(self: AdwToast): cstring
proc adw_toast_set_action_target*(self: AdwToast, format_string: cstring)
proc adw_toast_get_action_target*(self: AdwToast): cstring
# proc adw_toast_set_action_target_value*(self: AdwToast, action_target: GVariant)
proc adw_toast_set_button_label*(self: AdwToast, button_label: cstring)
proc adw_toast_get_button_label*(self: AdwToast): cstring
proc adw_toast_set_detailed_action_name*(self: AdwToast, detailed_action_name: cstring)
proc adw_toast_set_priority*(self: AdwToast, priority: ToastPriority)
proc adw_toast_get_priority*(self: AdwToast): ToastPriority
proc adw_toast_set_timeout*(self: AdwToast, timeout: cuint)
proc adw_toast_get_timeout*(self: AdwToast): cuint
proc adw_toast_set_title*(self: AdwToast, title: cstring)
proc adw_toast_get_title*(self: AdwToast): cstring

when AdwVersion >= (1, 2):
proc adw_toast_set_custom_title*(self: AdwToast, widget: GtkWidget)
proc adw_toast_get_custom_title*(self: AdwToast): GtkWidget

when AdwVersion >= (1, 4):
proc adw_toast_set_use_markup*(self: AdwToast, use_markup: cbool)
proc adw_toast_get_use_markup*(self: AdwToast): cbool

when AdwVersion >= (1, 4):
# Adw.SwitchRow
proc adw_switch_row_new*(): GtkWidget
Expand Down

0 comments on commit fb0a7a0

Please sign in to comment.