| 1 | # Copyright (c) 2008-2009, samurai-x.org |
|---|
| 2 | # All rights reserved. |
|---|
| 3 | # |
|---|
| 4 | # Redistribution and use in source and binary forms, with or without |
|---|
| 5 | # modification, are permitted provided that the following conditions are met: |
|---|
| 6 | # * Redistributions of source code must retain the above copyright |
|---|
| 7 | # notice, this list of conditions and the following disclaimer. |
|---|
| 8 | # * Redistributions in binary form must reproduce the above copyright |
|---|
| 9 | # notice, this list of conditions and the following disclaimer in the |
|---|
| 10 | # documentation and/or other materials provided with the distribution. |
|---|
| 11 | # * Neither the name of the samurai-x.org nor the |
|---|
| 12 | # names of its contributors may be used to endorse or promote products |
|---|
| 13 | # derived from this software without specific prior written permission. |
|---|
| 14 | # |
|---|
| 15 | # THIS SOFTWARE IS PROVIDED BY SAMURAI-X.ORG ``AS IS'' AND ANY |
|---|
| 16 | # EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED |
|---|
| 17 | # WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE |
|---|
| 18 | # DISCLAIMED. IN NO EVENT SHALL SAMURAI-X.ORG BE LIABLE FOR ANY |
|---|
| 19 | # DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES |
|---|
| 20 | # (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; |
|---|
| 21 | # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND |
|---|
| 22 | # ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT |
|---|
| 23 | # (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS |
|---|
| 24 | # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. |
|---|
| 25 | |
|---|
| 26 | import logging |
|---|
| 27 | log = logging.getLogger(__name__) |
|---|
| 28 | |
|---|
| 29 | import sys |
|---|
| 30 | import gc |
|---|
| 31 | |
|---|
| 32 | from ooxcb import XNone |
|---|
| 33 | from ooxcb.protocol import xproto |
|---|
| 34 | from ooxcb.protocol.xproto import EventMask |
|---|
| 35 | |
|---|
| 36 | from .client import Client |
|---|
| 37 | from .rect import Rect |
|---|
| 38 | from .base import SXObject |
|---|
| 39 | from .util import ClientMessageHandlers |
|---|
| 40 | |
|---|
| 41 | def configure_request_to_dict(evt): |
|---|
| 42 | """ |
|---|
| 43 | Convert the :class:`ooxcb.xproto.ConfigureRequestEvent` *evt* |
|---|
| 44 | to a dictionary containing only the values that are requested |
|---|
| 45 | to be changed and return it. |
|---|
| 46 | """ |
|---|
| 47 | cnf = {} |
|---|
| 48 | mask = evt.value_mask |
|---|
| 49 | # TODO: get rid of that boilerplate code |
|---|
| 50 | if mask & xproto.ConfigWindow.X: |
|---|
| 51 | cnf['x'] = evt.x |
|---|
| 52 | if mask & xproto.ConfigWindow.Y: |
|---|
| 53 | cnf['y'] = evt.y |
|---|
| 54 | if mask & xproto.ConfigWindow.Width: |
|---|
| 55 | cnf['width'] = evt.width |
|---|
| 56 | if mask & xproto.ConfigWindow.Height: |
|---|
| 57 | cnf['height'] = evt.height |
|---|
| 58 | if mask & xproto.ConfigWindow.BorderWidth: |
|---|
| 59 | cnf['border_width'] = evt.border_width |
|---|
| 60 | if mask & xproto.ConfigWindow.Sibling: |
|---|
| 61 | cnf['sibling'] = evt.sibling # does that work? |
|---|
| 62 | if mask & xproto.ConfigWindow.StackMode: |
|---|
| 63 | cnf['stack_mode'] = evt.stack_mode |
|---|
| 64 | return cnf |
|---|
| 65 | |
|---|
| 66 | class Screen(SXObject): |
|---|
| 67 | """ |
|---|
| 68 | A wrapper for a physical X screen. For many users, there will |
|---|
| 69 | only be one Screen. For some others, there will be more. |
|---|
| 70 | |
|---|
| 71 | The class attribute client_class is the class that will be used |
|---|
| 72 | to create new clients. You can change it, but that's maybe not |
|---|
| 73 | such a good idea. |
|---|
| 74 | |
|---|
| 75 | """ |
|---|
| 76 | client_class = Client |
|---|
| 77 | |
|---|
| 78 | def __init__(self, app, num): |
|---|
| 79 | SXObject.__init__(self) |
|---|
| 80 | |
|---|
| 81 | self.app = app |
|---|
| 82 | self.conn = app.conn |
|---|
| 83 | |
|---|
| 84 | self.clients = set() |
|---|
| 85 | self.client_message_handlers = ClientMessageHandlers() |
|---|
| 86 | self.focused_client = None |
|---|
| 87 | # possible states for _NET_WM_STATE. |
|---|
| 88 | self.possible_states = dict((name, self.conn.atoms[name]) for name in ( |
|---|
| 89 | '_NET_WM_STATE_MODAL', '_NET_WM_STATE_STICKY', |
|---|
| 90 | '_NET_WM_STATE_MAXIMIZED_VERT', '_NET_WM_STATE_MAXIMIZED_HORZ', |
|---|
| 91 | '_NET_WM_STATE_SHADED', '_NET_WM_STATE_SKIP_TASKBAR', |
|---|
| 92 | '_NET_WM_STATE_SKIP_PAGER', '_NET_WM_STATE_HIDDEN', |
|---|
| 93 | '_NET_WM_STATE_FULLSCREEN', '_NET_WM_STATE_ABOVE', |
|---|
| 94 | '_NET_WM_STATE_BELOW', '_NET_WM_STATE_DEMANDS_ATTENTION' |
|---|
| 95 | )) |
|---|
| 96 | |
|---|
| 97 | # the screen number |
|---|
| 98 | self.number = num |
|---|
| 99 | self.info = app.conn.get_setup().roots[num] |
|---|
| 100 | # the root window |
|---|
| 101 | self.root = self.info.root |
|---|
| 102 | |
|---|
| 103 | # set of hints this screen supports |
|---|
| 104 | self.supported_hints = set() |
|---|
| 105 | |
|---|
| 106 | # set the check window before setting the MANAGER selection |
|---|
| 107 | self.check_window = self.create_check_window() |
|---|
| 108 | self.set_manager_selection() |
|---|
| 109 | self.check_window.push_handlers( |
|---|
| 110 | on_selection_clear=self.check_window_on_selection_clear |
|---|
| 111 | ) |
|---|
| 112 | |
|---|
| 113 | self.root.change_attributes( |
|---|
| 114 | event_mask= |
|---|
| 115 | EventMask.SubstructureRedirect | |
|---|
| 116 | EventMask.SubstructureNotify | |
|---|
| 117 | EventMask.StructureNotify | |
|---|
| 118 | EventMask.Exposure | |
|---|
| 119 | EventMask.PropertyChange, |
|---|
| 120 | cursor= |
|---|
| 121 | app.cursors['Normal'] |
|---|
| 122 | ) |
|---|
| 123 | |
|---|
| 124 | self.root.push_handlers(self) |
|---|
| 125 | |
|---|
| 126 | def set_manager_selection(self): |
|---|
| 127 | """ |
|---|
| 128 | acquire the WM_Sn selection, where `n` is the screen number |
|---|
| 129 | """ |
|---|
| 130 | # check ownership first |
|---|
| 131 | atom = self.conn.atoms["WM_S%d" % self.number] |
|---|
| 132 | owner = atom.get_selection_owner().reply().owner |
|---|
| 133 | if owner is not XNone: |
|---|
| 134 | # there is already a wm running on this screen |
|---|
| 135 | # should replace? |
|---|
| 136 | if not self.app.replace_existing_wm: |
|---|
| 137 | # shouldn't replace |
|---|
| 138 | log.error('There is already a window manager running on screen %d - ' % self.number + |
|---|
| 139 | 'Type `sx-wm --replace` if you wish to replace the running wm') |
|---|
| 140 | # TODO: just quit it? |
|---|
| 141 | sys.exit(0) |
|---|
| 142 | return |
|---|
| 143 | # set the selection owner to the check window |
|---|
| 144 | atom.set_selection_owner(self.check_window) |
|---|
| 145 | # send a client message to kick the other window manager |
|---|
| 146 | evt = xproto.ClientMessageEvent.create( |
|---|
| 147 | self.conn, |
|---|
| 148 | self.conn.atoms["MANAGER"], |
|---|
| 149 | self.root, |
|---|
| 150 | 32, |
|---|
| 151 | [ |
|---|
| 152 | xproto.Time.CurrentTime, |
|---|
| 153 | atom.get_internal(), |
|---|
| 154 | self.check_window.get_internal(), |
|---|
| 155 | ] |
|---|
| 156 | ) |
|---|
| 157 | self.root.send_event( |
|---|
| 158 | EventMask.StructureNotify, |
|---|
| 159 | evt |
|---|
| 160 | ) |
|---|
| 161 | self.conn.flush() |
|---|
| 162 | |
|---|
| 163 | def check_window_on_selection_clear(self, evt): |
|---|
| 164 | """ |
|---|
| 165 | Event handler: most likely, a window manager wants to replace |
|---|
| 166 | samurai-x2! |
|---|
| 167 | """ |
|---|
| 168 | atom = self.conn.atoms["WM_S%d" % self.number] |
|---|
| 169 | if evt.selection == atom: |
|---|
| 170 | self.app.stop() |
|---|
| 171 | else: |
|---|
| 172 | log.warning('Received an unknown selection clear event: %s' % evt.selection) |
|---|
| 173 | |
|---|
| 174 | def get_geometry(self): |
|---|
| 175 | """ |
|---|
| 176 | return a :class:`samuraix.rect.Rect` describing the |
|---|
| 177 | geometry of the physical screen. |
|---|
| 178 | """ |
|---|
| 179 | return Rect.from_object(self.root.get_geometry().reply()) |
|---|
| 180 | |
|---|
| 181 | def create_check_window(self): |
|---|
| 182 | """ |
|---|
| 183 | The 'check window' is the window required by the |
|---|
| 184 | `_NET_SUPPORTING_WM_CHECK hint`, specified in the |
|---|
| 185 | netwm standard. It only has a `_NET_WM_NAME` property, |
|---|
| 186 | set to 'samurai-x2'. It's override-redirected and |
|---|
| 187 | invisible. |
|---|
| 188 | This also sets the `_NET_SUPPORTING_WM_CHECK` hint. |
|---|
| 189 | """ |
|---|
| 190 | win = xproto.Window.create(self.conn, |
|---|
| 191 | self.root, |
|---|
| 192 | self.info.root_depth, |
|---|
| 193 | self.info.root_visual, |
|---|
| 194 | 0, |
|---|
| 195 | 0, |
|---|
| 196 | 1, |
|---|
| 197 | 1, |
|---|
| 198 | 0, |
|---|
| 199 | override_redirect=True) |
|---|
| 200 | self.root.change_property('_NET_SUPPORTING_WM_CHECK', 'WINDOW', 32, [win.get_internal()]) |
|---|
| 201 | win.change_property('_NET_SUPPORTING_WM_CHECK', 'WINDOW', 32, [win.get_internal()]) |
|---|
| 202 | win.change_property('_NET_WM_NAME', 'UTF8_STRING', 8, map(ord, 'samurai-x')) # TODO: nicer conversion |
|---|
| 203 | return win |
|---|
| 204 | |
|---|
| 205 | def process_netwm_client_message(self, evt): |
|---|
| 206 | """ |
|---|
| 207 | process the netwm client message *evt*. |
|---|
| 208 | """ |
|---|
| 209 | return self.client_message_handlers.handle(evt) |
|---|
| 210 | |
|---|
| 211 | def on_client_message(self, evt): |
|---|
| 212 | """ |
|---|
| 213 | Root windows' event handler for |
|---|
| 214 | :class:`ooxcb.xproto.ClientMessageEvent`: |
|---|
| 215 | pass the client message event *evt* to |
|---|
| 216 | :meth:`process_netwm_client_message`. |
|---|
| 217 | """ |
|---|
| 218 | self.process_netwm_client_message(evt) |
|---|
| 219 | |
|---|
| 220 | def on_configure_request(self, evt): |
|---|
| 221 | """ |
|---|
| 222 | Root windows' event handler for |
|---|
| 223 | :class:`ooxcb.xproto.ConfigureRequestEvent`. |
|---|
| 224 | Fulfill the request, or give a warning if we received a very |
|---|
| 225 | strange configure request: one that doesn't request to change |
|---|
| 226 | any values o_O |
|---|
| 227 | |
|---|
| 228 | Event's parent window is the event target of the |
|---|
| 229 | configure request because sometimes we have a |
|---|
| 230 | window that isn't managed yet, but sends a configure |
|---|
| 231 | request. This configure request would be lost. |
|---|
| 232 | """ |
|---|
| 233 | cnf = configure_request_to_dict(evt) |
|---|
| 234 | if cnf: |
|---|
| 235 | evt.window.configure_checked(**cnf).check() |
|---|
| 236 | else: |
|---|
| 237 | log.warning('Strange configure request: No attributes set') |
|---|
| 238 | |
|---|
| 239 | def on_map_request(self, evt): |
|---|
| 240 | """ |
|---|
| 241 | Root windows' event handler for |
|---|
| 242 | :class:`ooxcb.xproto.MapRequestEvent`. That will |
|---|
| 243 | create a client for the window if there isn't one already. |
|---|
| 244 | In any case, this will map the window as requested. |
|---|
| 245 | """ |
|---|
| 246 | #if evt.override_redirect: |
|---|
| 247 | # return # TODO: strange override_redirect values (88? oO) |
|---|
| 248 | client = Client.get_by_window(evt.window) |
|---|
| 249 | if client is None: |
|---|
| 250 | # not created yet |
|---|
| 251 | # NB we still not might manage this window - check manage() |
|---|
| 252 | if self.manage(evt.window): |
|---|
| 253 | return |
|---|
| 254 | # uh, did we forget that? |
|---|
| 255 | evt.window.map() |
|---|
| 256 | |
|---|
| 257 | def on_destroy_notify(self, evt): |
|---|
| 258 | """ |
|---|
| 259 | Root windows' event handler for |
|---|
| 260 | :class:`ooxcb.xproto.DestroyNotifyEvent`. |
|---|
| 261 | If there is a client managing *evt*'s window, call |
|---|
| 262 | :meth:`samuraix.client.Client.remove` and set |
|---|
| 263 | its window.valid to False. |
|---|
| 264 | """ |
|---|
| 265 | win = evt.window |
|---|
| 266 | client = Client.get_by_window(evt.window) |
|---|
| 267 | log.debug('Root window got destroy notify event for ' |
|---|
| 268 | 'window %s client %s' % (evt.window, client)) |
|---|
| 269 | if client is not None: |
|---|
| 270 | client.window.valid = False # TODO: shouldn't be here. |
|---|
| 271 | client.remove() |
|---|
| 272 | |
|---|
| 273 | def manage(self, window): |
|---|
| 274 | """ |
|---|
| 275 | manage a new window - this may *not* result in a window |
|---|
| 276 | being managed if it is unsuitable (ie a dock |
|---|
| 277 | or override-redirected window) |
|---|
| 278 | """ |
|---|
| 279 | # we dont want to do this here as we still want to manage these windows |
|---|
| 280 | # its down to the decorator what to decorate or not |
|---|
| 281 | # check window type |
|---|
| 282 | #window_type = map(self.conn.atoms.get_by_id, |
|---|
| 283 | # window.get_property('_NET_WM_WINDOW_TYPE', 'ATOM').reply().value) |
|---|
| 284 | #if self.conn.atoms['_NET_WM_WINDOW_TYPE_DOCK'] in window_type: |
|---|
| 285 | # log.debug('%s not managing %s - is a dock.' % (self, window)) |
|---|
| 286 | # # TODO: ignore other types, too? |
|---|
| 287 | # return |
|---|
| 288 | |
|---|
| 289 | attributes = window.get_attributes().reply() |
|---|
| 290 | geom = window.get_geometry().reply() |
|---|
| 291 | |
|---|
| 292 | # override redirect windows need to be ignored - theyre not for us |
|---|
| 293 | if attributes.override_redirect: |
|---|
| 294 | log.debug('%s not managing %s override_redirect is set' % \ |
|---|
| 295 | (self, window)) |
|---|
| 296 | return False |
|---|
| 297 | |
|---|
| 298 | client = self.client_class(self, window, geom) |
|---|
| 299 | logging.debug('screen %s is now managing %s' % (self, client)) |
|---|
| 300 | self.clients.add(client) |
|---|
| 301 | |
|---|
| 302 | self.dispatch_event('on_new_client', self, client) |
|---|
| 303 | client.push_handlers(on_removed=self.on_client_removed) |
|---|
| 304 | |
|---|
| 305 | client.init() |
|---|
| 306 | |
|---|
| 307 | # Don't focus a new client ... |
|---|
| 308 | #if self.focused_client is None: |
|---|
| 309 | # self.focus(client) |
|---|
| 310 | |
|---|
| 311 | self.dispatch_event('on_after_new_client', self, client) |
|---|
| 312 | |
|---|
| 313 | # We update the client list after the 'on_after_new_client' event here. |
|---|
| 314 | # That is because (at least) bbpager reacts to changes of the |
|---|
| 315 | # _NET_CLIENT_LIST property. If it is updated before the window gets |
|---|
| 316 | # a _NET_WM_DESKTOP property (it gets one in sx-desktops' |
|---|
| 317 | # 'on_after_new_client' event handler), bbpager won't display the new |
|---|
| 318 | # window in its preview. So we update the client list after having |
|---|
| 319 | # done everything else. |
|---|
| 320 | # Maybe that's not the best way, but it works. |
|---|
| 321 | self.update_client_list() |
|---|
| 322 | return client |
|---|
| 323 | |
|---|
| 324 | def unmanage(self, client): |
|---|
| 325 | """ |
|---|
| 326 | Unmanage the client *client*. |
|---|
| 327 | That means: unban it. If we don't unban it, it is unmapped. |
|---|
| 328 | If it is unmapped, samurai-x won't manage it if it's restarted. |
|---|
| 329 | """ |
|---|
| 330 | log.info('Unmanaging %s ...' % client) |
|---|
| 331 | client.unmanage() |
|---|
| 332 | self.update_client_list() |
|---|
| 333 | self.clients.remove(client) |
|---|
| 334 | self.dispatch_event('on_unmanage_client', self, client) |
|---|
| 335 | # make the application call `gc.collect()` as soon |
|---|
| 336 | # as possible to collect `client`. |
|---|
| 337 | # TODO: solve that more nicely. |
|---|
| 338 | self.app.add_function_to_call(gc.collect) |
|---|
| 339 | log.info('Unmanaged %s' % client) |
|---|
| 340 | |
|---|
| 341 | def on_unmanage_client(self, screen, client): |
|---|
| 342 | """ |
|---|
| 343 | default handler: if the focused client is |
|---|
| 344 | unmanaged, a random other client is focused. |
|---|
| 345 | You May Override This. |
|---|
| 346 | """ |
|---|
| 347 | if self.focused_client is client: |
|---|
| 348 | new_client = None |
|---|
| 349 | try: |
|---|
| 350 | new_client = iter(self.clients).next() # TODO: expensive? |
|---|
| 351 | except StopIteration: |
|---|
| 352 | pass |
|---|
| 353 | self.focus(new_client) |
|---|
| 354 | |
|---|
| 355 | def on_client_removed(self, client): |
|---|
| 356 | """ |
|---|
| 357 | event handler: if a client's window is removed, |
|---|
| 358 | unmanage the client. |
|---|
| 359 | |
|---|
| 360 | :todo: is that necessary? |
|---|
| 361 | """ |
|---|
| 362 | self.unmanage(client) |
|---|
| 363 | |
|---|
| 364 | def unmanage_all(self): |
|---|
| 365 | """ |
|---|
| 366 | Unmanage all my clients. That is usually called at |
|---|
| 367 | the end of samurai-x' lifetime. |
|---|
| 368 | """ |
|---|
| 369 | while self.clients: |
|---|
| 370 | self.unmanage(iter(self.clients).next()) # TODO: expensive? |
|---|
| 371 | |
|---|
| 372 | def update_client_list(self): |
|---|
| 373 | """ |
|---|
| 374 | update the root window's `_NET_CLIENT_LIST` property as described |
|---|
| 375 | in the netwm standard. |
|---|
| 376 | """ |
|---|
| 377 | # re-set _NET_CLIENT_LIST |
|---|
| 378 | self.root.change_property('_NET_CLIENT_LIST', |
|---|
| 379 | 'WINDOW', |
|---|
| 380 | 32, |
|---|
| 381 | [c.window.get_internal() for c in self.client_class.all_clients]) |
|---|
| 382 | self.conn.flush() |
|---|
| 383 | # TODO: calling get_internal() is not that nice. we'll have to change that. |
|---|
| 384 | |
|---|
| 385 | def update_active_window(self): |
|---|
| 386 | """ |
|---|
| 387 | Update `_NET_ACTIVE_WINDOW`; set it to *self.focused_client*. |
|---|
| 388 | """ |
|---|
| 389 | if self.focused_client is not None: |
|---|
| 390 | self.root.change_property('_NET_ACTIVE_WINDOW', 'WINDOW', 32, |
|---|
| 391 | [self.focused_client.window.get_internal()]) |
|---|
| 392 | |
|---|
| 393 | def focus(self, client): |
|---|
| 394 | """ |
|---|
| 395 | focus the client `client`. |
|---|
| 396 | It may be None => No focus. |
|---|
| 397 | """ |
|---|
| 398 | if client: |
|---|
| 399 | log.debug('Screen. I am focusing %s %s %s' % (client, client.window, client.actor)) |
|---|
| 400 | else: |
|---|
| 401 | log.debug('Screen. I am focusing nothing.') |
|---|
| 402 | # set the new focused client before calling `blur`. A client's |
|---|
| 403 | # "on_blur" event handlers can use `Client.is_focused` then. |
|---|
| 404 | # Only call `blur` if the client's window is valid (ie not already destroyed) |
|---|
| 405 | old_client = self.focused_client |
|---|
| 406 | self.focused_client = client |
|---|
| 407 | if (old_client is not None and old_client.window.valid): |
|---|
| 408 | old_client.blur() |
|---|
| 409 | # set the hint |
|---|
| 410 | self.update_active_window() |
|---|
| 411 | if client is not None: |
|---|
| 412 | client.focus() |
|---|
| 413 | |
|---|
| 414 | def scan(self): |
|---|
| 415 | """ scan a screen for windows to manage """ |
|---|
| 416 | children = self.root.query_tree().reply().children |
|---|
| 417 | for child in children: |
|---|
| 418 | log.debug('%s found child %s', self, child) |
|---|
| 419 | try: |
|---|
| 420 | attr = child.get_attributes().reply() |
|---|
| 421 | except xproto.BadWindow: |
|---|
| 422 | log.warning('Window was destroyed while scanning: %s' % child) |
|---|
| 423 | continue |
|---|
| 424 | |
|---|
| 425 | log.debug('attr %s', attr) |
|---|
| 426 | |
|---|
| 427 | # according to awesome we only do this when scanning... |
|---|
| 428 | # ( not 100% sure why yet... ) |
|---|
| 429 | if attr.map_state != xproto.MapState.Viewable: |
|---|
| 430 | log.debug('%s not managing %s - not viewable', self, child) |
|---|
| 431 | continue |
|---|
| 432 | # TODO: we receive the attributes two times here. |
|---|
| 433 | self.manage(child) |
|---|
| 434 | |
|---|
| 435 | def set_supported_hints(self, supported): |
|---|
| 436 | """ |
|---|
| 437 | set the `_NET_SUPPORTED` atom to the hints we support |
|---|
| 438 | """ |
|---|
| 439 | self.root.change_property('_NET_SUPPORTED', 'ATOM', 32, |
|---|
| 440 | [s.get_internal() for s in supported]) # TODO: nicer conversion |
|---|
| 441 | |
|---|
| 442 | Screen.register_event_type('on_new_client') |
|---|
| 443 | Screen.register_event_type('on_after_new_client') |
|---|
| 444 | Screen.register_event_type('on_unmanage_client') |
|---|