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 State Refs #166

Open
wants to merge 27 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9a2b0f5
Starting point for circular references
PhilippMDoerner Jun 17, 2024
6c773f0
Small Refactoring of DSL for better clarity
PhilippMDoerner Jun 17, 2024
699fd71
#115 Add the ability to have circular References
PhilippMDoerner Jun 19, 2024
358b531
#115 Ignore stateRef field in playground
PhilippMDoerner Jun 19, 2024
d7433f7
#115 Enable playground to deal with nil ref values
PhilippMDoerner Jun 19, 2024
eb6e66f
#115 Remove unused import
PhilippMDoerner Jun 19, 2024
3cc80ee
#115 improve semantics and add utility for fetching widget
PhilippMDoerner Jun 21, 2024
24d1d4b
#115 Implement search entry keyCaptureWidget as example
PhilippMDoerner Jun 21, 2024
6b36651
#115 Improve nil handling on stateRef
PhilippMDoerner Jun 21, 2024
286888d
#115 Improve dealing with unsubscribing
PhilippMDoerner Jun 21, 2024
988dd29
#115 Use better syntax
PhilippMDoerner Jun 21, 2024
faa291f
#115 Improve handling of stateRef changes
PhilippMDoerner Jun 21, 2024
56c88cb
Modifies both parsing the gui-DSL into Node-types as well as generati…
PhilippMDoerner Jun 22, 2024
b4dba5f
#115 Fix parsing bug with as assignment
PhilippMDoerner Jun 22, 2024
04dcfc0
#115 remove optional in favour of nil
PhilippMDoerner Jun 22, 2024
0f04d1c
#115 Refactor node-check
PhilippMDoerner Jun 22, 2024
c9d69b4
#115 Remove echo
PhilippMDoerner Jun 22, 2024
73beec5
#115 Fix pragma parsing
PhilippMDoerner Jun 22, 2024
f2b2f13
#115 Add example for using as syntax with adder
PhilippMDoerner Jun 22, 2024
262f443
#115 Reduce fields on StateRef
PhilippMDoerner Jun 22, 2024
f9d7560
#115 Remove documentation improvements for separate PR
PhilippMDoerner Jun 22, 2024
26385a6
#115 Remove unused proc
PhilippMDoerner Jun 22, 2024
e891b18
#115 Improve code consistency
PhilippMDoerner Jun 22, 2024
da9ca39
#115 Update docs and improve function signature
PhilippMDoerner Jun 22, 2024
402d35c
#115 Reduce nil checks
PhilippMDoerner Jun 22, 2024
793bd88
#115 Remove test example, we have docs now
PhilippMDoerner Jun 22, 2024
5eba35e
#115 Mild loc reduction
PhilippMDoerner Jun 22, 2024
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
47 changes: 47 additions & 0 deletions book/guides/gui_macro.nim
Original file line number Diff line number Diff line change
Expand Up @@ -147,4 +147,51 @@ block:
Box:
insert(childWidget) {.expand: false.}


nbText: """
## WidgetState references

Some features require you to inform Widget A of another Widget B.
In Owlkettle you can do this by sharing a `StateRef` object between Widget A and Widget B.
A `StateRef` is an object that observes Widgets can subscribe to in order to get informed whenever its value changes.
That way, the Widgets always share the same value and update themselves when changes occur.
You can create it via `newStateRef` (typically as a field on a viewable or renderable state), let it get filled with a reference using the `as` syntax and then passing that reference to Widget B.

See here where we pass SearchEntry a reference to the lower Box as keyCaptureRef.
All keyboard events thrown while the Box is in focus will get forwarded to the SearchEntry widget.

This also works as you would expect where adders or ather pragmas are concerned
"""

nbCode:
let boxRef = newStateRef()
let widget = gui:
Box():
SearchEntry():
keyCaptureRef = boxRef
Box as boxRef {.hAlign: AlignFill.}:
for value in 0..<3:
Label(text = $value)

nbText: """
To get notified of any changes occurring to the reference inside `StateRef`, you can subscribe.
"""

nbCode:
let initialSubscriber = proc(state: WidgetState) {.closure.} = echo "Initial Subscriber says BoxRef state changed"
let boxRef2 = newStateRef(initialSubscriber)

let laterSubscriber = proc(state: WidgetState) {.closure.} = echo "Later Subscriber says BoxRef state changed"
boxRef2.subscribe(laterSubscriber)

