class WM: """ Provides basic building blocks to make a window manager. It hides many dirty details about XCB. It was intended to provide minimum functionality, the rest supposed to be implemented by user in configuration file. """ root = None # type: Window atoms = None # type: AtomVault def __init__(self, display=None, desktops=None, loop=None): self.log = Log("WM") # INIT SOME BASIC STUFF self.hook = Hook() self.windows = {} # mapping between window id and Window self.win2desk = {} if not display: display = os.environ.get("DISPLAY") try: self._conn = xcffib.connect(display=display) except xcffib.ConnectionException: sys.exit("cannot connect to %s" % display) self.atoms = AtomVault(self._conn) self.desktops = desktops or [Desktop()] self.cur_desktop = self.desktops[0] self.cur_desktop.show() # CREATE ROOT WINDOW xcb_setup = self._conn.get_setup() xcb_screens = [i for i in xcb_setup.roots] self.xcb_default_screen = xcb_screens[self._conn.pref_screen] root_wid = self.xcb_default_screen.root self.root = Window(self, wid=root_wid, atoms=self.atoms, mapped=True) self.windows[root_wid] = self.root # for desktop in self.desktops: # desktop.windows.append(self.root) self.root.set_attr( eventmask=( EventMask.StructureNotify | EventMask.SubstructureNotify | EventMask.FocusChange # | EventMask.SubstructureRedirect | EventMask.EnterWindow # | EventMask.LeaveWindow # | EventMask.PropertyChange | EventMask.OwnerGrabButton ) ) # INFORM X WHICH FEATURES WE SUPPORT self.root.props[self.atoms._NET_SUPPORTED] = [self.atoms[a] for a in SUPPORTED_ATOMS] # PRETEND TO BE A WINDOW MANAGER supporting_wm_check_window = self.create_window(-1, -1, 1, 1) supporting_wm_check_window.props['_NET_WM_NAME'] = "SWM" self.root.props['_NET_SUPPORTING_WM_CHECK'] = supporting_wm_check_window.wid self.root.props['_NET_NUMBER_OF_DESKTOPS'] = len(self.desktops) self.root.props['_NET_CURRENT_DESKTOP'] = 0 # TODO: set cursor # EVENTS THAT HAVE LITTLE USE FOR US... self.ignoreEvents = { "KeyRelease", "ReparentNotify", # "CreateNotify", # DWM handles this to help "broken focusing windows". # "MapNotify", "ConfigureNotify", "LeaveNotify", "FocusOut", "FocusIn", "NoExposure", } # KEYBOARD self.kbd = Keyboard(xcb_setup, self._conn) self.mouse = Mouse(conn=self._conn, root=self.root) # FLUSH XCB BUFFER self.xsync() # apply settings # the event loop is not yet there, but we might have some pending # events... self._xpoll() # TODO: self.grabMouse # NOW IT'S TIME TO GET PHYSICAL SCREEN CONFIGURATION self.xrandr = Xrandr(root=self.root, conn=self._conn) # TODO: self.update_net_desktops() # SETUP EVENT LOOP if not loop: loop = asyncio.new_event_loop() self._eventloop = loop self._eventloop.add_signal_handler(signal.SIGINT, self.stop) self._eventloop.add_signal_handler(signal.SIGTERM, self.stop) self._eventloop.add_signal_handler(signal.SIGCHLD, self.on_sigchld) self._eventloop.set_exception_handler( lambda loop, ctx: self.log.error( "Got an exception in {}: {}".format(loop, ctx)) ) fd = self._conn.get_file_descriptor() self._eventloop.add_reader(fd, self._xpoll) # HANDLE STANDARD EVENTS self.hook.register("MapRequest", self.on_map_request) self.hook.register("MapNotify", self.on_map_notify) self.hook.register("UnmapNotify", self.on_window_unmap) self.hook.register("KeyPress", self.on_key_press) # self.hook.register("KeyRelease", self.on_key_release) # self.hook.register("CreateNotify", self.on_window_create) self.hook.register("PropertyNotify", self.on_property_notify) self.hook.register("ClientMessage", self.on_client_message) self.hook.register("DestroyNotify", self.on_window_destroy) self.hook.register("EnterNotify", self.on_window_enter) self.hook.register("ConfigureRequest", self.on_configure_window) self.hook.register("MotionNotify", self.on_mouse_event) self.hook.register("ButtonPress", self.on_mouse_event) self.hook.register("ButtonRelease", self.on_mouse_event) def on_property_notify(self, evname, xcb_event): # TODO: messy ugly code wid = xcb_event.window atom = self.atoms.get_name(xcb_event.atom) # window = self.windows.get(wid, Window(wm=self, wid=wid, mapped=True)) self.log.error("PropertyNotify: %s" % atom) run_("xprop -id %s %s" % (wid, atom)) # TODO: dirty code, relocate to config def on_client_message(self, evname, xcb_event): self.log.error(dir(xcb_event)) data = xcb_event.data resp_type = self.atoms.get_name(xcb_event.response_type) type = self.atoms.get_name(xcb_event.type) wid = xcb_event.window self.log.error("client message: resp_type={resp_type} window={wid} type={type} data={data}" \ .format(**locals())) # 'bufsize', 'data', 'format', 'pack', 'response_type', 'sequence', 'synthetic', 'type', 'window'] if type == '_NET_ACTIVE_WINDOW': window = self.windows[wid] window.rise() self.focus_on(window) def on_sigchld(self): """ Rip orphans. """ while True: try: pid, status = os.waitpid(-1, os.WNOHANG) if (pid, status) == (0, 0): # no child to rip break self.log.notice("ripped child PID=%s" % pid) except ChildProcessError: break def on_map_request(self, evname, xcb_event): """ Map request is a request to draw the window on screen. """ wid = xcb_event.window if wid not in self.windows: window = self.on_new_window(wid) else: window = self.windows[wid] self.log.on_map_request.debug("map request for %s" % window) window.show() if window.above_all: window.rise() if window.can_focus: window.focus() def on_new_window(self, wid): """ Registers new window. """ window = Window(wm=self, wid=wid, atoms=self.atoms, mapped=True) # call configuration hood first # to setup attributes like 'sticky' self.hook.fire("new_window", window) self.log.on_new_window.debug( "new window is ready: %s" % window) self.windows[wid] = window self.win2desk[window] = self.cur_desktop if window.sticky: for desktop in self.desktops: desktop.add(window) else: self.cur_desktop.windows.append(window) return window def on_map_notify(self, evname, xcb_event): wid = xcb_event.window if wid not in self.windows: window = self.on_new_window(wid) else: window = self.windows[wid] window.mapped = True if window.above_all: window.rise() # if window.can_focus: # window.focus() self.log.on_map_notify.debug("map notify for %s" % window) def on_window_unmap(self, evname, xcb_event): wid = xcb_event.window if wid not in self.windows: return window = self.windows[wid] window.mapped = False self.hook.fire("window_unmap", window) def on_window_destroy(self, evname, xcb_event): wid = xcb_event.window if wid not in self.windows: return window = self.windows[wid] assert isinstance(window, Window), "it's not a window: %s (%s)" % ( window, type(window)) for desktop in self.desktops: try: desktop.windows.remove(window) self.log.debug("%s removed from %s" % (self, desktop)) except ValueError: pass del self.windows[wid] if window in self.win2desk: del self.win2desk[window] def on_window_enter(self, evname, xcb_event): wid = xcb_event.event if wid not in self.windows: # self.log.on_window_enter.error("no window with wid=%s" % wid) self.hook.fire("unknown_window", wid) return window = self.windows[wid] # self.log.on_window_enter("window_enter: %s %s" % (wid, window)) self.hook.fire("window_enter", window) def grab_key(self, modifiers, key, owner_events=False, window=None): """ Intercept this key when it is pressed. If owner_events=False then the window in focus will not receive it. This is useful from WM hotkeys. """ # TODO: check if key already grabbed? # Here is how X works with keys: # key => keysym => keycode # where `key' is something like 'a', 'b' or 'Enter', # `keysum' is what should be written on they key cap (physical keyboard) # and `keycode' is a number reported by the keyboard when the key is pressed. # Modifiers are keys like Shift, Alt, Win and some other buttons. self.log.grab_key.debug("intercept keys: %s %s" % (modifiers, key)) if window is None: window = self.root keycode = self.kbd.key_to_code(key) modmask = get_modmask(modifiers) # TODO: move to Keyboard event = ("on_key_press", modmask, keycode) pointer_mode = xproto.GrabMode.Async keyboard_mode = xproto.GrabMode.Async self._conn.core.GrabKey( owner_events, window.wid, modmask, keycode, pointer_mode, keyboard_mode ) self.flush() # TODO: do we need this? return event def on_key_press(self, evname, xcb_event): # TODO: ignore capslock, scrolllock and other modifiers? modmap = xcb_event.state keycode = xcb_event.detail event = ("on_key_press", modmap, keycode) self.hook.fire(event) def on_key_release(self, evname, xcb_event): modmap = xcb_event.state keycode = xcb_event.detail event = ("on_key_release", modmap, keycode) self.hook.fire(event) def grab_mouse(self, modifiers, button, owner_events=False, window=None): # http://www.x.org/archive/X11R7.7/doc/man/man3/xcb_grab_button.3.xhtml wid = (window or self.root).wid event_mask = xcffib.xproto.EventMask.ButtonPress | \ xcffib.xproto.EventMask.ButtonRelease | \ xcffib.xproto.EventMask.Button1Motion modmask = get_modmask(modifiers) pointer_mode = xproto.GrabMode.Async # I don't know what it is keyboard_mode = xproto.GrabMode.Async # do not block other keyboard events confine_to = xcffib.xproto.Atom._None # do not restrict cursor movements cursor = xcffib.xproto.Atom._None # do not change cursor event = ("on_mouse", modmask, button) # event to be used in hooks self._conn.core.GrabButton( owner_events, wid, event_mask, pointer_mode, keyboard_mode, confine_to, cursor, button, modmask, ) self.flush() # TODO: do we need this? return event def hotkey(self, keys, cmd): """ Setup hook to launch a command on specific hotkeys. """ @self.hook(self.grab_key(*keys)) def cb(event): run_(cmd) def focus_on(self, window, warp=False): """ Focuses on given window. """ self.cur_desktop.focus_on(window, warp) self.root.set_prop('_NET_ACTIVE_WINDOW', window.wid) def switch_to(self, desktop: Desktop): """ Switches to another desktop. """ if isinstance(desktop, int): desktop = self.desktops[desktop] if self.cur_desktop == desktop: self.log.notice("attempt to switch to the same desktop") return self.log.debug("switching from {} to {}".format( self.cur_desktop, desktop)) self.cur_desktop.hide() self.cur_desktop = desktop self.cur_desktop.show() # TODO: move this code to Desktop.show() self.root.props[self.atom._NET_CURRENT_DESKTOP] = desktop.id def relocate_to(self, window: Window, to_desktop: Desktop): """ Relocates window to a specific desktop. """ if window.sticky: self.log.debug( "%s is meant to be on all desktops, cannot relocate to specific one" % window) return from_desktop = self.cur_desktop if from_desktop == to_desktop: self.log.debug( "no need to relocate %s because remains on the same desktop" % window) return from_desktop.remove(window) to_desktop.add(window) def on_mouse_event(self, evname, xcb_event): """evname is one of ButtonPress, ButtonRelease or MotionNotify.""" # l = [(attr, getattr(xcb_event, attr)) for attr in sorted(dir(xcb_event)) if not attr.startswith('_')] # print(evname) # print(l) modmask = xcb_event.state & 0xff # TODO: is the mask correct? if evname == 'MotionNotify': button = 1 # TODO else: button = xcb_event.detail event = ('on_mouse', modmask, button) # print(event) self.hook.fire(event, evname, xcb_event) def on_configure_window(self, _, event): # This code is so trivial that I just took it from fpwm as is :) values = [] if event.value_mask & ConfigWindow.X: values.append(event.x) if event.value_mask & ConfigWindow.Y: values.append(event.y) if event.value_mask & ConfigWindow.Width: values.append(event.width) if event.value_mask & ConfigWindow.Height: values.append(event.height) if event.value_mask & ConfigWindow.BorderWidth: values.append(event.border_width) if event.value_mask & ConfigWindow.Sibling: values.append(event.sibling) if event.value_mask & ConfigWindow.StackMode: values.append(event.stack_mode) self._conn.core.ConfigureWindow(event.window, event.value_mask, values) def create_window(self, x, y, width, height): """ Create a window. Right now only used for initialization, see __init__. """ wid = self._conn.generate_id() self._conn.core.CreateWindow( self.xcb_default_screen.root_depth, wid, self.xcb_default_screen.root, x, y, width, height, 0, WindowClass.InputOutput, self.xcb_default_screen.root_visual, CW.BackPixel | CW.EventMask, [ self.xcb_default_screen.black_pixel, EventMask.StructureNotify | EventMask.Exposure ] ) return Window(self, wid=wid, atoms=self.atoms) def scan(self, focus=True): """ Gets all windows in the system. """ self.log.debug("performing scan of all mapped windows") q = self._conn.core.QueryTree(self.root.wid).reply() for wid in q.children: # attrs=self._conn.core.GetWindowAttributes(wid).reply() # print(attrs, type(attrs)) # if attrs.map_state == xproto.MapState.Unmapped: # self.log.scan.debug( # "window %s is not mapped, skipping" % wid) # TODO # continue if wid not in self.windows: self.on_new_window(wid) self.log.scan.info("the following windows are active: %s" % sorted(self.windows.values())) if focus: windows = sorted(self.windows.values()) windows = list(filter(lambda w: w != self.root and not w.skip, windows)) if windows: # on empty desktop there is nothing to focus on self.cur_desktop.focus_on(windows[-1], warp=True) def finalize(self): """ This code is run when event loop is terminated. """ pass # currently nothing to do here def flush(self): """ Force pending X request to be sent. By default XCB aggressevly buffers for performance reasons. """ return self._conn.flush() def xsync(self): """ Flush XCB queue and wait till it is processed by X server. """ # The idea here is that pushing an innocuous request through the queue # and waiting for a response "syncs" the connection, since requests are # serviced in order. self._conn.core.GetInputFocus().reply() def stop(self, xserver_dead=False): """ Stop WM to quit. """ self.hook.fire("on_exit") # display all hidden windows try: if not xserver_dead: for window in self.windows.values(): window.show() self.xsync() except Exception as err: self.log.stop.error("error on stop: %s" % err) self.log.stop.debug("stopping event loop") self._eventloop.stop() def replace(self, execv_args): self.log.notice("replacing current process with %s" % (execv_args,)) self.stop() import os os.execv(*execv_args) def loop(self): """ DITTO """ self.scan() try: self._eventloop.run_forever() finally: self.finalize() def _xpoll(self): """ Fetch incomming events (if any) and call hooks. """ # OK, kids, today I'll teach you how to write reliable enterprise # software! You just catch all the exceptions in the top-level loop # and ignore them. No, I'm kidding, these exceptions are no use # for us because we don't care if a window cannot be drawn or something. # We actually only need to handle just a few events and ignore the rest. # Exceptions happen because of the async nature of X. while True: try: xcb_event = self._conn.poll_for_event() if not xcb_event: break evname = xcb_event.__class__.__name__ if evname.endswith("Event"): evname = evname[:-5] if evname in self.ignoreEvents: self.log._xpoll.info("ignoring %s" % xcb_event) continue self.log._xpoll.critical("got %s %s" % (evname, xcb_event)) self.hook.fire(evname, xcb_event) self.flush() # xcb doesn't flush implicitly except (WindowError, AccessError, DrawableError): self.log.debug("(minor exception)") except Exception as e: self.log._xpoll.error(traceback.format_exc()) error_code = self._conn.has_error() if error_code: error_string = XCB_CONN_ERRORS[error_code] self.log.critical("Shutting down due to X connection error %s (%s)" % (error_string, error_code)) self.stop(xserver_dead=True) break