1 | /* |
2 | Simple DirectMedia Layer |
3 | Copyright (C) 1997-2021 Sam Lantinga <slouken@libsdl.org> |
4 | |
5 | This software is provided 'as-is', without any express or implied |
6 | warranty. In no event will the authors be held liable for any damages |
7 | arising from the use of this software. |
8 | |
9 | Permission is granted to anyone to use this software for any purpose, |
10 | including commercial applications, and to alter it and redistribute it |
11 | freely, subject to the following restrictions: |
12 | |
13 | 1. The origin of this software must not be misrepresented; you must not |
14 | claim that you wrote the original software. If you use this software |
15 | in a product, an acknowledgment in the product documentation would be |
16 | appreciated but is not required. |
17 | 2. Altered source versions must be plainly marked as such, and must not be |
18 | misrepresented as being the original software. |
19 | 3. This notice may not be removed or altered from any source distribution. |
20 | */ |
21 | #include "../../SDL_internal.h" |
22 | |
23 | #ifdef HAVE_IBUS_IBUS_H |
24 | #include "SDL.h" |
25 | #include "SDL_syswm.h" |
26 | #include "SDL_ibus.h" |
27 | #include "SDL_dbus.h" |
28 | #include "../../video/SDL_sysvideo.h" |
29 | #include "../../events/SDL_keyboard_c.h" |
30 | |
31 | #if SDL_VIDEO_DRIVER_X11 |
32 | #include "../../video/x11/SDL_x11video.h" |
33 | #endif |
34 | |
35 | #include <sys/inotify.h> |
36 | #include <unistd.h> |
37 | #include <fcntl.h> |
38 | |
39 | static const char IBUS_SERVICE[] = "org.freedesktop.IBus" ; |
40 | static const char IBUS_PATH[] = "/org/freedesktop/IBus" ; |
41 | static const char IBUS_INTERFACE[] = "org.freedesktop.IBus" ; |
42 | static const char IBUS_INPUT_INTERFACE[] = "org.freedesktop.IBus.InputContext" ; |
43 | |
44 | static char *input_ctx_path = NULL; |
45 | static SDL_Rect ibus_cursor_rect = { 0, 0, 0, 0 }; |
46 | static DBusConnection *ibus_conn = NULL; |
47 | static char *ibus_addr_file = NULL; |
48 | static int inotify_fd = -1, inotify_wd = -1; |
49 | |
50 | static Uint32 |
51 | IBus_ModState(void) |
52 | { |
53 | Uint32 ibus_mods = 0; |
54 | SDL_Keymod sdl_mods = SDL_GetModState(); |
55 | |
56 | /* Not sure about MOD3, MOD4 and HYPER mappings */ |
57 | if (sdl_mods & KMOD_LSHIFT) ibus_mods |= IBUS_SHIFT_MASK; |
58 | if (sdl_mods & KMOD_CAPS) ibus_mods |= IBUS_LOCK_MASK; |
59 | if (sdl_mods & KMOD_LCTRL) ibus_mods |= IBUS_CONTROL_MASK; |
60 | if (sdl_mods & KMOD_LALT) ibus_mods |= IBUS_MOD1_MASK; |
61 | if (sdl_mods & KMOD_NUM) ibus_mods |= IBUS_MOD2_MASK; |
62 | if (sdl_mods & KMOD_MODE) ibus_mods |= IBUS_MOD5_MASK; |
63 | if (sdl_mods & KMOD_LGUI) ibus_mods |= IBUS_SUPER_MASK; |
64 | if (sdl_mods & KMOD_RGUI) ibus_mods |= IBUS_META_MASK; |
65 | |
66 | return ibus_mods; |
67 | } |
68 | |
69 | static const char * |
70 | IBus_GetVariantText(DBusConnection *conn, DBusMessageIter *iter, SDL_DBusContext *dbus) |
71 | { |
72 | /* The text we need is nested weirdly, use dbus-monitor to see the structure better */ |
73 | const char *text = NULL; |
74 | const char *struct_id = NULL; |
75 | DBusMessageIter sub1, sub2; |
76 | |
77 | if (dbus->message_iter_get_arg_type(iter) != DBUS_TYPE_VARIANT) { |
78 | return NULL; |
79 | } |
80 | |
81 | dbus->message_iter_recurse(iter, &sub1); |
82 | |
83 | if (dbus->message_iter_get_arg_type(&sub1) != DBUS_TYPE_STRUCT) { |
84 | return NULL; |
85 | } |
86 | |
87 | dbus->message_iter_recurse(&sub1, &sub2); |
88 | |
89 | if (dbus->message_iter_get_arg_type(&sub2) != DBUS_TYPE_STRING) { |
90 | return NULL; |
91 | } |
92 | |
93 | dbus->message_iter_get_basic(&sub2, &struct_id); |
94 | if (!struct_id || SDL_strncmp(struct_id, "IBusText" , sizeof("IBusText" )) != 0) { |
95 | return NULL; |
96 | } |
97 | |
98 | dbus->message_iter_next(&sub2); |
99 | dbus->message_iter_next(&sub2); |
100 | |
101 | if (dbus->message_iter_get_arg_type(&sub2) != DBUS_TYPE_STRING) { |
102 | return NULL; |
103 | } |
104 | |
105 | dbus->message_iter_get_basic(&sub2, &text); |
106 | |
107 | return text; |
108 | } |
109 | |
110 | static DBusHandlerResult |
111 | IBus_MessageHandler(DBusConnection *conn, DBusMessage *msg, void *user_data) |
112 | { |
113 | SDL_DBusContext *dbus = (SDL_DBusContext *)user_data; |
114 | |
115 | if (dbus->message_is_signal(msg, IBUS_INPUT_INTERFACE, "CommitText" )) { |
116 | DBusMessageIter iter; |
117 | const char *text; |
118 | |
119 | dbus->message_iter_init(msg, &iter); |
120 | |
121 | text = IBus_GetVariantText(conn, &iter, dbus); |
122 | if (text && *text) { |
123 | char buf[SDL_TEXTINPUTEVENT_TEXT_SIZE]; |
124 | size_t text_bytes = SDL_strlen(text), i = 0; |
125 | |
126 | while (i < text_bytes) { |
127 | size_t sz = SDL_utf8strlcpy(buf, text+i, sizeof(buf)); |
128 | SDL_SendKeyboardText(buf); |
129 | |
130 | i += sz; |
131 | } |
132 | } |
133 | |
134 | return DBUS_HANDLER_RESULT_HANDLED; |
135 | } |
136 | |
137 | if (dbus->message_is_signal(msg, IBUS_INPUT_INTERFACE, "UpdatePreeditText" )) { |
138 | DBusMessageIter iter; |
139 | const char *text; |
140 | |
141 | dbus->message_iter_init(msg, &iter); |
142 | text = IBus_GetVariantText(conn, &iter, dbus); |
143 | |
144 | if (text) { |
145 | char buf[SDL_TEXTEDITINGEVENT_TEXT_SIZE]; |
146 | size_t text_bytes = SDL_strlen(text), i = 0; |
147 | size_t cursor = 0; |
148 | |
149 | do { |
150 | const size_t sz = SDL_utf8strlcpy(buf, text+i, sizeof(buf)); |
151 | const size_t chars = SDL_utf8strlen(buf); |
152 | |
153 | SDL_SendEditingText(buf, cursor, chars); |
154 | |
155 | i += sz; |
156 | cursor += chars; |
157 | } while (i < text_bytes); |
158 | } |
159 | |
160 | SDL_IBus_UpdateTextRect(NULL); |
161 | |
162 | return DBUS_HANDLER_RESULT_HANDLED; |
163 | } |
164 | |
165 | if (dbus->message_is_signal(msg, IBUS_INPUT_INTERFACE, "HidePreeditText" )) { |
166 | SDL_SendEditingText("" , 0, 0); |
167 | return DBUS_HANDLER_RESULT_HANDLED; |
168 | } |
169 | |
170 | return DBUS_HANDLER_RESULT_NOT_YET_HANDLED; |
171 | } |
172 | |
173 | static char * |
174 | IBus_ReadAddressFromFile(const char *file_path) |
175 | { |
176 | char addr_buf[1024]; |
177 | SDL_bool success = SDL_FALSE; |
178 | FILE *addr_file; |
179 | |
180 | addr_file = fopen(file_path, "r" ); |
181 | if (!addr_file) { |
182 | return NULL; |
183 | } |
184 | |
185 | while (fgets(addr_buf, sizeof(addr_buf), addr_file)) { |
186 | if (SDL_strncmp(addr_buf, "IBUS_ADDRESS=" , sizeof("IBUS_ADDRESS=" )-1) == 0) { |
187 | size_t sz = SDL_strlen(addr_buf); |
188 | if (addr_buf[sz-1] == '\n') addr_buf[sz-1] = 0; |
189 | if (addr_buf[sz-2] == '\r') addr_buf[sz-2] = 0; |
190 | success = SDL_TRUE; |
191 | break; |
192 | } |
193 | } |
194 | |
195 | fclose(addr_file); |
196 | |
197 | if (success) { |
198 | return SDL_strdup(addr_buf + (sizeof("IBUS_ADDRESS=" ) - 1)); |
199 | } else { |
200 | return NULL; |
201 | } |
202 | } |
203 | |
204 | static char * |
205 | IBus_GetDBusAddressFilename(void) |
206 | { |
207 | SDL_DBusContext *dbus; |
208 | const char *disp_env; |
209 | char config_dir[PATH_MAX]; |
210 | char *display = NULL; |
211 | const char *addr; |
212 | const char *conf_env; |
213 | char *key; |
214 | char file_path[PATH_MAX]; |
215 | const char *host; |
216 | char *disp_num, *screen_num; |
217 | |
218 | if (ibus_addr_file) { |
219 | return SDL_strdup(ibus_addr_file); |
220 | } |
221 | |
222 | dbus = SDL_DBus_GetContext(); |
223 | if (!dbus) { |
224 | return NULL; |
225 | } |
226 | |
227 | /* Use this environment variable if it exists. */ |
228 | addr = SDL_getenv("IBUS_ADDRESS" ); |
229 | if (addr && *addr) { |
230 | return SDL_strdup(addr); |
231 | } |
232 | |
233 | /* Otherwise, we have to get the hostname, display, machine id, config dir |
234 | and look up the address from a filepath using all those bits, eek. */ |
235 | disp_env = SDL_getenv("DISPLAY" ); |
236 | |
237 | if (!disp_env || !*disp_env) { |
238 | display = SDL_strdup(":0.0" ); |
239 | } else { |
240 | display = SDL_strdup(disp_env); |
241 | } |
242 | |
243 | host = display; |
244 | disp_num = SDL_strrchr(display, ':'); |
245 | screen_num = SDL_strrchr(display, '.'); |
246 | |
247 | if (!disp_num) { |
248 | SDL_free(display); |
249 | return NULL; |
250 | } |
251 | |
252 | *disp_num = 0; |
253 | disp_num++; |
254 | |
255 | if (screen_num) { |
256 | *screen_num = 0; |
257 | } |
258 | |
259 | if (!*host) { |
260 | const char *session = SDL_getenv("XDG_SESSION_TYPE" ); |
261 | if (session != NULL && SDL_strcmp(session, "wayland" ) == 0) { |
262 | host = "unix-wayland" ; |
263 | } else { |
264 | host = "unix" ; |
265 | } |
266 | } |
267 | |
268 | SDL_memset(config_dir, 0, sizeof(config_dir)); |
269 | |
270 | conf_env = SDL_getenv("XDG_CONFIG_HOME" ); |
271 | if (conf_env && *conf_env) { |
272 | SDL_strlcpy(config_dir, conf_env, sizeof(config_dir)); |
273 | } else { |
274 | const char *home_env = SDL_getenv("HOME" ); |
275 | if (!home_env || !*home_env) { |
276 | SDL_free(display); |
277 | return NULL; |
278 | } |
279 | SDL_snprintf(config_dir, sizeof(config_dir), "%s/.config" , home_env); |
280 | } |
281 | |
282 | key = dbus->get_local_machine_id(); |
283 | |
284 | SDL_memset(file_path, 0, sizeof(file_path)); |
285 | SDL_snprintf(file_path, sizeof(file_path), "%s/ibus/bus/%s-%s-%s" , |
286 | config_dir, key, host, disp_num); |
287 | dbus->free(key); |
288 | SDL_free(display); |
289 | |
290 | return SDL_strdup(file_path); |
291 | } |
292 | |
293 | static SDL_bool IBus_CheckConnection(SDL_DBusContext *dbus); |
294 | |
295 | static void SDLCALL |
296 | IBus_SetCapabilities(void *data, const char *name, const char *old_val, |
297 | const char *internal_editing) |
298 | { |
299 | SDL_DBusContext *dbus = SDL_DBus_GetContext(); |
300 | |
301 | if (IBus_CheckConnection(dbus)) { |
302 | Uint32 caps = IBUS_CAP_FOCUS; |
303 | if (!(internal_editing && *internal_editing == '1')) { |
304 | caps |= IBUS_CAP_PREEDIT_TEXT; |
305 | } |
306 | |
307 | SDL_DBus_CallVoidMethodOnConnection(ibus_conn, IBUS_SERVICE, input_ctx_path, IBUS_INPUT_INTERFACE, "SetCapabilities" , |
308 | DBUS_TYPE_UINT32, &caps, DBUS_TYPE_INVALID); |
309 | } |
310 | } |
311 | |
312 | |
313 | static SDL_bool |
314 | IBus_SetupConnection(SDL_DBusContext *dbus, const char* addr) |
315 | { |
316 | const char *client_name = "SDL2_Application" ; |
317 | const char *path = NULL; |
318 | SDL_bool result = SDL_FALSE; |
319 | DBusObjectPathVTable ibus_vtable; |
320 | |
321 | SDL_zero(ibus_vtable); |
322 | ibus_vtable.message_function = &IBus_MessageHandler; |
323 | |
324 | ibus_conn = dbus->connection_open_private(addr, NULL); |
325 | |
326 | if (!ibus_conn) { |
327 | return SDL_FALSE; |
328 | } |
329 | |
330 | dbus->connection_flush(ibus_conn); |
331 | |
332 | if (!dbus->bus_register(ibus_conn, NULL)) { |
333 | ibus_conn = NULL; |
334 | return SDL_FALSE; |
335 | } |
336 | |
337 | dbus->connection_flush(ibus_conn); |
338 | |
339 | if (SDL_DBus_CallMethodOnConnection(ibus_conn, IBUS_SERVICE, IBUS_PATH, IBUS_INTERFACE, "CreateInputContext" , |
340 | DBUS_TYPE_STRING, &client_name, DBUS_TYPE_INVALID, |
341 | DBUS_TYPE_OBJECT_PATH, &path, DBUS_TYPE_INVALID)) { |
342 | SDL_free(input_ctx_path); |
343 | input_ctx_path = SDL_strdup(path); |
344 | SDL_AddHintCallback(SDL_HINT_IME_INTERNAL_EDITING, IBus_SetCapabilities, NULL); |
345 | |
346 | dbus->bus_add_match(ibus_conn, "type='signal',interface='org.freedesktop.IBus.InputContext'" , NULL); |
347 | dbus->connection_try_register_object_path(ibus_conn, input_ctx_path, &ibus_vtable, dbus, NULL); |
348 | dbus->connection_flush(ibus_conn); |
349 | result = SDL_TRUE; |
350 | } |
351 | |
352 | SDL_IBus_SetFocus(SDL_GetKeyboardFocus() != NULL); |
353 | SDL_IBus_UpdateTextRect(NULL); |
354 | |
355 | return result; |
356 | } |
357 | |
358 | static SDL_bool |
359 | IBus_CheckConnection(SDL_DBusContext *dbus) |
360 | { |
361 | if (!dbus) return SDL_FALSE; |
362 | |
363 | if (ibus_conn && dbus->connection_get_is_connected(ibus_conn)) { |
364 | return SDL_TRUE; |
365 | } |
366 | |
367 | if (inotify_fd > 0 && inotify_wd > 0) { |
368 | char buf[1024]; |
369 | ssize_t readsize = read(inotify_fd, buf, sizeof(buf)); |
370 | if (readsize > 0) { |
371 | |
372 | char *p; |
373 | SDL_bool file_updated = SDL_FALSE; |
374 | |
375 | for (p = buf; p < buf + readsize; /**/) { |
376 | struct inotify_event *event = (struct inotify_event*) p; |
377 | if (event->len > 0) { |
378 | char *addr_file_no_path = SDL_strrchr(ibus_addr_file, '/'); |
379 | if (!addr_file_no_path) return SDL_FALSE; |
380 | |
381 | if (SDL_strcmp(addr_file_no_path + 1, event->name) == 0) { |
382 | file_updated = SDL_TRUE; |
383 | break; |
384 | } |
385 | } |
386 | |
387 | p += sizeof(struct inotify_event) + event->len; |
388 | } |
389 | |
390 | if (file_updated) { |
391 | char *addr = IBus_ReadAddressFromFile(ibus_addr_file); |
392 | if (addr) { |
393 | SDL_bool result = IBus_SetupConnection(dbus, addr); |
394 | SDL_free(addr); |
395 | return result; |
396 | } |
397 | } |
398 | } |
399 | } |
400 | |
401 | return SDL_FALSE; |
402 | } |
403 | |
404 | SDL_bool |
405 | SDL_IBus_Init(void) |
406 | { |
407 | SDL_bool result = SDL_FALSE; |
408 | SDL_DBusContext *dbus = SDL_DBus_GetContext(); |
409 | |
410 | if (dbus) { |
411 | char *addr_file = IBus_GetDBusAddressFilename(); |
412 | char *addr; |
413 | char *addr_file_dir; |
414 | |
415 | if (!addr_file) { |
416 | return SDL_FALSE; |
417 | } |
418 | |
419 | /* !!! FIXME: if ibus_addr_file != NULL, this will overwrite it and leak (twice!) */ |
420 | ibus_addr_file = SDL_strdup(addr_file); |
421 | |
422 | addr = IBus_ReadAddressFromFile(addr_file); |
423 | if (!addr) { |
424 | SDL_free(addr_file); |
425 | return SDL_FALSE; |
426 | } |
427 | |
428 | if (inotify_fd < 0) { |
429 | inotify_fd = inotify_init(); |
430 | fcntl(inotify_fd, F_SETFL, O_NONBLOCK); |
431 | } |
432 | |
433 | addr_file_dir = SDL_strrchr(addr_file, '/'); |
434 | if (addr_file_dir) { |
435 | *addr_file_dir = 0; |
436 | } |
437 | |
438 | inotify_wd = inotify_add_watch(inotify_fd, addr_file, IN_CREATE | IN_MODIFY); |
439 | SDL_free(addr_file); |
440 | |
441 | if (addr) { |
442 | result = IBus_SetupConnection(dbus, addr); |
443 | SDL_free(addr); |
444 | } |
445 | } |
446 | |
447 | return result; |
448 | } |
449 | |
450 | void |
451 | SDL_IBus_Quit(void) |
452 | { |
453 | SDL_DBusContext *dbus; |
454 | |
455 | if (input_ctx_path) { |
456 | SDL_free(input_ctx_path); |
457 | input_ctx_path = NULL; |
458 | } |
459 | |
460 | if (ibus_addr_file) { |
461 | SDL_free(ibus_addr_file); |
462 | ibus_addr_file = NULL; |
463 | } |
464 | |
465 | dbus = SDL_DBus_GetContext(); |
466 | |
467 | if (dbus && ibus_conn) { |
468 | dbus->connection_close(ibus_conn); |
469 | dbus->connection_unref(ibus_conn); |
470 | } |
471 | |
472 | if (inotify_fd > 0 && inotify_wd > 0) { |
473 | inotify_rm_watch(inotify_fd, inotify_wd); |
474 | inotify_wd = -1; |
475 | } |
476 | |
477 | SDL_DelHintCallback(SDL_HINT_IME_INTERNAL_EDITING, IBus_SetCapabilities, NULL); |
478 | |
479 | SDL_memset(&ibus_cursor_rect, 0, sizeof(ibus_cursor_rect)); |
480 | } |
481 | |
482 | static void |
483 | IBus_SimpleMessage(const char *method) |
484 | { |
485 | SDL_DBusContext *dbus = SDL_DBus_GetContext(); |
486 | |
487 | if ((input_ctx_path != NULL) && (IBus_CheckConnection(dbus))) { |
488 | SDL_DBus_CallVoidMethodOnConnection(ibus_conn, IBUS_SERVICE, input_ctx_path, IBUS_INPUT_INTERFACE, method, DBUS_TYPE_INVALID); |
489 | } |
490 | } |
491 | |
492 | void |
493 | SDL_IBus_SetFocus(SDL_bool focused) |
494 | { |
495 | const char *method = focused ? "FocusIn" : "FocusOut" ; |
496 | IBus_SimpleMessage(method); |
497 | } |
498 | |
499 | void |
500 | SDL_IBus_Reset(void) |
501 | { |
502 | IBus_SimpleMessage("Reset" ); |
503 | } |
504 | |
505 | SDL_bool |
506 | SDL_IBus_ProcessKeyEvent(Uint32 keysym, Uint32 keycode) |
507 | { |
508 | Uint32 result = 0; |
509 | SDL_DBusContext *dbus = SDL_DBus_GetContext(); |
510 | |
511 | if (IBus_CheckConnection(dbus)) { |
512 | Uint32 mods = IBus_ModState(); |
513 | if (!SDL_DBus_CallMethodOnConnection(ibus_conn, IBUS_SERVICE, input_ctx_path, IBUS_INPUT_INTERFACE, "ProcessKeyEvent" , |
514 | DBUS_TYPE_UINT32, &keysym, DBUS_TYPE_UINT32, &keycode, DBUS_TYPE_UINT32, &mods, DBUS_TYPE_INVALID, |
515 | DBUS_TYPE_BOOLEAN, &result, DBUS_TYPE_INVALID)) { |
516 | result = 0; |
517 | } |
518 | } |
519 | |
520 | SDL_IBus_UpdateTextRect(NULL); |
521 | |
522 | return result ? SDL_TRUE : SDL_FALSE; |
523 | } |
524 | |
525 | void |
526 | SDL_IBus_UpdateTextRect(SDL_Rect *rect) |
527 | { |
528 | SDL_Window *focused_win; |
529 | SDL_SysWMinfo info; |
530 | int x = 0, y = 0; |
531 | SDL_DBusContext *dbus; |
532 | |
533 | if (rect) { |
534 | SDL_memcpy(&ibus_cursor_rect, rect, sizeof(ibus_cursor_rect)); |
535 | } |
536 | |
537 | focused_win = SDL_GetKeyboardFocus(); |
538 | if (!focused_win) { |
539 | return; |
540 | } |
541 | |
542 | SDL_VERSION(&info.version); |
543 | if (!SDL_GetWindowWMInfo(focused_win, &info)) { |
544 | return; |
545 | } |
546 | |
547 | SDL_GetWindowPosition(focused_win, &x, &y); |
548 | |
549 | #if SDL_VIDEO_DRIVER_X11 |
550 | if (info.subsystem == SDL_SYSWM_X11) { |
551 | SDL_DisplayData *displaydata = (SDL_DisplayData *) SDL_GetDisplayForWindow(focused_win)->driverdata; |
552 | |
553 | Display *x_disp = info.info.x11.display; |
554 | Window x_win = info.info.x11.window; |
555 | int x_screen = displaydata->screen; |
556 | Window unused; |
557 | |
558 | X11_XTranslateCoordinates(x_disp, x_win, RootWindow(x_disp, x_screen), 0, 0, &x, &y, &unused); |
559 | } |
560 | #endif |
561 | |
562 | x += ibus_cursor_rect.x; |
563 | y += ibus_cursor_rect.y; |
564 | |
565 | dbus = SDL_DBus_GetContext(); |
566 | |
567 | if (IBus_CheckConnection(dbus)) { |
568 | SDL_DBus_CallVoidMethodOnConnection(ibus_conn, IBUS_SERVICE, input_ctx_path, IBUS_INPUT_INTERFACE, "SetCursorLocation" , |
569 | DBUS_TYPE_INT32, &x, DBUS_TYPE_INT32, &y, DBUS_TYPE_INT32, &ibus_cursor_rect.w, DBUS_TYPE_INT32, &ibus_cursor_rect.h, DBUS_TYPE_INVALID); |
570 | } |
571 | } |
572 | |
573 | void |
574 | SDL_IBus_PumpEvents(void) |
575 | { |
576 | SDL_DBusContext *dbus = SDL_DBus_GetContext(); |
577 | |
578 | if (IBus_CheckConnection(dbus)) { |
579 | dbus->connection_read_write(ibus_conn, 0); |
580 | |
581 | while (dbus->connection_dispatch(ibus_conn) == DBUS_DISPATCH_DATA_REMAINS) { |
582 | /* Do nothing, actual work happens in IBus_MessageHandler */ |
583 | } |
584 | } |
585 | } |
586 | |
587 | #endif |
588 | |
589 | /* vi: set ts=4 sw=4 expandtab: */ |
590 | |