let widget2 = gui:
Box():
SearchEntry():
keyCaptureRef = boxRef2
Box as boxRef2 {.hAlign: AlignFill.}:
for value in 0..<3:
Label(text = $value)

boxRef2.unsubscribe(initialSubscriber)
boxRef2.unsubscribe(laterSubscriber)
nbSave
1 change: 1 addition & 0 deletions docs/widgets.md
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,7 @@ renderable SearchEntry of BaseWidget

- All fields from [BaseWidget](#BaseWidget)
- `text: string`
- `keyCaptureRef: StateRef`
- `searchDelay: uint = 100` Determines the minimum time after a `searchChanged` event occurred before the next can be emitted. Since: `GtkMinor >= 8`
- `placeholderText: string = "Search"` Since: `GtkMinor >= 10`

Expand Down
12 changes: 6 additions & 6 deletions examples/widgets/search_entry.nim
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ viewable App:
sensitive: bool = true
tooltip: string = ""
sizeRequest: tuple[x, y: int] = (-1, -1)

keyCaptureRef: StateRef = newStateRef(proc(state: WidgetState){.closure.} = echo "Key Capture Ref was filled")
items: seq[string] = mapIt(0..<100, "Item " & $it)
filteredItems: seq[string] = mapIt(0..<100, "Item " & $it)
selected: int = 0
Expand All @@ -41,9 +41,10 @@ method view(app: AppState): Widget =
Window():
defaultSize = (600, 400)
HeaderBar() {.addTitlebar.}:
insert(app.toAutoFormMenu(ignoreFields = @["filteredItems"])) {.addRight.}
insert(app.toAutoFormMenu(ignoreFields = @["filteredItems", "keyCapureRef", "dummyRef"])) {.addRight.}

SearchEntry() {.addTitle, expand: true.}:
keyCaptureRef = app.keyCaptureRef
margin = Margin(top:0, left: 48, bottom:0, right: 48)
text = app.text
searchDelay = app.searchDelay
Expand Down Expand Up @@ -73,7 +74,7 @@ method view(app: AppState): Widget =
app.text = ""
app.filteredItems = app.items

ScrolledWindow:
ScrolledWindow as app.keyCaptureRef:
PhilippMDoerner marked this conversation as resolved.
Show resolved Hide resolved
ListBox:
selectionMode = SelectionSingle
if app.selected < app.filteredItems.len:
Expand All @@ -86,8 +87,7 @@ method view(app: AppState): Widget =
app.selected = num

for index, item in app.filteredItems:
Box():
Label(text = item, margin = 6) {.hAlign: AlignStart, expand: false.}

ListBoxRow() {.addRow.}:
Label(text = item, margin = 6)

adw.brew(gui(App()))
39 changes: 38 additions & 1 deletion owlkettle/guidsl.nim
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type
of NodeWidget:
widget: seq[string]
adder: Adder
stateRef: NimNode
of NodeField:
name: string
value: NimNode
Expand Down Expand Up @@ -82,7 +83,7 @@ proc parseAdder(node: NimNode): Adder =

proc parseGui(node: NimNode): Node =
case node.kind:
of nnkCallKinds:
of nnkCallKinds - {nnkInfix}:
if node[0].unwrapName().eqIdent("insert"):
if node.len != 2:
error("The insert statement must have exactly one argument", node)
Expand All @@ -93,6 +94,35 @@ proc parseGui(node: NimNode): Node =
result = node[0].parseGui()
for it in 1..<node.len:
result.children.add(node[it].parseGui())
of nnkInfix: # For expressions like "<Widget> as <stateRefVariable>"
let isRefAssignmentExpression = node[0].eqIdent("as")
if not isRefAssignmentExpression:
error("You can only use infix for assigning stateReferences. That must be done via '<Widget> as <stateRefVariable>' syntax")
let widgetName = case node[1].kind:
of nnkIdent: node[1]
of nnkCall: node[1][0]
else:
error("Tried to use 'as' with invalid syntax", node)
newEmptyNode() # Forces the compiler to acknowlege that all branches of the case statement return a NimNode

let stateRefVar = case node[2].kind:
of nnkPragmaExpr: node[2][0]
else: node[2]
Comment on lines +108 to +110
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This just ignores the adder, doesn't it?

If you want to use the syntax Widget {.adder.} as .... Please report an error if the user tries to use Widget as ... {.adder.}.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does not in fact. Like, I didn't think deeper about that fact, but the DSL combination that gets created by Widget {.somepragma.} as ... appears to run into the else block in line 105.

        else: 
          error("Tried to use 'as' with invalid syntax", node)

At least when I added

          ListBoxRow() {.addRow.} as app.dummyRef:
            Label(text = "Static last")

To the search_entry example it blew up in my face with exactly that error message.

let widgetContent = node[3]

if widgetName.isQualifiedName:
result = Node(
kind: NodeWidget,
widget: widgetName.qualifiedName,
lineInfo: widgetContent,
stateRef: stateRefVar
)
else:
result = widgetName.parseGui()

for it in 3..<node.len: # Parse content of Widget. Ignore NimNodes for "as", widgetName and stateRefVar
result.children.add(node[it].parseGui())

of nnkPragmaExpr:
if node[0].isQualifiedName:
result = Node(kind: NodeWidget, widget: node[0].qualifiedName, lineInfo: node)
Expand Down Expand Up @@ -220,6 +250,13 @@ proc gen(node: Node, stmts, parent: NimNode) =
body.add(newLetStmt(name, newCall(widgetTyp)))
for child in node.children:
child.gen(body, name)

if not node.stateRef.isNil():
let refVar = node.stateRef
let refAssignment = quote do:
`name`.stateRef = `refVar`
body.add(refAssignment)

if not parent.isNil:
body.add(node.adder.gen(name, parent))
else:
Expand Down
17 changes: 12 additions & 5 deletions owlkettle/playground.nim
Original file line number Diff line number Diff line change
Expand Up @@ -292,10 +292,16 @@ proc toFormField(state: Viewable, field: ptr[auto], fieldName: string): Widget =

when hasFields:
var subFieldWidgets: Table[string, Widget] = initTable[string, Widget]()
for subFieldName, subFieldValue in field[].getIterator():
let subField: ptr = subFieldValue.addr
let subFieldWidget = state.toFormField(subField, subFieldName)
subFieldWidgets[subFieldName] = subFieldWidget
let hasFieldValue = when field.typeOf() is ptr ref:
field[].isNil()
else:
true

if hasFieldValue:
for subFieldName, subFieldValue in field[].getIterator():
let subField: ptr = subFieldValue.addr
let subFieldWidget = state.toFormField(subField, subFieldName)
subFieldWidgets[subFieldName] = subFieldWidget

return gui:
ExpanderRow:
Expand All @@ -314,8 +320,9 @@ proc toAutoFormMenu*[T](app: T, sizeRequest: tuple[x,y: int] = (400, 700), ignor
## `sizeRequest` defines the requested size for the popover.
## Displays a dummy widget if there is no `toFormField` implementation for a field with a custom type.
var fieldWidgets: seq[Widget] = @[]
const privateGeneralFields = ["app", "viewed", "stateRef"]
for name, value in app[].fieldPairs:
when name notin ["app", "viewed"] and name notin ignoreFields:
when name notin privateGeneralFields and name notin ignoreFields:
let field: ptr = value.addr
let fieldWidget = app.toFormField(field, name)
fieldWidgets.add(fieldWidget)
Expand Down
50 changes: 48 additions & 2 deletions owlkettle/widgetdef.nim
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

# Macros used to define new widgets

import std/[macros, strutils, tables]
import std/[macros, strutils, tables, sets]
when defined(nimPreviewSlimSystem):
import std/assertions
import common
Expand All @@ -31,7 +31,8 @@ import bindings/gtk
type
Widget* = ref object of RootObj
app*: Viewable

stateRef*: StateRef

WidgetState* = ref object of RootObj
app*: Viewable

Expand All @@ -49,6 +50,10 @@ type

Event*[T] = ref EventObj[T]

StateRef* = ref object
state: WidgetState
observers: HashSet[proc(state: WidgetState)]

method build*(widget: Widget): WidgetState {.base.} = discard
method update*(widget: Widget, state: WidgetState): WidgetState {.base.} = discard
method view*(viewable: Viewable): Widget {.base.} = discard
Expand Down Expand Up @@ -82,6 +87,38 @@ proc redraw*(viewable: Viewable): bool =
viewable.viewed = newWidget
result = true

proc hasRef*(stateRef: StateRef): bool = stateRef.state.isNil()

proc newStateRef*(subscribers: varargs[proc(state: WidgetState) {.closure.}]): StateRef =
var observers = initHashSet[proc(state: WidgetState)]()
for subscriber in subscribers:
observers.incl(subscriber)
return StateRef(observers: observers)

proc unwrapInternalWidget*(stateRef: StateRef): GtkWidget =
if not stateRef.hasRef():
return nil.GtkWidget

return stateRef.state.unwrapInternalWidget()

proc setRef*(stateRef: StateRef, state: WidgetState) =
let isStateChange = stateRef.state != state
if not isStateChange:
return

stateRef.state = state
for observer in stateRef.observers:
observer(stateRef.state)

proc subscribe*(stateRef: StateRef, observer: proc(state: WidgetState)) =
stateRef.observers.incl(observer)
if stateRef.hasRef():
observer(stateRef.state)

proc unsubscribe*(stateRef: StateRef, observer: proc(state: WidgetState)) =
stateRef.observers.excl(observer)


type
WidgetKind = enum WidgetRenderable, WidgetViewable

Expand Down Expand Up @@ -487,6 +524,11 @@ proc genBuildState(def: WidgetDef): NimNode =
body = result
)

proc genStateRef(widget: NimNode, state: NimNode): NimNode =
return quote:
if not `widget`.stateRef.isNil():
`widget`.stateRef.setRef(`state`)

proc genBuild(def: WidgetDef): NimNode =
let (state, widget) = (ident("state"), ident("widget"))
result = newStmtList(newVarStmt(state, newCall(ident(def.stateName))))
Expand All @@ -511,6 +553,8 @@ proc genBuild(def: WidgetDef): NimNode =
for body in def.hooks[HookAfterBuild]:
result.add(body)

result.add: genStateRef(widget, state)

result.add(newTree(nnkReturnStmt, state))
result = newProc(
procType=nnkMethodDef,
Expand Down Expand Up @@ -566,6 +610,8 @@ proc genUpdateState(def: WidgetDef): NimNode =
])
]))

