pywebview
pywebview copied to clipboard
DOM support/manipulation
The next major version of pywebview (5.0) is going to have support for DOM manipulation and event handling. The idea is to provide a basic set of functions that would allow to manipulate DOM and handle DOM events in Python. Event handling is already implemented in the dom branch. Syntax so far looks like this
element = window.dom.get_element('#element')
element.on('click', click_handler)
# this work as well
element.events.click += click_handler
You can take a look at a working example at https://github.com/r0x0r/pywebview/blob/dom/examples/dom_events.py
window.get_elements
got refactored so it returns a new Element
object. The underlying DOM node is available via element.node
. I am not 100% sure about this syntax, so this might change. Also should properties of element.node
be accessible via class dot notation instead of the dict? Ie. element.node.offsetTop
instead of element.node['offsetTop']
Window object got a new window.dom
property that currently hosts window.dom.document
and window.dom.window
(correspond to DOM's window.document
and window
respectively). These are instances of Element
, meaning that they follow the element.node
syntax. Ie to get a window scroll position, you call window.dom.window.node['scrollY']
. Would `window.dom.window.scrollY make a better choice?
DOM manipulation will provide a basic jQuery like set of functions. hide/show/toggle/remove/next/set_style etc. ~~Nothing is done on this front yet.~~
Implementation follows a single source of truth principle, ie no data is cached on the Python side, but the latest state is always fetched from DOM .
Please note that these are preliminary ideas and nothing is set it stone. Discussion, ideas and suggestions are welcomed.
So far API changes are as follows
Window
dom
- and instance of a DOM
object
get_elements(selector: str)
returns a list of Element
objects. Deprecated in favour of window.dom.get_elements
evaluate_js
raises a JavascriptException (serialized Javascript error) if executed code has an error.
DOM
body: Element
- an instance of document body
document: Element
- an instance of window.document
window: Element
- an instance of window
create_element(html: str, parent: Optional[Element]=None) -> Element
- creates a DOM structure that corresponds to the given html code. Returns an Element
of the root object . If parent is provided, created DOM is attached to the parent as a last child. Otherwise attached to document body.
get_element(selector: str) -> Optional[Element])
- get a first Element
matching the selector. None if not found.
get_elements(selector: str) -> list[Element])
- get a list of Element
matching the selector.
webview.dom.Element
node: dict
- jsonified representation of the matching DOM node. Node's children are not included.
id: str
- node's id. Get/set property
tag: str
- a tag name
tabindex: int
- Get/set tabindex.
text: str
- Get/set text content of the element
value: any
- Get/set value of the element. Applicable only to input elements that have a value
focused: bool
- whether element has a focus. Get property
visible: bool
- whether element is visible
'classes: ClassList' - a list of node's classes. Returns a list like object that can be mutated to update node's classes. Get/set property, accepts an Iterable
as a setter param.
attributes: PropsDict
- Node's attributes. Returns a dict like object that can be mutated to update node's attributes, ie. element.attributes['id'] = 'container'
Get/set property, accepts a dictionary as a setter param. When assigning a new value, new attributes are appended to existing ones overwriting matching attribute keys. Existing non-matching attributes are kept.
style: PropsDict
- Node's styles. Get/set property. Works in the same way as attributes.
events
- a container class of node's all DOM events. ie events.click
, event.keydown
etc
children -> list['Element']
- get a list of node's children. Get property.
parent -> Union['Element', None]
- get node's parent or None for the root node . Get property.
append(html: str) -> Element
- create and append html as a last child
next -> Union['Element', None]
- get node's next sibling. Get property.
previous -> Union['Element', None]
- get node's previous sibling. Get property.
hide()
- hide element
show()
- show element
focus()
- focus element
blur()
- blur element
toggle()
- toggle element's visibility
copy(target: Union[str, 'Element']=None, mode=ManipulationMode.LastChild, id: str=None)
- creates a new copy of the element. If target is omitted, a copy is created in the current element's parent. mode
parameters specifies in which fashion the copy will be inserted to the target. The id
parameter is stripped from the copy. Optionally you can set a new id by specifying the id
parameter.
move(target: Union[str, 'Element'], mode=ManipulationMode.LastChild)
- moves the element to the target container. mode
parameters specifies in which fashion the copy will be inserted to the target.
remove()
- remove element from DOM. After element is removed, trying to access any of element's properties/methods results in a warning.
empty()
- empty element by removing all its children.
on(event: str, callback: Callable)
- attach an event handler to element's DOM event.
off(event: str, callback: Callable)
- remove a previously attached event handler from element's DOM event.
webview.dom.ManipulationMode enum
Can be used as an argument to element.copy
and element.move
to set in which fashion element is moved/copied to another parent. Possible values are LastChild
, FirstChild
, Before
, After
, Replace
webview.dom.DOMEventHandler
DOMEventHandler(callback: Callback, prevent_default=False, stop_propagation=False)
an event handler container used if you need to prevent default behaviour of the catch event or stop event propagation. Example usage element.events.click += DOMEventHandler(lambda e: e, prevent_default=True, stop_propagation=True)
Hey @r0x0r,
I was actually implementing something similar myself. I think this is a great idea!
Would it make more sense to have a get_element
instead which returns a single element as you are using HTML id's? And then use get_elements
on types like div, span, h1, etc.
It would also be great if you could call get_elements
prior to webview.start
, by saving anything called before to a list and evaluating after the DOM has loaded.
Syntax wise, an alternative to element.on('click', foo)
could be element.click += foo
, which is more pythonic but dissimilar to javascript, so thats your call.
@louisnw01 Thanks for suggestions. I have implemented both get_element
and an alternative way to handle DOM events. get_element
and get_elements
are now moved to window.dom
(window.get_elements
is marked deprecated) and DOM events are generated automatically and can be found under element.events
. I left element.on
and element.off
intact as an alternative way to attach/remove events.
Calling get_elements
before webview.start
is tricky, as pywebview
does not assume anything about DOM, but rather makes Javascript calls to get the latest state. What is your use case for this? Does architecture in examples/dom_manipulation.py
help your case?
These and other changes are pushed to the dom
branch. I have also updated the original post with API changes to easier tracking.
Great solution for get_elements
and element.events
.
As for calling before start, I achieved something similar in my charting library (using pywebview) by creating a run_script
function (shown here), which either uses evaluate_js
, or saves the script to a list and executes it once the window has loaded.
However, as your implementation requires an object to be returned to a user-declared variable (create_element
for example) you would need a way to define unique elements without the DOM giving you any information about that element. I achieved this functionality with a random generator, which returns an id window.<random_str>
. That way the node_id
wouldn't be required. So your create_element
and Element
class could look something like this:
class Element:
def __init__(self, window, node_id) -> None:
self.__window = window
self.events = EventContainer()
self._id = window.generate_unique_id() # window.iudjrnmk
...
def create_element(self, html: str, parent: Optional[Element]=None) -> Element:
parent_selector = parent._query_string if parent else 'document.body'
element = Element(self.__window)
self.run_script(f"""
var parent = {parent_selector};
var template = document.createElement('template');
template.innerHTML = '{escape_quotes(html)}'.trim();
{element._id} = template.content.firstChild;
parent.appendChild({element._id});
pywebview._getNodeId({element._id});
""")
return element
This way, the python representations of the DOM elements are not dependant upon objects returned from JavaScript, meaning the scripts can be evaluated in the future (once the window has loaded) by using the element._id
to access the respective element.
@louisnw01 Thanks for the suggestion. What about get_element
? How should it be handled before program start? Does it make any sense at all?
I am vary about introducing any more complexity at this point. As this work is quite a massive change already. So far I have been sticking to the single source of truth principle, getting the latest state directly from DOM and not caching anything on the Python side.
More updates on the implementation.
- Added
element.show()
,element.hide()
,element.toggle()
andelement.visible
(the latter is read only), as well aselement.focus()
,element.blur()
andelement.focused
(read only). I have been thinking of getting rid of getter/setter and makingelement.visible
andelement.focused
property setters, but it might make not sense. As element might be hidden for other reasons other than hiding it withdisplay: none
. Same applies to focus. Opinions? - elevate_js throws a JavascriptException if an error is thrown on a JS side.
- Added
add_class
,remove_class
andtoggle_class
, but been thinking about replacing them with a list like class. Soelement.add_class
would becomeelement.classes.append
,remove_class
->element.classes.remove
,element.toggle_class
->element.classes.toggle
- Similar thing could be done to
element.style
andelement.attributes
. The current way of setting style is rather cumbersome, ieelement.style = {'display': 'flex'}
.element.style['display'] = 'flex'
would be much nicer.
- Added
element.copy
andelement.move
- Added a
ManipulationMode
enum to control how node is copied/moved -
element.classes
returns now a list like object. Classes can set/removed/toggled likeelement.classes.append('class')
,element.classes.remove('class')
,element.classes.toggle('class')
, -
element.attributes
returns a dict like object. To set an attributeelement.attributes['id'] = 'test'
. To remove an attributedel element.attributes['id']
. -
element.style
returns a dict like object. To set a styleelement.style['background-color'] = 'red'
. To reset a styledel element.style['background-color']
The DOM implementation seems to be pretty much complete basic feature wise to me. I take comments / suggestions on the implementation. The target date for the release is near the end of this year.
Added DOMEventHandler(callback: Callback, prevent_default=False, stop_propagation=False)
an event handler container.
It is used if you need to prevent default behaviour of the catch event or stop event propagation. Example usage element.events.click += DOMEventHandler(lambda e: e, prevent_default=True, stop_propagation=True)
element.events.click += event_handler
is equivalent to element.events.click += DOMEventHandler(event_handler, prevent_default=True, stop_propagation=True)
Released in 5.0.1