From 8144f71c1a4bf33426b8a5be4246b17efdb71cac Mon Sep 17 00:00:00 2001 From: Michael Weghorn Date: Wed, 20 Nov 2024 13:53:32 +0100 Subject: [PATCH 1/2] win manager: Move Wnck logic to new WnckWindowManager Move the Wnck-specific logic from the WindowManager class to a new subclass WnckWindowManager, and create an instance of that class (for now) unless an environment variable is set to select KWinWindowManager or GnomeShellWindowManager. No change in behavior intended. This is in preparation of making the Wnck dependency optional. Related: #41 --- src/lib/accerciser/window_manager.py | 122 +++++++++++++++------------ 1 file changed, 67 insertions(+), 55 deletions(-) diff --git a/src/lib/accerciser/window_manager.py b/src/lib/accerciser/window_manager.py index 117d62f..2aec3ba 100644 --- a/src/lib/accerciser/window_manager.py +++ b/src/lib/accerciser/window_manager.py @@ -33,7 +33,7 @@ def get_window_manager(): return KWinWindowManager() if preferred == 'gnomeshell': return GnomeShellWindowManager() - return WindowManager() + return WnckWindowManager() class WindowGeometry: @@ -75,8 +75,8 @@ class WindowManager: screen itself. This includes fetching information from the X11 window manager or Wayland compositor. - Any handling specific to a particular window system should be done in this - class. + Any handling specific to a particular window system should be done in + (a subclass of) this class. ''' def getToolkitNameAndVersion(self, acc): @@ -123,52 +123,13 @@ class WindowManager: ''' Retrieve window information for all (system) windows. + The default implementation returns an empty list. + Subclasses should override this. + @return: Window information for all windows. @rtype: list[WindowInfo] ''' - wnck_screen = Wnck.Screen.get_default() - active_workspace = wnck_screen.get_active_workspace() - win_infos = [] - stacking_index = 0 - for window in wnck_screen.get_windows_stacked(): - # client geometry is used unless window has client side decorations, - # in which case frame geometry is used; - # assume that client side decoration is used when client rect contains the frame rect - client_x, client_y, client_width, client_height = window.get_client_window_geometry() - frame_x, frame_y, frame_width, frame_height = window.get_geometry() - client_contains_frame = (client_x <= frame_x) and (client_y <= frame_y) \ - and (client_x + client_width >= frame_x + frame_width) \ - and (client_y + client_height >= frame_y + frame_height) - if client_contains_frame: - toplevel_x, toplevel_y, toplevel_width, toplevel_height = frame_x, frame_y, frame_width, frame_height - else: - toplevel_x, toplevel_y, toplevel_width, toplevel_height = client_x, client_y, client_width, client_height - - title = window.get_name() - - # strip additional trailing Left-to-Right Mark (U+200E), seen with libwnck and KWin - if title[-1] == '\u200e': - title = title[0:-1] - - # KWin 5 (but not KWin 6) adds suffix to window title when there are multiple windows with the - # same name, e.g. first window: "Hypertext", second window: "Hypertext <2>". - # Remove the suffix as the accessible name retrieved from AT-SPI2 doesn't have it either - regex = '^.* <[0-9]+>$' - if re.match(regex, title): - title = title[0:title.rfind(' ')] - - # workspace can be None if window is on all workspaces, so only consider case - # of an actually returned workspace differing from the active one as not on active workspace - workspace = window.get_workspace() - on_active_workspace = (not workspace) or (not active_workspace) or window.is_on_workspace(active_workspace) - pid = window.get_pid() - win_info = WindowInfo(title, toplevel_x, toplevel_y, toplevel_width, toplevel_height, - stacking_index=stacking_index, on_current_workspace=on_active_workspace, - process_id=pid) - win_infos.append(win_info) - stacking_index = stacking_index + 1 - - return win_infos + return [] def getWindowInfo(self, toplevel): ''' @@ -212,7 +173,7 @@ class WindowManager: if pid_candidates: candidates = pid_candidates - # in case of multiple candidates, prefer one where size reported by AT-SPI matches Wnck one + # in case of multiple candidates, prefer one for which size reported by AT-SPI matches atspi_width, atspi_height = toplevel.queryComponent().getSize() for candidate in candidates: if candidate.width == atspi_width and candidate.height == atspi_height: @@ -247,20 +208,14 @@ class WindowManager: ''' Get the icon of one of the top-level windows of the given application. - The default implementation uses Wnck. + The default implementation returns None. + Subclasses should override this. @param app: The application accessible for which to retrieve a window icon. @type toplevel: Atspi.Accessible @return: The icon, or None. @rtype: GdkPixbuf.Pixbuf ''' - s = Wnck.Screen.get_default() - s.force_update() - for win in s.get_windows(): - wname = win.get_name() - for child in app: - if child.name == wname: - return win.get_mini_icon() return None def getApplicationIconViaAppID(self, app): @@ -535,3 +490,60 @@ class GnomeShellWindowManager(WindowManager): def getApplicationIcon(self, app): return self.getApplicationIconViaAppID(app) + +class WnckWindowManager(WindowManager): + + def getWindowInfos(self): + wnck_screen = Wnck.Screen.get_default() + active_workspace = wnck_screen.get_active_workspace() + win_infos = [] + stacking_index = 0 + for window in wnck_screen.get_windows_stacked(): + # client geometry is used unless window has client side decorations, + # in which case frame geometry is used; + # assume that client side decoration is used when client rect contains the frame rect + client_x, client_y, client_width, client_height = window.get_client_window_geometry() + frame_x, frame_y, frame_width, frame_height = window.get_geometry() + client_contains_frame = (client_x <= frame_x) and (client_y <= frame_y) \ + and (client_x + client_width >= frame_x + frame_width) \ + and (client_y + client_height >= frame_y + frame_height) + if client_contains_frame: + toplevel_x, toplevel_y, toplevel_width, toplevel_height = frame_x, frame_y, frame_width, frame_height + else: + toplevel_x, toplevel_y, toplevel_width, toplevel_height = client_x, client_y, client_width, client_height + + title = window.get_name() + + # strip additional trailing Left-to-Right Mark (U+200E), seen with libwnck and KWin + if title[-1] == '\u200e': + title = title[0:-1] + + # KWin 5 (but not KWin 6) adds suffix to window title when there are multiple windows with the + # same name, e.g. first window: "Hypertext", second window: "Hypertext <2>". + # Remove the suffix as the accessible name retrieved from AT-SPI2 doesn't have it either + regex = '^.* <[0-9]+>$' + if re.match(regex, title): + title = title[0:title.rfind(' ')] + + # workspace can be None if window is on all workspaces, so only consider case + # of an actually returned workspace differing from the active one as not on active workspace + workspace = window.get_workspace() + on_active_workspace = (not workspace) or (not active_workspace) or window.is_on_workspace(active_workspace) + pid = window.get_pid() + win_info = WindowInfo(title, toplevel_x, toplevel_y, toplevel_width, toplevel_height, + stacking_index=stacking_index, on_current_workspace=on_active_workspace, + process_id=pid) + win_infos.append(win_info) + stacking_index = stacking_index + 1 + + return win_infos + + def getApplicationIcon(self, app): + s = Wnck.Screen.get_default() + s.force_update() + for win in s.get_windows(): + wname = win.get_name() + for child in app: + if child.name == wname: + return win.get_mini_icon() + return None -- GitLab From 223927123f43acbec9dd1977bfd62f1b71b09d67 Mon Sep 17 00:00:00 2001 From: Michael Weghorn Date: Fri, 22 Nov 2024 16:45:51 +0100 Subject: [PATCH 2/2] Move Wnck logic to a separate helper program Instead of using Wnck directly in-process in the WnckWindowManager class, move the logic to a new helper program `wnck-window-infos.py` that retrieves the relevant logic using Wnck and prints it in JSON format to stdout. Let WnckWindowManager call that Python script and process the JSON output, somewhat similar to how KWinWindowManager and GnomeShellWindowManager already do it with the information they receive from KWin or GNOME Shell/Mutter. While doing things in-process is faster, splitting the Wnck logic into a separate helper program gets rid of the Wnck dependency in the main Acccerciser application, which came with an implicit dependency on GTK 3 and the need to run Accerciser on X11/XWayland as Wnck doesn't work on Wayland or with GTK 4. Due to the way that Wnck works, the helper program can still only detect X11 applications, no native Wayland clients, but this would now still work when Accerciser itself runs as a native Wayland application. For now, still keep setting the `GDK_BACKEND=x11` environment variable for the main application as well, however, to force it to run on X11/XWayland, too. While it's no longer needed for Wnck, the highlighting feature (e.g. when clicking on an accessible in the treeview) still requires running on X11, as the highlighting window otherwise cannot be moved to the correct position on screen when run on Wayland. Once an alternative solution for this has been implemented, the main application can run as a native Wayland app. And when using either KWinWindowManager or GnomeShellWindowManager, XWayland also isn't needed for determinining window information either. Leaving WnckWindowManager around for now still makes sense in my opinion, as it allows using the highlighting feature (for X11/XWayland apps at least) on desktop environments other than GNOME and KDE Plasma and with applications/toolkits (versions) not (yet) properly reporting window-relative coordinates, but only screen coordinates. Call `Wnck.Screen.force_update` [1] in the new helper program, as the window information isn't otherwise available there as described in the doc [1]: > Synchronously and immediately updates the list of Wnck.Window on > self. This bypasses the standard update mechanism, where the list of > Wnck.Window is updated in the idle loop. > > This is usually a bad idea for both performance and correctness > reasons (to get things right, you need to write model-view code that > tracks changes, not get a static list of open windows). > However, this function can be useful for small applications that > just do something and then exit. For debugging purposes, the script can also be run standalone. Example use in a Wayland session with a gtk3-demo and gtk4-demo running on XWayland: Get window infos: $ GDK_BACKEND=x11 python3 ./src/wnck-window-infos.py window-infos [{"caption": "Application Class", "geometry.x": 2265, "geometry.y": 150, "geometry.width": 800, "geometry.height": 651, "isOnCurrentWorkspace": true, "pid": 142858, "stackingOrder": 0}, {"caption": "GTK Demo", "geometry.x": 2759, "geometry.y": 351, "geometry.width": 800, "geometry.height": 600, "isOnCurrentWorkspace": true, "pid": 151933, "stackingOrder": 1}, {"caption": "Accerciser Accessibility Explorer", "geometry.x": 0, "geometry.y": 72, "geometry.width": 1920, "geometry.height": 1052, "isOnCurrentWorkspace": true, "pid": 156725, "stackingOrder": 2}] Get icon infos for gtk4-demo's "GTK Demo" window, which can be used to create a GdkPixmap: $ GDK_BACKEND=x11 python3 ./src/wnck-window-infos.py icon "GTK Demo" {"pixels": "0000000000000000000000000000000000000000000000001a1a1a02c7c9cb5dc9cbce5f1f1f1f030000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c0c0c03fccdaeacfa8cbf3ffa7caf3ffcbdaebd2c1c1c1430000000000000000000000000000000000000000000000000000000000000000aeaeae1fced7e1aab6d3f4fe98c1f1ff98c1f1ff98c1f1ff98c1f1ffb4d2f4feced7e2afb0b0b022000000000000000000000000000000003c3c3c06d5dadf83bed5f1f69ac2f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff9ac2f1ffbdd5f1f7d4d9df863b3b3b060000000000000000d7d7d766e2d0deffa7caf3ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ffa6c9f2ffcce9ecffd8d8d8660000000000000000d0d0d081ee3e45fff2888dffd0d4e9ff9ec5f1ff98c1f1ff98c1f1ff98c1f1ff98c1f1ff9dc4f1ffc5e0f1ff99e7beff54d890ffd5d5d5820000000000000000d0d0d081ed3b43ffed333bffee4148ffefa3a9ffc1d7f3ff99c1f1ff98c1f1ffbed8f5ffaee9cfff58d992ff48d688ff50d78dffd5d5d5820000000000000000d0d0d081ed3b43ffed333affed3638ffee3a34fff25e54ffe8c3c8ffc1e9e1ff6bde9fff48d688ff48d688ff48d688ff50d78dffd5d5d5820000000000000000d0d0d081ed3b43ffee3736ffef3d31fff0422efff1452bfff57b66ff7be1a9ff4bd585ff53d47fff5ad37aff5ad379ff5dd583ffd5d5d5820000000000000000d0d0d081ee3f40ffef3d31fff1442cfff24927fff34d24fff78061ff82e0a4ff62d274ff71d06aff7ad064ff7bcf63ff7bd26effd5d5d5820000000000000000d0d0d081f0443cfff1432cfff34a26fff45021fff5551dfff8865cff91de99ff7bcf63ff8fcd55ff9ccb4cff9ecb4bff99cf5affd5d5d5820000000000000000cacac964f7aba4fff35437fff45022fff6571bfff75c17fffa8c57ff9ddd91ff8fcd55ffaaca43ffbec835ffc4ca3effd3e0a0ffc5c4c46400000000000000002b2b2b05bdbdbb7de0d3cff4f89c7cfff85f1afff96311fffb9252ffa3dc8dff9acc4effbac83affe4da79ffd5d2c0f6a19f9d80252524050000000000000000000000000000000081807f1cc1c1bfa4efd7c9fefc9451fffd984dffa2dc8effb0d97cffd9ddc0feadacaaa9706e6b1d00000000000000000000000000000000000000000000000000000000000000009999983ad6d6d4caf9e6d5ffdbebd9ffbebdbbce7e7d7a3d00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e0e0e01b7b7b659a1a19f5b0e0e0d02000000000000000000000000000000000000000000000000", "has_alpha": true, "bits_per_sample": 8, "width": 16, "height": 16, "rowstride": 64} [1] https://lazka.github.io/pgi-docs/Wnck-3.0/classes/Screen.html#Wnck.Screen.force_update Issue: #41 --- src/accerciser.in | 4 +- src/lib/accerciser/window_manager.py | 106 ++++++++++----------- src/meson.build | 10 ++ src/wnck-window-infos.py | 133 +++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 55 deletions(-) create mode 100755 src/wnck-window-infos.py diff --git a/src/accerciser.in b/src/accerciser.in index d097762..18b151f 100755 --- a/src/accerciser.in +++ b/src/accerciser.in @@ -13,13 +13,13 @@ available under the terms of the BSD which accompanies this distribution, and is available at U{http://www.opensource.org/licenses/bsd-license.php} ''' import gi -gi.require_version('Wnck', '3.0') from gi.repository import GLib import sys, os -# workaround for "libwnck is designed to work in X11 only, no valid display found" +# force running on X11/XWayland because window positioning +# needed for the highlighting feature doesn't work on Wayland os.environ['GDK_BACKEND'] = 'x11' # make the accerciser directory part of the path to aid user imports diff --git a/src/lib/accerciser/window_manager.py b/src/lib/accerciser/window_manager.py index 2aec3ba..9fca7d6 100644 --- a/src/lib/accerciser/window_manager.py +++ b/src/lib/accerciser/window_manager.py @@ -10,7 +10,6 @@ is available at U{http://www.opensource.org/licenses/bsd-license.php} from gi.repository import Gdk from gi.repository import GdkPixbuf -from gi.repository import Wnck import pyatspi import xdg @@ -22,7 +21,6 @@ import datetime import dbus import json import os -import re import subprocess import sys @@ -493,57 +491,59 @@ class GnomeShellWindowManager(WindowManager): class WnckWindowManager(WindowManager): + def _runWnckScript(self, args): + ''' + Run Python script that uses Wnck to retrieve window information + with the given arguments and return the script (JSON) output converted + to a Python data structure. + ''' + try: + # run script with env var GDK_BACKEND=x11 because Wnck only works on X11/XWayland + env_vars = os.environ + env_vars['GDK_BACKEND'] = 'x11' + wnck_script_path = os.path.join(sys.prefix, 'share', 'accerciser', 'wnck-window-infos.py') + all_args = [wnck_script_path] + args + script_output = subprocess.run(all_args, + capture_output=True, + check=True, + env=env_vars + ).stdout.decode() + data = json.loads(script_output) + return data + except Exception: + return None + def getWindowInfos(self): - wnck_screen = Wnck.Screen.get_default() - active_workspace = wnck_screen.get_active_workspace() - win_infos = [] - stacking_index = 0 - for window in wnck_screen.get_windows_stacked(): - # client geometry is used unless window has client side decorations, - # in which case frame geometry is used; - # assume that client side decoration is used when client rect contains the frame rect - client_x, client_y, client_width, client_height = window.get_client_window_geometry() - frame_x, frame_y, frame_width, frame_height = window.get_geometry() - client_contains_frame = (client_x <= frame_x) and (client_y <= frame_y) \ - and (client_x + client_width >= frame_x + frame_width) \ - and (client_y + client_height >= frame_y + frame_height) - if client_contains_frame: - toplevel_x, toplevel_y, toplevel_width, toplevel_height = frame_x, frame_y, frame_width, frame_height - else: - toplevel_x, toplevel_y, toplevel_width, toplevel_height = client_x, client_y, client_width, client_height - - title = window.get_name() - - # strip additional trailing Left-to-Right Mark (U+200E), seen with libwnck and KWin - if title[-1] == '\u200e': - title = title[0:-1] - - # KWin 5 (but not KWin 6) adds suffix to window title when there are multiple windows with the - # same name, e.g. first window: "Hypertext", second window: "Hypertext <2>". - # Remove the suffix as the accessible name retrieved from AT-SPI2 doesn't have it either - regex = '^.* <[0-9]+>$' - if re.match(regex, title): - title = title[0:title.rfind(' ')] - - # workspace can be None if window is on all workspaces, so only consider case - # of an actually returned workspace differing from the active one as not on active workspace - workspace = window.get_workspace() - on_active_workspace = (not workspace) or (not active_workspace) or window.is_on_workspace(active_workspace) - pid = window.get_pid() - win_info = WindowInfo(title, toplevel_x, toplevel_y, toplevel_width, toplevel_height, - stacking_index=stacking_index, on_current_workspace=on_active_workspace, - process_id=pid) - win_infos.append(win_info) - stacking_index = stacking_index + 1 - - return win_infos + window_data = self._runWnckScript(['window-infos']) + if not window_data: + return [] + + window_infos = [] + for win in window_data: + win_info = WindowInfo(win["caption"], win["geometry.x"], win["geometry.y"], + win["geometry.width"], win["geometry.height"], + stacking_index=win["stackingOrder"], + on_current_workspace=win["isOnCurrentWorkspace"], + process_id=win["pid"]) + window_infos.append(win_info) + return window_infos def getApplicationIcon(self, app): - s = Wnck.Screen.get_default() - s.force_update() - for win in s.get_windows(): - wname = win.get_name() - for child in app: - if child.name == wname: - return win.get_mini_icon() - return None + window_names = [app.get_child_at_index(i).get_name() for i in range(0, app.get_child_count())] + response = self._runWnckScript(['icon'] + window_names) + if not response or not ('pixels' in response): + return None + + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_data( + bytes.fromhex(response['pixels']), + GdkPixbuf.Colorspace.RGB, + response['has_alpha'], + response['bits_per_sample'], + response['width'], + response['height'], + response['rowstride'] + ) + return pixbuf + except: + return None diff --git a/src/meson.build b/src/meson.build index cbe1de4..d72c022 100644 --- a/src/meson.build +++ b/src/meson.build @@ -13,3 +13,13 @@ configure_file( install: true, install_dir: get_option('bindir'), ) + +configure_file( + input: 'wnck-window-infos.py', + output: 'wnck-window-infos.py', + configuration: { + 'PYTHON': python3.full_path(), + }, + install: true, + install_dir: get_option('datadir') / meson.project_name(), +) diff --git a/src/wnck-window-infos.py b/src/wnck-window-infos.py new file mode 100755 index 0000000..f2bb71d --- /dev/null +++ b/src/wnck-window-infos.py @@ -0,0 +1,133 @@ +#!@PYTHON@ + +''' +@license: BSD + +All rights reserved. This program and the accompanying materials are made +available under the terms of the BSD which accompanies this distribution, and +is available at U{http://www.opensource.org/licenses/bsd-license.php} + + +Helper program for WnckWindowManager that retrieves window information +using Wnck and prints them to stdout in JSON format. + +The information to print is specified by the program arguments. + +This is a separate program to avoid a Wnck (and thus X11 and GTK 3) +dependency in the main application. +''' + +import gi +gi.require_version('Wnck', '3.0') +from gi.repository import Wnck + +import json +import os +import re +import sys + +class WindowInfoProvider: + + def __init__(self): + # require GDK_BACKEND=x11 environment variable to be set + # as Wnck only works on X11/XWayland + if os.environ.get('GDK_BACKEND') != 'x11': + print('Error: Environment variable GDK_BACKEND=x11 not set', file=sys.stderr) + exit(1) + + self.wnck_screen = Wnck.Screen.get_default() + self.wnck_screen.force_update() + + def print_window_infos(self): + ''' + Print JSON array containing information about all windows to stdout. + ''' + active_workspace = self.wnck_screen.get_active_workspace() + win_infos = [] + stacking_index = 0 + for window in self.wnck_screen.get_windows_stacked(): + # client geometry is used unless window has client side decorations, + # in which case frame geometry is used; + # assume that client side decoration is used when client rect contains the frame rect + client_x, client_y, client_width, client_height = window.get_client_window_geometry() + frame_x, frame_y, frame_width, frame_height = window.get_geometry() + client_contains_frame = (client_x <= frame_x) and (client_y <= frame_y) \ + and (client_x + client_width >= frame_x + frame_width) \ + and (client_y + client_height >= frame_y + frame_height) + if client_contains_frame: + toplevel_x, toplevel_y, toplevel_width, toplevel_height = frame_x, frame_y, frame_width, frame_height + else: + toplevel_x, toplevel_y, toplevel_width, toplevel_height = client_x, client_y, client_width, client_height + title = window.get_name() + # strip additional trailing Left-to-Right Mark (U+200E), seen with KWin + if title[-1] == '\u200e': + title = title[0:-1] + # KWin 5 (but not KWin 6) adds suffix to window title when there are multiple windows with the + # same name, e.g. first window: "Hypertext", second window: "Hypertext <2>". + # Remove the suffix as the accessible name retrieved from AT-SPI2 doesn't have it either + regex = '^.* <[0-9]+>$' + if re.match(regex, title): + title = title[0:title.rfind(' ')] + # workspace can be None if window is on all workspaces, so only consider case + # of an actually returned workspace differing from the active one as not on active workspace + workspace = window.get_workspace() + on_active_workspace = (not workspace) or (not active_workspace) or window.is_on_workspace(active_workspace) + pid = window.get_pid() + + win_infos.append( + { + 'caption': title, + 'geometry.x': toplevel_x, + 'geometry.y': toplevel_y, + 'geometry.width': toplevel_width, + 'geometry.height': toplevel_height, + 'isOnCurrentWorkspace': on_active_workspace, + 'pid': pid, + 'stackingOrder': stacking_index + } + ) + stacking_index = stacking_index + 1 + + print(json.dumps(win_infos)) + + def print_icon_infos(self, window_names): + ''' + Print relevant information about the window icon that can be + used to create a GdkPixbuf.Pixbuf from it. + The information will be printed for the first + window whose name matches one in the given list. + + @param window_names: List of window names/titles. + @type list[str] + ''' + for win in self.wnck_screen.get_windows(): + wname = win.get_name() + for name in window_names: + if name == wname: + pixbuf = win.get_mini_icon() + result = { + 'pixels': pixbuf.get_pixels().hex(), + 'has_alpha': pixbuf.get_has_alpha(), + 'bits_per_sample': pixbuf.get_bits_per_sample(), + 'width': pixbuf.get_width(), + 'height': pixbuf.get_height(), + 'rowstride': pixbuf.get_rowstride() + } + print(json.dumps(result)) + return + + print(json.dumps({})) + + +if __name__ == '__main__': + if len(sys.argv) < 2: + print('Usage: `wnck-get-window-infos.py window-infos` or `wnck-get-window-infos.py icon win_name1 [win_name2 ...]`', file=sys.stderr) + exit(1) + + provider = WindowInfoProvider() + if sys.argv[1] == "window-infos": + provider.print_window_infos() + elif sys.argv[1] == "icon": + provider.print_icon_infos(sys.argv[2:]) + else: + print('Usage: `wnck-get-window-infos.py window-infos` or `wnck-get-window-infos.py icon win_name1 [win_name2 ...]`', file=sys.stderr) -- GitLab