Chapter 4. Listening, and Other Techniques

This chapter concludes the general introduction to Office programming by looking at several techniques that will reappear periodically in later chapters: the use of window listeners.

See also Chapter 25. Monitoring Sheets.

4.1 Listening to a Window

There are many Listeners in office (near 140 or so). Using LibreOffice Developer Search and running loapi comp -a -m 150 -t interface -s Listener shows 139 listeners.

On LibreOffice API you can go to XEventListener that display a tree diagram at the top of the page, and you can click on a subclass box to jump to its documentation.

One nice syntactic feature of listeners is that almost all their names end with “Listener”. This makes them easy to find when searching through indices of class names, such as the Class Index or using LibreOffice Developer Search.

The top-level document window can be monitored for changes using XTopWindowListener, which responds to modifications of the window`s state, such as when it is opened, closed, minimized, and made active.

OooDev has implement some listeners in the adapter namespace such as TopWindowListener and TopWindowEvents. The listeners and event classes in the adapter namespace simplify greatly working with listeners.

Office Window Listener example illustrates two different ways to add listeners. Both of these example are functionally identical.

In the case of DocWindow class it inherits XTopWindowListener class and thus must implement all methods in XTopWindowListener and its parent classes.

With DocWindowAdapter it is not necessary to implement XTopWindowListener methods. Only the desired events can be subscribed to via TopWindowEvents class that in turn uses an internal instance of TopWindowListener class.

Another advantage of using listeners from adapter namespace is many listeners can be used inside a single class if needed. For example Office Window Monitor uses TerminateEvents and EventEvents.

from __future__ import annotations
from typing import TYPE_CHECKING
import unohelper

from ooodev.office.write import Write
from ooodev.gui import GUI
from ooodev.loader.lo import Lo

from com.sun.star.awt import XExtendedToolkit
from com.sun.star.awt import XTopWindowListener
from com.sun.star.awt import XWindow

if TYPE_CHECKING:
    from com.sun.star.lang import EventObject


class DocWindow(unohelper.Base, XTopWindowListener):
    def __init__(self) -> None:
        super().__init__()
        self.closed = False
        loader = Lo.load_office(Lo.ConnectPipe())
        self.doc = Write.create_doc(loader=loader)

        self.tk = Lo.create_instance_mcf(XExtendedToolkit, "com.sun.star.awt.Toolkit")
        if self.tk is not None:
            self.tk.addTopWindowListener(self)

        GUI.set_visible(True, self.doc)
        # triggers 2 opened and 2 activated events

    def windowOpened(self, event: EventObject) -> None:
        """is invoked when a window is activated."""
        print("WL: Opened")
        xwin = Lo.qi(XWindow, event.Source)
        GUI.print_rect(xwin.getPosSize())

    def windowActivated(self, event: EventObject) -> None:
        """is invoked when a window is activated."""
        print("WL: Activated")
        print(f"  Title bar: {GUI.get_title_bar()}")

    def windowDeactivated(self, event: EventObject) -> None:
        """is invoked when a window is deactivated."""
        print("WL: Minimized")

    def windowMinimized(self, event: EventObject) -> None:
        """is invoked when a window is iconified."""
        print("WL:  De-activated")

    def windowNormalized(self, event: EventObject) -> None:
        """is invoked when a window is deiconified."""
        print("WL: Normalized")

    def windowClosing(self, event: EventObject) -> None:
        print("WL: Closing")

    def windowClosed(self, event: EventObject) -> None:
        """is invoked when a window has been closed."""
        if not self.closed:
            print("WL: Closed")
            self.closed = True

    def disposing(self, event: EventObject) -> None:
        print("WL: Disposing")

To subscribe to an event of a listener always subscribe to the listener method name. In the following example TopWindowListener class implements XTopWindowListener. Subscribing to an event is a matter of calling on() and passing it the exact name of the method. This is true for all listeners in the adapter namespace. In this example the method subscribed to is windowOpened.

self._twl = TopWindowListener()
self._twl.on("windowOpened", self._fn_on_window_opened)

Most every listener in adapter package has a corresponding event class. The event classes are used to simplify working with listeners and provides a more pythonic way of working with listeners and provides methods for subscribing to events. For example the ooodev.adapter.awt.top_window_listener.TopWindowListener class has a corresponding ooodev.adapter.awt.top_window_events.TopWindowEvents class.

In adapter listeners the Original EventObject data is always available via EventArgs.event_data as demonstrated below in on_window_opened().

# in DocWindowAdapter class
def on_window_opened(self, source: Any, event_args: EventArgs, *args, **kwargs) -> None:
    """is invoked when a window is activated."""
    event = cast("EventObject", event_args.event_data)
    print("WA: Opened")
    xwin = Lo.qi(XWindow, event.Source)
    GUI.print_rect(xwin.getPosSize())

The DocWindow class implements seven methods from XTopWindowListener, and disposing() inherited from XEventListener.

The DocWindow class is made the listener for the window by accessing the XExtendedToolkit interface, which is part of the Toolkit service. Toolkit is utilized by Office to create windows, and XExtendedToolkit adds three kinds of listeners: XTopWindowListener, XFocusListener, and the XKeyHandler listener. TopWindowListener implements these listeners automatically. TopWindowEvents takes advantage of TopWindowListener and makes working with the events even easier.

When an event arrives at a listener method, one of the more useful things to do is to transform it into an XWindow instance:

def windowOpened(self, event: EventObject) -> None:
    xwin = Lo.qi(XWindow, event.Source)
    GUI.print_rect(xwin.getPosSize())

It’s then possible to access details about the frame, such as its size.

Events are fired when GUI.set_visible() is called in the class constructor. An opened event is issued, followed by an activated event, triggering calls to windowOpened() and windowActivated() or on_window_opened() and on_window_activated() in the adapter class. Rather confusingly, both these methods are called twice.

Note

If Lo.close_doc() were to be called, a single deactivated event is fired, but two closed events are issued. Consequently, there’s a single call to windowDeactivated() and two to windowClosed(). Strangely, there’s no window closing event trigger of windowClosing(), and Lo.close() doesn’t cause disposing() to fire.

Office Window Listener example is also demonstrates how to keep a python script alive while office is running.

# in start.py
def main_loop() -> None:
    dw = DocWindow()

    # while Writer is open, keep running the script unless specifically ended by user
    while 1:
        if dw.closed is True: # wait for windowClosed event to be raised
            print("\nExiting by document close.\n")
            break
        time.sleep(0.1)

if __name__ == "__main__":
    print("Press 'ctl+c' to exit script early.")
    try:
        main_loop()
    except KeyboardInterrupt:
        # ctrl+c exist the script early
        print("\nExiting by user request.\n", file=sys.stderr)
        SystemExit(0)

4.2 Office Manipulation

Although XTopWindowListener can detect the minimization and re-activation of the document window, it can’t trigger events. Listeners Listen for events but can not trigger events.

I the GUI class there are a few methods for basic window manipulation. For instance to activate a window use activate(), for min and max there is minimize() and maximize(), get_pos_size() for size and position.

OooDev GUI Automation for windows for windows makes some of this possible.

See 4.6 Robot Keys

4.3 Detecting Office Termination

Office termination is most easily observed by attaching a listener to the Desktop object, as seen in Office Window Monitor example.

# simplified version of DocMonitor class
class DocMonitor:
    def __init__(self) -> None:
        super().__init__()
        self.closed = False
        self.bridge_disposed = False
        loader = Lo.load_office(Lo.ConnectPipe())
        _ = Lo.XSCRIPTCONTEXT.getDesktop()

        self._set_internal_events()

        self.doc = Calc.create_doc(loader=loader)

        GUI.set_visible(True, self.doc)

    def _set_internal_events(self):
        self._fn_on_notify_termination = self.on_notify_termination
        self._fn_on_query_termination = self.on_query_termination
        self._fn_on_disposing = self.on_disposing
        self._fn_on_disposing_bridge = self.on_disposing_bridge
        self._fn_on_disposed = self.on_disposed

        self._term_events = TerminateEvents()
        self._term_events.add_event_notify_termination(self._fn_on_notify_termination)
        self._term_events.add_event_query_termination(self._fn_on_query_termination)
        self._term_events.add_event_terminate_events_disposing(self._fn_on_disposing)

        self.events = Events(source=self)
        self.events.on(LoNamedEvent.BRIDGE_DISPOSED, self._fn_on_disposed)

        self._bridge_events = EventEvents()
        self._bridge_events.add_event_disposing(self._fn_on_disposing)
        Lo.bridge.addEventListener(self._bridge_events.events_listener_event)

    def on_notify_termination(self, source: Any, event_args: EventArgs, *args, **kwargs) -> None:
        print("TL: Finished Closing")
        self.bridge_disposed = True
        self.closed = True

    def on_query_termination(self, source: Any, event_args: EventArgs, *args, **kwargs) -> None:
        print("TL: Starting Closing")

    def on_disposing(self, source: Any, event_args: EventArgs, *args, **kwargs) -> None:
        # don't expect Disposing to print if script ends due to closing.
        # script will stop before dispose is called
        print("TL: Disposing")

    def on_disposed(self, source: Any, event_args: EventArgs) -> None:
        # just another way of knowing when bridge is gone.
        print("LO: Office bridge has gone!!")
        self.bridge_disposed = True

Behind the scenes TerminateEvents creates an instance of TerminateListener which inherits XTerminateListener and is attached to the XDesktop instance.

The TerminateEvents class has a add_terminate_listener option in the constructor that defaults to True. This means when a class instance is created it will automatically attach a listener to the current XDesktop instance.

The program’s output is:

PS D:\Users\user\Python\python-ooouno-ex> python .\ex\auto\general\odev_monitor\start.py True
Press 'ctl+c' to exit script early.
Loading Office...
Creating Office document scalc
Closing Office
TL: Starting Closing
TL: Finished Closing
Office terminated

Exiting by document close.

on_query_termination() and on_notify_termination() are called at the start and end of the Office closing sequence.

on_disposing() is not called. For some reason XTerminateListenerdisposing() method is never triggered.

4.4 Bridge Shutdown Detection

4.4.1 Detecting Shutdown via Listener

There’s another way to detect Office closure: by listening for the shutdown of the UNO bridge between the Python and Office processes. This can be useful if Office crashes independently of your Python code. This approach works for both socket and pipe connections using python.

The modified parts of Office Window Monitor are:

# DocMonitor _set_internal_events changes
self._fn_on_disposing_bridge = self.on_disposing_bridge

# attach a listener to the bridge connection that gets notified if
# office bridge connection terminates unexpectedly.
# Lo.bridge is not available if a script is run as a macro.
self._bridge_events = EventEvents()
self._bridge_events.add_event_disposing(self._fn_on_disposing)
Lo.bridge.addEventListener(self._bridge_events.events_listener_event)

# DocMonitor new method
def on_disposing_bridge(self, source: Any, event_args: EventArgs, *args, **kwargs) -> None:
    print("BR: Office bridge has gone!!")

main_loop() method checks for dw.bridge_disposed.

# in start.py
def main_loop() -> None:
    dw = DocMonitor()

    if len(sys.argv) > 1:
        if str(sys.argv[1]).casefold() in ("t", "true", "y", "yes"):
            Lo.delay(3000)
            Lo.close_office()
            return

    while 1:
        if dw.closed is True:  # wait for windowClosed event to be raised
            print("\nExiting by document close.\n")
            break
        if dw.bridge_disposed is True:
            print("\nExiting due to office bridge is gone\n")
            raise SystemExit(1)
        time.sleep(0.1)

Since the disappearance of the Office bridge is a fatal event, in main_loop() raise SystemExit(1) is called to kill python.

Note

Raising SystemExit(1) inside of disposing_bridge() does not exit script and that is why it is delegated to main_loop()

The output of the revised Office Window Monitor is:

PS D:\Users\user\Python\python-ooouno-ex> python .\ex\auto\general\odev_monitor\start.py True
Press 'ctl+c' to exit script early.
Loading Office...
Creating Office document scalc
Closing Office
TL: Starting Closing
TL: Finished Closing
Office terminated
BR: Office bridge has gone!!

Exiting by document close.

This output shows that bridge closure follows the call to Lo.close_office(), as you’d expect. However, if I make Office crash while DocMonitor is running, then the output becomes:

PS D:\Users\user\Python\python-ooouno-ex> python .\ex\auto\general\odev_monitor\start.py
Press 'ctl+c' to exit script early.
Loading Office...
Creating Office document scalc
Office bridge has gone!!
BR: Office bridge has gone!!

Exiting due to office bridge is gone

Office was killed while the python program was still running, so it never reached its Lo.close_office() call which triggers the XTerminateListener methods. However, the XEventListener attached to the bridge did fire. (If you’re wondering, office was killed Office by running loproc -k, which stopped the soffice process. See: LibreOffice Developer Search)

4.4.2 Detecting Shutdown via Event

And finally it is possible use an OooDev event to shutdown. OooDev listens to bridge connection internally and raise an event when the bridge goes away.

In this simplified example of Office Window Monitor an instance of Class Events is used to respond to bridge going away.

#!/usr/bin/env python
from __future__ import annotations
import time
import sys
from typing import Any

from ooodev.adapter.lang.event_events import EventEvents
from ooodev.events.args.event_args import EventArgs
from ooodev.events.lo_events import Events
from ooodev.events.lo_named_event import LoNamedEvent
from ooodev.office.calc import Calc
from ooodev.gui import GUI
from ooodev.loader.lo import Lo


class DocMonitor:
    def __init__(self) -> None:
        self.closed = False
        self.bridge_disposed = False
        loader = Lo.load_office(Lo.ConnectPipe(), opt=Lo.Options(verbose=True))

        self.doc = Calc.create_doc(loader=loader)

        self._fn_on_disposed = self.on_disposed

        self.events = Events(source=self)
        self.events.on(LoNamedEvent.BRIDGE_DISPOSED, self._fn_on_disposed)

        self._bridge_events = EventEvents()
        self._bridge_events.add_event_disposing(self._fn_on_disposing)
        Lo.bridge.addEventListener(self._bridge_events.events_listener_event)

        GUI.set_visible(True, self.doc)

    def on_disposed(self, source: Any, event_args: EventArgs) -> None:
        # just another way of knowing when bridge is gone.
        print("LO: Office bridge has gone!!")
        self.bridge_disposed = True


def main_loop() -> None:
    dw = DocMonitor()

    # check an see if user passed in a auto terminate option
    if len(sys.argv) > 1:
        if str(sys.argv[1]).casefold() in ("t", "true", "y", "yes"):
            Lo.delay(5000)
            Lo.close_office()

    # while Writer is open, keep running the script unless specifically ended by user
    while 1:
        if dw.bridge_disposed is True:
            print("\nExiting due to office bridge is gone\n")
            raise SystemExit(1)
        time.sleep(0.1)


if __name__ == "__main__":
    print("Press 'ctl+c' to exit script early.")
    try:
        main_loop()
    except SystemExit as e:
        SystemExit(e.code)
    except KeyboardInterrupt:
        # ctrl+c exist the script early
        print("\nExiting by user request.\n", file=sys.stderr)
        SystemExit(0)

4.5 Dispatching

This book is about the Python Office API, which manipulates UNO data structures such as services, interfaces, and components. There’s an alternative programming style, based on the dispatching of messages to Office. These messages are mostly related to menu items, so, for example, the messages .uno:Copy, .uno:Cut, .uno:Paste, and .uno:Undo duplicate commands in the Edit menu. The use of messages tends to be most common when writing macros (scripts) in Basic, because Office’s built-in Macro recorder automatically converts a user’s interaction with menus into dispatches.

One drawback of dispatching is that it isn’t a complete programming solution. For instance, copying requires the selection of text, which has to be implemented with the Office API.

OooDev has many dispatch commands in the dispatch namespace, making it much easier to pass dispatch commands.

LibreOffice has a comprehensive webpage listing all the dispatch commands Development/DispatchCommands.

Another resource is chapter 10 of OpenOffice.org Macros Explained by Andrew Pitonyak.

Creating a dispatcher in Python is a little complicated since XDispatchProvider and XDispatchHelper instances are needed. XDispatchProvider is obtained from the frame (i.e. window) where the message will be delivered, which is almost always the Desktop’s frame (i.e. Office application’s window). XDispatchHelper sends the message via its executeDispatch() method. It’s also possible to examine the result status in an DispatchResultEvent object, but that seems a bit flakey – it reports failure when the dispatch works, and raises an exception when the dispatch really fails.

The code is wrapped up in Lo.dispatch_cmd(), which is called twice in the Dispatch Commands Example:

Example
#!/usr/bin/env python
# coding: utf-8
# region Imports
from __future__ import annotations
import argparse
import sys

from ooodev.loader.lo import Lo
from ooodev.gui import GUI

# endregion Imports

# region args
def args_add(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "-d",
        "--doc",
        help="Path to document",
        action="store",
        dest="fnm_doc",
        required=True,
    )


# endregion args

# region Main
def main() -> int:
    # create parser to read terminal input
    parser = argparse.ArgumentParser(description="main")

    # add args to parser
    args_add(parser=parser)

    if len(sys.argv) <= 1:
        parser.print_help(sys.stderr)
        return 1

    # read the current command line args
    args = parser.parse_args()

    fnm = args.fnm_doc
    loader = Lo.load_office(Lo.ConnectPipe())
    try:
        doc = Lo.open_doc(fnm=fnm, loader=loader)
        # breakpoint()
    except Exception:
        print(f"Could not open '{fnm}'")
        Lo.close_office()
        raise SystemExit(1)

    GUI.set_visible(is_visible=True, odoc=doc)
    Lo.delay(3000)  # delay 3 seconds

    # put doc into readonly mode
    Lo.dispatch_cmd("ReadOnlyDoc")
    Lo.delay(1000)

    # opens get involved webpage of LibreOffice in local browser
    Lo.dispatch_cmd("GetInvolved")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

# endregion main

Example using GlobalEditDispatch class.

from ooodev.utils.dispatch.global_edit_dispatch import GlobalEditDispatch
from ooodev.loader.lo import Lo

Lo.dispatch_cmd(cmd=GlobalEditDispatch.COPY)
# other processing ...
Lo.dispatch_cmd(cmd=GlobalEditDispatch.PASTE)

It is also possible in OooDev to hook events. These events are specific to OooDev and not part of LibreOffice.

Here is the updated example that hooks DISPATCHING and DISPATCHED events. In the example the About dispatch is canceled.

See Class Events.

Extended Example
#!/usr/bin/env python
# coding: utf-8
# region Imports
from __future__ import annotations
import argparse
import sys
from typing import Any

from ooodev.loader.lo import Lo
from ooodev.gui import GUI
from ooodev.events.lo_events import Events
from ooodev.events.lo_named_event import LoNamedEvent
from ooodev.events.args.dispatch_args import DispatchArgs
from ooodev.events.args.dispatch_cancel_args import DispatchCancelArgs

# endregion Imports

# region args
def args_add(parser: argparse.ArgumentParser) -> None:
    parser.add_argument(
        "-d",
        "--doc",
        help="Path to document",
        action="store",
        dest="fnm_doc",
        required=True,
    )


# endregion args

# region dispatch events
def on_dispatching(source: Any, event: DispatchCancelArgs) -> None:
    if event.cmd == "About":
        print("About dispatch canceled")
        event.cancel = True
        return
    print(f"Dispatching: {event.cmd}")


def on_dispatched(source: Any, event: DispatchArgs) -> None:
    print(f"Dispatched: {event.cmd}")


# endregion dispatch events

# region Main
def main() -> int:
    # create parser to read terminal input
    parser = argparse.ArgumentParser(description="main")

    # add args to parser
    args_add(parser=parser)

    if len(sys.argv) <= 1:
        parser.print_help(sys.stderr)
        return 1

    # read the current command line args
    args = parser.parse_args()

    fnm = args.fnm_doc
    loader = Lo.load_office(Lo.ConnectPipe())
    try:
        doc = Lo.open_doc(fnm=fnm, loader=loader)
        # breakpoint()
    except Exception:
        print(f"Could not open '{fnm}'")
        Lo.close_office()
        raise SystemExit(1)

    # create an instance of events to hook into ooodev events
    events = Events()
    events.on(LoNamedEvent.DISPATCHING, on_dispatching)
    events.on(LoNamedEvent.DISPATCHED, on_dispatched)

    GUI.set_visible(is_visible=True, odoc=doc)
    Lo.delay(3000)  # delay 3 seconds

    # put doc into readonly mode
    Lo.dispatch_cmd("ReadOnlyDoc")
    Lo.delay(1000)

    # opens get involved webpage of LibreOffice in local browser
    Lo.dispatch_cmd("GetInvolved")
    Lo.dispatch_cmd("About")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())

# endregion main

Here is the output from extended example.

PS D:\Users\user\Python\python-ooouno-ex> python .\ex\auto\general\odev_dispatch\start.py -d "resources\odt\story.odt"
Loading Office...
Opening D:\Users\user\Python\python-ooouno-ex\resources\odt\story.odt
Dispatching: ReadOnlyDoc
Dispatched: ReadOnlyDoc
Dispatching: GetInvolved
Dispatched: GetInvolved
About dispatch canceled

4.6 Robot Keys

Another menu-related approach to controlling Office is to programmatically send menu shortcut key strokes to the currently active window. For example, a loaded Write document is often displayed with a SideBar. This can be closed using the menu item View, SideBar, which is assigned the shortcut keys CTL+F5.

OooDev GUI Automation for windows makes this possible on Windows. Also available as a LibreOffice extension.

In dispatcher.py of Dispatch Commands Example, _toggle_side_bar() ‘types’ these key strokes with the help of Class RobotKeys:

try:
    # only windows
    from odevgui_win.robot_keys import RobotKeys, SendKeyInfo
    from odevgui_win.keys.writer_key_codes import WriterKeyCodes
except ImportError:
    RobotKeys, SendKeyInfo, WriterKeyCodes = None, None, None

class Dispatcher:
    # ...

    def _toggle_side_bar(self) -> None:
        # RobotKeys is currently windows only
        if not RobotKeys:
            Lo.print("odevgui_win not found.")
            return
        RobotKeys.send_current(SendKeyInfo(WriterKeyCodes.KB_SIDE_BAR))

odevgui_win.robot_keys.RobotKeys.send_current() gets current document window and sets its focus before sending keys.

_toggle_side_bar() is in the dark about the current state of the GUI. If the SideBar is visible then the keys will make it disappear, but if the pane is not currently on-screen then these keys will bring it up. Also, developer must be cautious that the correct keys are being sent to the correct window. Sending keys to the wrong window may have detrimental effects.

OooDev GUI Automation for windows has many predefined shortcut keys that can be found in Keys and also there’s lots of documentation on keyboard shortcuts for Office in its User guides (downloadable from https://th.libreoffice.org/get-help/documentation), and these can be easily translated into key presses and releases in Class RobotKeys.