result.add: genStateRef(widget, state)

for event in def.events:
result.add(newAssignment(
newDotExpr(state, event.name),
Expand Down
44 changes: 39 additions & 5 deletions owlkettle/widgets.nim
Original file line number Diff line number Diff line change
Expand Up @@ -2241,7 +2241,7 @@ renderable ModelButton of BaseWidget:

renderable SearchEntry of BaseWidget:
text: string
# child: GtkWidget # This is currently not supported
keyCaptureRef: StateRef
searchDelay {.since: GtkMinor >= 8.}: uint = 100 ## Determines the minimum time after a `searchChanged` event occurred before the next can be emitted.
placeholderText {.since: GtkMinor >= 10.}: string = "Search"

Expand Down Expand Up @@ -2277,10 +2277,44 @@ renderable SearchEntry of BaseWidget:
# state.internalWidget.disconnect(state.searchStarted) # Currently not supported
state.internalWidget.disconnect(state.stopSearch)

# hooks child:
# property:
# gtk_search_entry_set_key_capture_widget(state.internalWidget, state.child.pointer)

hooks keyCaptureRef:
build:
if widget.hasKeyCaptureRef and widget.valKeyCaptureRef.hasRef():
proc observer(childState: WidgetState) =
if childState.isNil():
return

let childWidget = childState.unwrapInternalWidget()
if not childWidget.isNil():
gtk_search_entry_set_key_capture_widget(state.internalWidget, childWidget)

widget.valKeyCaptureRef.subscribe(observer)
state.keyCaptureRef = widget.valKeyCaptureRef

update:
let isChangeFromNoneToSome = state.keyCaptureRef.isNil() and widget.hasKeyCaptureRef
let isChangeFromSomeToNone = not state.keyCaptureRef.isNil() and not widget.hasKeyCaptureRef
let isRefChange = isChangeFromNoneToSome or isChangeFromSomeToNone or state.keyCaptureRef != widget.valKeyCaptureRef
if isRefChange:
proc observer(childState: WidgetState) =
let childWidget = childState.unwrapInternalWidget()
if not childWidget.isNil():
gtk_search_entry_set_key_capture_widget(state.internalWidget, childWidget)

# Remove observer from old keyCaptureRef
let oldKeyCaptureRef = state.keyCaptureRef
oldKeyCaptureRef.unsubscribe(observer)

# Add observer to new keyCaptureRef
let hasNewKeyCaptureRef = widget.hasKeyCaptureRef
let newKeyCaptureRef = if hasNewKeyCaptureRef:
widget.valKeyCaptureRef.subscribe(observer)
widget.valKeyCaptureRef
else:
nil

state.keyCaptureRef = newKeyCaptureRef

hooks text:
property:
gtk_editable_set_text(state.internalWidget, state.text.cstring)
Expand Down
Loading