fvwm-window-search 1.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (13) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +13 -1
  3. data/README.md +39 -15
  4. data/activate.c +163 -0
  5. data/activate.sh +12 -0
  6. data/dmenu.patch +70 -19
  7. data/fontinfo.c +34 -0
  8. data/fvwm-window-search +112 -31
  9. data/lib.c +60 -0
  10. data/winlist.c +120 -0
  11. metadata +23 -13
  12. data/focus.sh +0 -7
  13. data/lib.rb +0 -105
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1365fa4326358bdf06efd23fa5e8a42348dfad1a4acae24f169fa603c0c6f8c2
4
- data.tar.gz: da1fc15018240a4d37bad24726e81f60cfe44232e7e23b172d30c7d5d384d91c
3
+ metadata.gz: 3e0b567a71daca6fd7780719a9228cd34a63f9cdfbf084a4b4c854f13b60c103
4
+ data.tar.gz: 8b274620b6a4ba391ad4127c91f63bfff85057c971c0678c4850b1a48cdb287b
5
5
  SHA512:
6
- metadata.gz: 2bd53629fa900f2c1b30e6cd02a4de7db3bde4676ec5b0c5dcef9c5ef9a26c3c174cce33a58470060643da89532fccb478609110d31fdc7faeb1aab765a8706a
7
- data.tar.gz: 0ecc94c5b3161697d3f71cf02e20ef06d70b4d878c5b224b6f3737335aa494bd9b6bb5637c97e953b36169f6b67a13aeaff916b5885806649554b3f18590286c
6
+ metadata.gz: b354e8b6cfe44214474c39f54ba10a931835c272ced4b315abf7872cd4c33017a6a146b02c4699b4fe67c633a1fb25d45ddc340f1f1f81e7d8af5d9fd3a92a9e
7
+ data.tar.gz: a41ae5a0816b84a33149b60ca3a2a30556ec7ea443acd07b57a4a258c5276a5797141ba0c2a7cf93fd53d44e3f01ec3f68b16edb4be2a32c1daefc35dc6a68c3
data/Makefile CHANGED
@@ -1,6 +1,8 @@
1
1
  out := _out
2
2
  dmenu := $(out)/dmenu
3
- dmenu.commit := 9b38fda6feda68f95754d5b8932b1a69471df960
3
+ dmenu.commit := 1a13d0465d1a6f4f74bc5b07b04c9bd542f20ba6
4
+
5
+ all: $(addprefix $(out)/, .dmenu.build activate winlist fontinfo)
4
6
 
5
7
  $(out)/.dmenu.build: $(out)/.dmenu.$(dmenu.commit) dmenu.patch
6
8
  patch -d $(dmenu) -p1 < dmenu.patch
@@ -12,5 +14,15 @@ $(out)/.dmenu.$(dmenu.commit):
12
14
  git -C $(dmenu) checkout $(dmenu.commit) -q
13
15
  touch $@
14
16
 
17
+ libs := x11
18
+ LDFLAGS = $(shell pkg-config --libs $(libs))
19
+ CFLAGS = -g -Wall -Werror $(shell pkg-config --cflags $(libs))
20
+ $(out)/%: %.c lib.c
21
+ $(LINK.c) $< $(LOADLIBES) $(LDLIBS) -o $@
22
+
23
+ $(out)/activate: libs += jansson
24
+ $(out)/winlist: libs += jansson
25
+ $(out)/fontinfo: libs += xft freetype2
26
+
15
27
  # an empty target to satisfy rubygems
16
28
  install:
data/README.md CHANGED
@@ -5,24 +5,35 @@ Incremental window search & immediate switch to the selected window
5
5
 
6
6
  $ gem install fvwm-window-search
7
7
 
8
- ![A screenshot of running fvwm-window-search](https://raw.github.com/gromnitsky/fvwm-window-search/master/screnshot1.png)
8
+ ![demo](https://thumbs.gfycat.com/GenerousRingedFlicker-small.gif)
9
9
 
10
- * Should work w/ any X11 window manager.
11
- * Filtering by windows names/resources/classes.
10
+ * Should work w/ most EWMH-compliant stackings X11 window managers.
11
+ * Filter by window name/resource/class.
12
+ * Optionally list windows from the current desktop only.
13
+ * Preserve minimised/shaded window states.
12
14
 
13
15
  ## Reqs
14
16
 
15
- * Ruby (tested w/ 2.7.0)
16
- * `xwininfo` & `xdotool` (`xorg-x11-utils` & `xdotool` Fedora pkgs)
17
+ * Ruby 2.1+
18
+ * `dnf install jansson-devel freetype-devel`
17
19
 
18
20
  ## Compilation
19
21
 
20
- Type `make`. This clones the dmenu repo, patches & builds it. It does
21
- not contravene w/ a system-installed dmenu.
22
+ Type `make`. This clones the dmenu repo, patches & builds it. It
23
+ doesn't interfere w/ a system-installed dmenu.
22
24
 
23
25
  ## Usage
24
26
 
25
- Run `fvwm-window-search`.
27
+ ~~~
28
+ $ ./fvwm-window-search -h
29
+ Usage: fvwm-window-search [options]
30
+ -c path an alternative path to conf.yaml
31
+ -d list windows from the current desktop only
32
+ -r switch to a window only when <Return> is pressed
33
+ ~~~
34
+
35
+ To scroll in dmenu (using Up/Down/Home/End/PgUp/PgDown) without
36
+ windows activation, hold <kbd>Shift</kbd>.
26
37
 
27
38
  To customise dmenu or filtering, create a yaml file
28
39
  `$XDG_CONFIG_HOME/fvwm-window-search/conf.yaml`, e.g.:
@@ -32,19 +43,32 @@ To customise dmenu or filtering, create a yaml file
32
43
  dmenu:
33
44
  fn: Monospace-12
34
45
  b: false
35
- filter:
46
+ selection_hook_activation_return_key_only: true
47
+ filter-out:
36
48
  name: ['System Monitor']
49
+ resource: []
50
+ class: []
37
51
  ~~~
38
52
 
39
- This passes to dmenu `-fn` & `-b` CLOs & instructs to filter out any
40
- window that matches `System Monitor` regexp in its title.
53
+ Subkeys in `dmenu` are the usual CLOs for
54
+ [dmenu(1)][]. `selection_hook_activation_return_key_only` is an
55
+ equivalent of `-r` CLO.
56
+
57
+ [dmenu(1)]: https://manpages.debian.org/unstable/suckless-tools/dmenu.1.en.html
58
+
59
+ `filter-out` key tells what windows should be ignored. Each value in a
60
+ subkey is an array of regexes. See the defaults at the top of
61
+ `fvwm-window-search` file.
41
62
 
42
- See the defaults in `fvwm-window-search` file.
63
+ ## Start-up time
43
64
 
44
- ## Bugs
65
+ As a task switcher, the program must not only run fast, but also
66
+ *start* fast. I managed to get it under 70ms on my laptop, when you
67
+ run `./fvwm-window-search` directly from the repo.
45
68
 
46
- * Tested only w/ Fvwm3.
47
- * No distinction between normal & iconified windows.
69
+ This is not the case with rubygems! The latter generates a stub script
70
+ that invokes `./fvwm-window-search` file. This indirection may add
71
+ ~140ms of additional delay.
48
72
 
49
73
  ## License
50
74
 
data/activate.c ADDED
@@ -0,0 +1,163 @@
1
+ #include <stdlib.h>
2
+ #include <err.h>
3
+ #include <stdio.h>
4
+ #include <stdbool.h>
5
+ #include <unistd.h>
6
+ #include <fcntl.h>
7
+ #include <string.h>
8
+ #include <limits.h>
9
+ #include <libgen.h>
10
+ #include <sys/stat.h>
11
+ #include <errno.h>
12
+
13
+ #include <X11/Xlib.h>
14
+ #include <X11/Xatom.h>
15
+ #include <jansson.h>
16
+
17
+ #include "lib.c"
18
+
19
+ ulong str2id(const char *s) {
20
+ ulong id;
21
+ if (sscanf(s, "0x%lx", &id) != 1 &&
22
+ sscanf(s, "0X%lx", &id) != 1 &&
23
+ sscanf(s, "%lu", &id) != 1) return 0;
24
+ return id;
25
+ }
26
+
27
+ bool client_msg(Display *dpy, Window id, const char *msg,
28
+ unsigned long data0, unsigned long data1,
29
+ unsigned long data2, unsigned long data3,
30
+ unsigned long data4) {
31
+ XEvent event;
32
+ long mask = SubstructureRedirectMask | SubstructureNotifyMask;
33
+
34
+ event.xclient.type = ClientMessage;
35
+ event.xclient.serial = 0;
36
+ event.xclient.send_event = True;
37
+ event.xclient.message_type = XInternAtom(dpy, msg, False);
38
+ event.xclient.window = id;
39
+ event.xclient.format = 32;
40
+ event.xclient.data.l[0] = data0;
41
+ event.xclient.data.l[1] = data1;
42
+ event.xclient.data.l[2] = data2;
43
+ event.xclient.data.l[3] = data3;
44
+ event.xclient.data.l[4] = data4;
45
+
46
+ if (XSendEvent(dpy, DefaultRootWindow(dpy), False, mask, &event))
47
+ return true;
48
+ warnx("cannot send %s event", msg);
49
+ return false;
50
+ }
51
+
52
+ bool window_activate(Display *dpy, Window id) {
53
+ long desk = desktop(dpy, id);
54
+ if (-1 != desk) {
55
+ client_msg(dpy, DefaultRootWindow(dpy), "_NET_CURRENT_DESKTOP",
56
+ desk, 0, 0, 0, 0);
57
+ }
58
+
59
+ bool active = client_msg(dpy, id, "_NET_ACTIVE_WINDOW", 0, 0, 0, 0, 0);
60
+
61
+ const int _net_wm_state_rm = 0;
62
+ bool unshaded = client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_rm,
63
+ myAtoms._NET_WM_STATE_SHADED, 0, 0, 0);
64
+
65
+ XMapRaised(dpy, id);
66
+ return active && unshaded;
67
+ }
68
+
69
+ bool window_center_mouse(Display *dpy, ulong id) {
70
+ XWindowAttributes attrs;
71
+ if (!XGetWindowAttributes(dpy, id, &attrs)) return false;
72
+ if (!XWarpPointer(dpy, 0, id, 0, 0, 0, 0, attrs.width/2, attrs.height/2))
73
+ return false;
74
+ XFlush(dpy);
75
+ return true;
76
+ }
77
+
78
+ // the result shout be freed
79
+ char* config() {
80
+ char xdg_runtime_home[PATH_MAX-64];
81
+ if (getenv("XDG_RUNTIME_HOME")) {
82
+ snprintf(xdg_runtime_home, PATH_MAX-64, "%s", getenv("XDG_RUNTIME_HOME"));
83
+ } else {
84
+ snprintf(xdg_runtime_home, PATH_MAX-64, "/run/user/%d", getuid());
85
+ }
86
+ char *file = (char*)malloc(PATH_MAX);
87
+ snprintf(file, PATH_MAX, "%s/%s/%s",
88
+ xdg_runtime_home, "fvwm-window-search", "last_window.json");
89
+
90
+ char *dir = dirname(strdup(file));
91
+ mkdir(xdg_runtime_home, 0755);
92
+ int r = mkdir(dir, 0755); if (-1 == r && EEXIST != errno) {
93
+ warn("failed to create %s", dir);
94
+ return NULL;
95
+ }
96
+ free(dir);
97
+ return file;
98
+ }
99
+
100
+ void state_save(Display *dpy, Window id) {
101
+ char *file = config();
102
+ int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0644); if (-1 == fd) {
103
+ warn("failed to truncate %s", file);
104
+ return;
105
+ }
106
+ free(file);
107
+
108
+ WindowState ws = state(dpy, id);
109
+ json_t *o = json_object();
110
+ json_object_set_new(o, "id", json_integer(ws.id));
111
+ json_object_set_new(o, "_NET_WM_STATE_SHADED", json_boolean(ws._NET_WM_STATE_SHADED));
112
+ json_object_set_new(o, "_NET_WM_STATE_HIDDEN", json_boolean(ws._NET_WM_STATE_HIDDEN));
113
+
114
+ char *dump = json_dumps(o, JSON_COMPACT);
115
+ write(fd, dump, strlen(dump));
116
+ free(dump);
117
+ json_decref(o);
118
+
119
+ close(fd);
120
+ }
121
+
122
+ Window state_load(Display *dpy, Window id_current) {
123
+ char *file = config();
124
+ json_t *root = json_load_file(file, 0, NULL);
125
+ free(file);
126
+ if (!root) return 0;
127
+
128
+ Window id = json_integer_value(json_object_get(root, "id"));
129
+ if (id == id_current) return id;
130
+
131
+ const int _net_wm_state_add = 1;
132
+ bool is_shaded = json_boolean_value(json_object_get(root, "_NET_WM_STATE_SHADED"));
133
+ if (is_shaded) client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_add,
134
+ myAtoms._NET_WM_STATE_SHADED, 0, 0, 0);
135
+ bool is_hidden = json_boolean_value(json_object_get(root, "_NET_WM_STATE_HIDDEN"));
136
+ if (is_hidden) client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_add,
137
+ myAtoms._NET_WM_STATE_HIDDEN, 0, 0, 0);
138
+
139
+ json_decref(root);
140
+ return id;
141
+ }
142
+
143
+
144
+
145
+ int main(int argc, char **argv) {
146
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
147
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
148
+ if (argc != 2) errx(1, "usage: activate window-id");
149
+
150
+ mk_atoms(dpy);
151
+
152
+ ulong id = str2id(argv[1]);
153
+ if (!id) errx(1, "invalid window id: `%s`", argv[1]);
154
+
155
+ Window prev_id = state_load(dpy, id);
156
+ if (prev_id != id) state_save(dpy, id);
157
+
158
+ XSynchronize(dpy, True); // snake oil?
159
+ bool r = window_activate(dpy, id);
160
+ if (!r) return 1;
161
+ r = window_center_mouse(dpy, id);
162
+ return !r;
163
+ }
data/activate.sh ADDED
@@ -0,0 +1,12 @@
1
+ #!/bin/sh
2
+
3
+ id=`echo "$1" | awk -F'|' '{print $NF} END { exit $NF == "" ? 1 : 0}'` || {
4
+ echo "usage: `basename "$0"` 'foo | bar | id'"
5
+ exit 1
6
+ }
7
+
8
+ __filename=`readlink -f "$0"`
9
+ __dirname=`dirname "${__filename}"`
10
+
11
+ # shellcheck disable=2086
12
+ ${__dirname}/_out/activate $id
data/dmenu.patch CHANGED
@@ -14,18 +14,20 @@ index a03a95c..ee5cffb 100644
14
14
  $(OBJ): arg.h config.h config.mk drw.h
15
15
 
16
16
  diff --git a/config.def.h b/config.def.h
17
- index 1edb647..c6d081f 100644
17
+ index 1edb647..65c831f 100644
18
18
  --- a/config.def.h
19
19
  +++ b/config.def.h
20
- @@ -21,3 +21,6 @@ static unsigned int lines = 0;
20
+ @@ -21,3 +21,8 @@ static unsigned int lines = 0;
21
21
  * for example: " /?\"&[]"
22
22
  */
23
23
  static const char worddelimiters[] = " ";
24
24
  +
25
- +/* -selhook option; run a command on every selection */
25
+ +/* -selection_hook option; run a command on every selection */
26
26
  +static const char *selection_hook = NULL;
27
+ +static int selection_hook_activation = 1;
28
+ +static int selection_hook_activation_return_key_only = 0;
27
29
  diff --git a/dmenu.c b/dmenu.c
28
- index 65f25ce..69254ee 100644
30
+ index 65f25ce..47a6b37 100644
29
31
  --- a/dmenu.c
30
32
  +++ b/dmenu.c
31
33
  @@ -304,6 +304,62 @@ movewordedge(int dir)
@@ -91,7 +93,45 @@ index 65f25ce..69254ee 100644
91
93
  static void
92
94
  keypress(XKeyEvent *ev)
93
95
  {
94
- @@ -464,6 +520,7 @@ insert:
96
+ @@ -410,6 +466,7 @@ insert:
97
+ insert(NULL, nextrune(-1) - cursor);
98
+ break;
99
+ case XK_End:
100
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
101
+ if (text[cursor] != '\0') {
102
+ cursor = strlen(text);
103
+ break;
104
+ @@ -429,6 +486,7 @@ insert:
105
+ cleanup();
106
+ exit(1);
107
+ case XK_Home:
108
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
109
+ if (sel == matches) {
110
+ cursor = 0;
111
+ break;
112
+ @@ -445,18 +503,21 @@ insert:
113
+ return;
114
+ /* fallthrough */
115
+ case XK_Up:
116
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
117
+ if (sel && sel->left && (sel = sel->left)->right == curr) {
118
+ curr = prev;
119
+ calcoffsets();
120
+ }
121
+ break;
122
+ case XK_Next:
123
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
124
+ if (!next)
125
+ return;
126
+ sel = curr = next;
127
+ calcoffsets();
128
+ break;
129
+ case XK_Prior:
130
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
131
+ if (!prev)
132
+ return;
133
+ sel = curr = prev;
134
+ @@ -464,6 +525,7 @@ insert:
95
135
  break;
96
136
  case XK_Return:
97
137
  case XK_KP_Enter:
@@ -99,29 +139,40 @@ index 65f25ce..69254ee 100644
99
139
  puts((sel && !(ev->state & ShiftMask)) ? sel->text : text);
100
140
  if (!(ev->state & ControlMask)) {
101
141
  cleanup();
102
- @@ -572,6 +629,7 @@ run(void)
142
+ @@ -481,6 +543,7 @@ insert:
143
+ return;
144
+ /* fallthrough */
145
+ case XK_Down:
146
+ + if (ev->state & ShiftMask) selection_hook_activation = 0;
147
+ if (sel && sel->right && (sel = sel->right) == next) {
148
+ curr = next;
149
+ calcoffsets();
150
+ @@ -572,6 +635,11 @@ run(void)
103
151
  break;
104
152
  case KeyPress:
105
153
  keypress(&ev.xkey);
106
- + selhook(selection_hook, sel);
154
+ + if (!selection_hook_activation_return_key_only &&
155
+ + selection_hook_activation)
156
+ + selhook(selection_hook, sel);
157
+ +
158
+ + selection_hook_activation = 1;
107
159
  break;
108
160
  case SelectionNotify:
109
161
  if (ev.xselection.property == utf8)
110
- @@ -690,7 +748,8 @@ static void
111
- usage(void)
112
- {
113
- fputs("usage: dmenu [-bfiv] [-l lines] [-p prompt] [-fn font] [-m monitor]\n"
114
- - " [-nb color] [-nf color] [-sb color] [-sf color] [-w windowid]\n", stderr);
115
- + " [-nb color] [-nf color] [-sb color] [-sf color] [-w windowid]\n"
116
- + " [-selhook cmd]\n", stderr);
117
- exit(1);
118
- }
119
-
120
- @@ -733,6 +792,8 @@ main(int argc, char *argv[])
162
+ @@ -712,6 +780,8 @@ main(int argc, char *argv[])
163
+ else if (!strcmp(argv[i], "-i")) { /* case-insensitive item matching */
164
+ fstrncmp = strncasecmp;
165
+ fstrstr = cistrstr;
166
+ + } else if (!strcmp(argv[i], "-selection_hook_activation_return_key_only")) {
167
+ + selection_hook_activation_return_key_only = 1;
168
+ } else if (i + 1 == argc)
169
+ usage();
170
+ /* these options take one argument */
171
+ @@ -733,6 +803,8 @@ main(int argc, char *argv[])
121
172
  colors[SchemeSel][ColFg] = argv[++i];
122
173
  else if (!strcmp(argv[i], "-w")) /* embedding window id */
123
174
  embed = argv[++i];
124
- + else if(!strcmp(argv[i], "-selhook")) /* a command to run */
175
+ + else if (!strcmp(argv[i], "-selection_hook")) /* a command to run */
125
176
  + selection_hook = argv[++i];
126
177
  else
127
178
  usage();
data/fontinfo.c ADDED
@@ -0,0 +1,34 @@
1
+ // Prints a triptych of 'screenWidth charWidth userTextWidth' to stdout.
2
+
3
+ #include <stdbool.h>
4
+ #include <err.h>
5
+ #include <X11/Xft/Xft.h>
6
+ #include <X11/Xatom.h>
7
+
8
+ #include "lib.c"
9
+
10
+ long desktop_width(Display *dpy) {
11
+ u_char *prop_val = NULL;
12
+ ulong prop_size;
13
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_CARDINAL, "_NET_DESKTOP_GEOMETRY", &prop_val, &prop_size))
14
+ return -1;
15
+
16
+ long r = ((long*)prop_val)[0];
17
+ free(prop_val);
18
+ return r;
19
+ }
20
+
21
+ int main(int argc, char **argv) {
22
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
23
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
24
+ if (argc != 3) errx(1, "usage: fontinfo font text-string");
25
+
26
+ XftFont *font = XftFontOpenName(dpy, DefaultScreen(dpy), argv[1]);
27
+ if (!font) errx(1, "no font match");
28
+
29
+ XGlyphInfo info_text, info_char;
30
+ XftTextExtentsUtf8(dpy, font, (FcChar8*)"@", 1, &info_char);
31
+ XftTextExtentsUtf8(dpy, font, (FcChar8*)argv[2], strlen(argv[2]), &info_text);
32
+
33
+ printf("%ld %d %d\n", desktop_width(dpy), info_char.width, info_text.width);
34
+ }
data/fvwm-window-search CHANGED
@@ -1,38 +1,64 @@
1
- #!/usr/bin/env ruby
1
+ #!/usr/bin/env -S ruby --disable-gems
2
+ # coding: utf-8
3
+ # frozen_string_literal: true
2
4
 
3
- require_relative './lib'
4
- include FvwmWindowSearch
5
5
  require 'yaml'
6
-
7
- def options_load
8
- xdg_config_home = ENV['XDG_CONFIG_HOME'] || File.expand_path('~/.config')
9
- conf = File.join xdg_config_home, 'fvwm-window-search', 'conf.yaml'
10
- r = File.read conf rescue nil
11
- YAML.load r, conf rescue errx 1, "invalid config: #{$!}" if r
12
- end
6
+ require 'json'
7
+ require 'optparse'
8
+ require 'shellwords'
13
9
 
14
10
  def options
15
11
  default = {
16
- "dmenu" => { # each key is a corresponding CL option
17
- "selhook" => File.join(__dir__, "focus.sh %s"),
12
+ 'dmenu' => { # each key corresponds to a dmenu CL option
18
13
  "fn" => "Monospace-10",
19
14
  "l" => 8,
20
15
  "b" => true,
21
16
  "i" => true,
17
+ 'selection_hook' => File.join(__dir__, "activate.sh %s"),
18
+ 'selection_hook_activation_return_key_only' => false,
22
19
  },
23
- "filter" => {
20
+ "filter-out" => {
24
21
  "name" => [],
25
22
  "resource" => [],
26
23
  "class" => ['^Fvwm', '!^FvwmIdent$']
27
24
  }
28
25
  }
29
26
 
30
- deep_merge(default, options_load || {})
27
+ args = options_command_line
28
+ file = options_config_file(args) || {}
29
+ deep_merge default, deep_merge(file, args)
30
+ end
31
+
32
+ def options_command_line
33
+ opt = { "dmenu" => {} }
34
+ OptionParser.new do |o|
35
+ o.on("-c path", "an alternative path to conf.yaml") { |v| opt["conf"] = v }
36
+ o.on('-d', 'list windows from the current desktop only') { opt['this_desk_only'] = true }
37
+ o.on("-r", "switch to a window only when <Return> is pressed") do
38
+ opt['dmenu']['selection_hook_activation_return_key_only'] = true
39
+ end
40
+ end.parse!
41
+ opt
42
+ end
43
+
44
+ def options_config_file opt
45
+ file = opt["conf"] || -> do
46
+ xdg_config_home = ENV['XDG_CONFIG_HOME'] || File.expand_path('~/.config')
47
+ File.join xdg_config_home, 'fvwm-window-search', 'conf.yaml'
48
+ end.call
49
+ r = File.read file rescue nil
50
+ YAML.safe_load(r, filename: file) rescue abort "invalid config: #{$!}" if r
51
+ end
52
+
53
+ def deep_merge first, second
54
+ merger = proc {|_,v1,v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
55
+ first.merge(second, &merger)
31
56
  end
32
57
 
33
- def menu params, text
34
- cmd = [File.join(__dir__, "_out/dmenu/dmenu")]
35
- params = params.map do |k,v|
58
+ def helper exe; File.join(__dir__, "_out/#{exe}"); end
59
+
60
+ def dmenu_cmd params
61
+ [helper('dmenu/dmenu')] + params.map do |k,v|
36
62
  k = "-"+k
37
63
  if !!v == v
38
64
  v ? k : nil
@@ -40,25 +66,80 @@ def menu params, text
40
66
  [k,v]
41
67
  end
42
68
  end.reject(&:nil?).flatten.map(&:to_s)
43
- IO.popen(cmd + params, 'w') { |ios| ios.puts text }
44
69
  end
45
70
 
46
- def main
47
- ['xwininfo', 'xdotool'].each do |util|
48
- errx 1, "no #{util} in PATH" unless which util
49
- end
71
+ def desired patterns, window
72
+ match = -> (type, value) {
73
+ include = patterns[type].select {|v| v[0] != '!'}
74
+ exclude = patterns[type].select {|v| v[0] == '!'}.map {|v| v[1..-1]}
75
+
76
+ exclude.each do |pattern|
77
+ return true if value.match pattern
78
+ end
79
+ include.each do |pattern|
80
+ return false if value.match pattern
81
+ end
82
+ true
83
+ }
84
+
85
+ match.call("class", window['class']) &&
86
+ match.call("resource", window['resource']) &&
87
+ match.call("name", window['name'])
88
+ end
89
+
90
+ def dmenu_max_text_len opt
91
+ cmd = "#{helper('fontinfo')} #{opt['dmenu']['fn'].shellescape} '@'"
92
+ desk_width, char_width = `#{cmd}`.split.map(&:to_i)
93
+ (desk_width - char_width*2) / char_width
94
+ end
95
+
96
+ def menu_line max_len, desk_indicator, w
97
+ desk = w['desk'] == -1 ? '*' : w['desk'].to_s
98
+ desktop = desk_indicator + desk
99
+ id = '0x'+w['id'].to_s(16)
100
+
101
+ c = ->(s, len) { s.size > len ? s[0...len-1] + '…' : s }
50
102
 
103
+ name_width = max_len - 4 - 10 - 10 - 9 - 4*3
104
+
105
+ "%-4s | %10s | %-#{name_width}s | %10s | %9s" % [
106
+ desktop,
107
+ c.call(w['class'], 10),
108
+ c.call(w['name'], name_width),
109
+ c.call(w['host'], 10),
110
+ c.call(id, 9)
111
+ ]
112
+ end
113
+
114
+ def main
51
115
  opt = options
52
- begin
53
- winlist = windows_filter opt["filter"], windows
54
- rescue RegexpError
55
- errx 1, "filter: #{$!}"
116
+ pp opt if $DEBUG
117
+
118
+ max_len = dmenu_max_text_len opt
119
+ dmenu = IO.popen(dmenu_cmd(opt['dmenu']), 'r+')
120
+
121
+ IO.popen(helper('winlist')).each_line do |line|
122
+ begin
123
+ w = JSON.parse line
124
+ rescue
125
+ dmenu.puts $!.to_s.gsub(/\n+/m, ' ') # let a user see an error
126
+ next
127
+ end
128
+
129
+ if opt['this_desk_only']
130
+ next unless w['desk_cur']
131
+ desk_indicator = ''
132
+ else
133
+ desk_indicator = w['desk_cur'] ? '→ ' : ' '
134
+ end
135
+
136
+ next unless desired opt['filter-out'], w
137
+
138
+ dmenu.puts menu_line(max_len, desk_indicator, w)
56
139
  end
57
- winlist = winlist.map do |w|
58
- "#{w.name} | #{w.class} | #{w.id}"
59
- end.join "\n"
60
140
 
61
- menu opt["dmenu"], winlist
141
+ dmenu.close
62
142
  end
63
143
 
64
- main
144
+ # not __FILE__ == $0, for $0 points to a generated stub after `gem install ...`
145
+ main unless defined? Minitest
data/lib.c ADDED
@@ -0,0 +1,60 @@
1
+ bool prop(Display *dpy, Window wid, Atom expected_type, const char *name,
2
+ u_char **result, ulong *size) {
3
+ Atom type;
4
+ int format;
5
+ ulong bytes_after;
6
+
7
+ Atom atom = XInternAtom(dpy, name, False);
8
+ int r = XGetWindowProperty(dpy, wid, atom, 0L, ~0L, False,
9
+ expected_type, &type, &format,
10
+ size, &bytes_after, result);
11
+ return r == Success && result;
12
+ }
13
+
14
+ long desktop(Display *dpy, Window wid) {
15
+ u_char *prop_val = NULL;
16
+ ulong prop_size;
17
+ if (!prop(dpy, wid, XA_CARDINAL, "_NET_WM_DESKTOP", &prop_val, &prop_size))
18
+ return -2;
19
+
20
+ long r = -1; // means a window is in a 'sticky' mode
21
+ if (prop_val) r = ((long*)prop_val)[0];
22
+ free(prop_val);
23
+ return r;
24
+ }
25
+
26
+ typedef struct {
27
+ bool _NET_WM_STATE_SHADED;
28
+ bool _NET_WM_STATE_HIDDEN;
29
+ Window id;
30
+ } WindowState;
31
+
32
+ typedef struct {
33
+ Atom _NET_WM_STATE_SHADED;
34
+ Atom _NET_WM_STATE_HIDDEN;
35
+ Atom UTF8_STRING;
36
+ } MyAtoms;
37
+
38
+ MyAtoms myAtoms;
39
+
40
+ void mk_atoms(Display *dpy) {
41
+ myAtoms._NET_WM_STATE_SHADED = XInternAtom(dpy, "_NET_WM_STATE_SHADED", False);
42
+ myAtoms._NET_WM_STATE_HIDDEN = XInternAtom(dpy, "_NET_WM_STATE_HIDDEN", False);
43
+ myAtoms.UTF8_STRING = XInternAtom(dpy, "UTF8_STRING", False);
44
+ }
45
+
46
+ WindowState state(Display *dpy, Window id) {
47
+ WindowState r = { .id = id };
48
+ u_char *prop_val = NULL;
49
+ ulong prop_size;
50
+ if (!prop(dpy, id, XA_ATOM, "_NET_WM_STATE", &prop_val, &prop_size)) return r;
51
+
52
+ Atom *atoms = (Atom*)prop_val;
53
+ for (int idx = 0; idx < prop_size; idx++) {
54
+ if (atoms[idx] == myAtoms._NET_WM_STATE_SHADED) r._NET_WM_STATE_SHADED = true;
55
+ if (atoms[idx] == myAtoms._NET_WM_STATE_HIDDEN) r._NET_WM_STATE_HIDDEN = true;
56
+ }
57
+ XFree(prop_val);
58
+
59
+ return r;
60
+ }
data/winlist.c ADDED
@@ -0,0 +1,120 @@
1
+ /*
2
+ produces line-delimited JSON of currently managed windows by an X
3
+ window manager:
4
+
5
+ {"desk":0,"host":"hm76","name":"xterm","resource":"xterm","class":"XTerm","id":67108878}
6
+ */
7
+
8
+ #include <stdlib.h>
9
+ #include <err.h>
10
+ #include <stdio.h>
11
+ #include <stdbool.h>
12
+ #include <string.h>
13
+ #include <math.h>
14
+
15
+ #include <X11/Xlib.h>
16
+ #include <X11/Xatom.h>
17
+ #include <X11/Xutil.h>
18
+ #include <jansson.h>
19
+
20
+ #include "lib.c"
21
+
22
+ typedef struct {
23
+ Window *ids;
24
+ ulong size;
25
+ } WinList;
26
+
27
+ // result (WinList.ids) should be freed
28
+ WinList winlist(Display *dpy) {
29
+ WinList list = { .ids = NULL };
30
+ u_char *result;
31
+
32
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_WINDOW, "_NET_CLIENT_LIST_STACKING",
33
+ &result, &list.size)) {
34
+ return list;
35
+ }
36
+
37
+ list.ids = (Window*)result;
38
+ return list;
39
+ }
40
+
41
+ // result should be freed
42
+ char* wm_client_machine(Display *dpy, Window wid) {
43
+ u_char *prop_val = NULL;
44
+ ulong prop_size;
45
+ prop(dpy, wid, XA_STRING, "WM_CLIENT_MACHINE", &prop_val, &prop_size);
46
+ return prop_val ? (char*)prop_val : strdup("nil");
47
+ }
48
+
49
+ // result (XClassHint.*) should be freed
50
+ XClassHint wm_class(Display *dpy, Window wid) {
51
+ XClassHint r = { .res_name = NULL };
52
+ XGetClassHint(dpy, wid, &r);
53
+ if (!r.res_name) r.res_name = strdup("nil");
54
+ if (!r.res_class) r.res_class = strdup("nil");
55
+ return r;
56
+ }
57
+
58
+ // result should be freed
59
+ char* wm_name(Display *dpy, Window wid) {
60
+ u_char *prop_val = NULL;
61
+ ulong prop_size;
62
+
63
+ bool r = prop(dpy, wid, myAtoms.UTF8_STRING, "_NET_WM_NAME", &prop_val, &prop_size);
64
+ if (r && prop_val) return (char*)prop_val;
65
+
66
+ prop(dpy, wid, XA_STRING, "WM_NAME", &prop_val, &prop_size);
67
+ return prop_val ? (char*)prop_val : strdup("nil");
68
+ }
69
+
70
+ long desktop_current(Display *dpy) {
71
+ u_char *prop_val = NULL;
72
+ ulong prop_size;
73
+ long r = -1;
74
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_CARDINAL, "_NET_CURRENT_DESKTOP",
75
+ &prop_val, &prop_size))
76
+ return r;
77
+
78
+ if (prop_val) r = ((long*)prop_val)[0];
79
+ free(prop_val);
80
+ return r;
81
+ }
82
+
83
+
84
+
85
+ int main() {
86
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
87
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
88
+ mk_atoms(dpy);
89
+
90
+ WinList list = winlist(dpy);
91
+ for (long idx = list.size-1; idx >= 0; idx--) {
92
+ ulong wid = list.ids[idx];
93
+
94
+ char *host = wm_client_machine(dpy, wid);
95
+ char *name = wm_name(dpy, wid);
96
+ XClassHint rc = wm_class(dpy, wid);
97
+ long desk = desktop(dpy, wid);
98
+ bool is_desk_cur = desk < 0 || desk == desktop_current(dpy);
99
+
100
+ json_t *line = json_object();
101
+ json_object_set_new(line, "desk", json_integer(desk));
102
+ json_object_set_new(line, "desk_cur", json_boolean(is_desk_cur));
103
+ json_object_set_new(line, "host", json_string(host));
104
+ json_object_set_new(line, "name", json_string(name));
105
+ json_object_set_new(line, "resource", json_string(rc.res_name));
106
+ json_object_set_new(line, "class", json_string(rc.res_class));
107
+ json_object_set_new(line, "id", json_integer(wid));
108
+
109
+ char *dump = json_dumps(line, JSON_COMPACT);
110
+ printf("%s\n", dump);
111
+ free(dump);
112
+ json_decref(line);
113
+
114
+ free(host);
115
+ free(name);
116
+ free(rc.res_name);
117
+ free(rc.res_class);
118
+ }
119
+ XFree(list.ids);
120
+ }
metadata CHANGED
@@ -1,19 +1,25 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fvwm-window-search
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 2.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexander Gromnitsky
8
- autorequire:
8
+ autorequire:
9
9
  bindir: "."
10
10
  cert_chain: []
11
- date: 2020-07-18 00:00:00.000000000 Z
11
+ date: 2021-04-09 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
- Search for windows interactively using a patched dmenu utility.
15
- Originally made for Fvwm, it's been fully rewritten to work out-of-the-box
16
- with any window manager. Requires xdotool & xwininfo installed.
14
+ A window switcher: search for windows interactively using a patched
15
+ dmenu utility (the gem fetches & patches it during its installation).
16
+ This was originally made for Fvwm, but it's been rewritten to work with
17
+ any EWMH-compliant stacking window manager.
18
+
19
+ Requires a preinstalled jansson-devel C library.
20
+
21
+ It differs from rofi & co in that it activates (brings up) windows
22
+ _during_ the search.
17
23
  email: alexander.gromnitsky@gmail.com
18
24
  executables:
19
25
  - fvwm-window-search
@@ -24,15 +30,18 @@ files:
24
30
  - "./fvwm-window-search"
25
31
  - Makefile
26
32
  - README.md
33
+ - activate.c
34
+ - activate.sh
27
35
  - dmenu.patch
28
36
  - extconf.rb
29
- - focus.sh
30
- - lib.rb
37
+ - fontinfo.c
38
+ - lib.c
39
+ - winlist.c
31
40
  homepage: https://github.com/gromnitsky/fvwm-window-search
32
41
  licenses:
33
42
  - MIT
34
43
  metadata: {}
35
- post_install_message:
44
+ post_install_message:
36
45
  rdoc_options: []
37
46
  require_paths:
38
47
  - lib
@@ -40,15 +49,16 @@ required_ruby_version: !ruby/object:Gem::Requirement
40
49
  requirements:
41
50
  - - ">="
42
51
  - !ruby/object:Gem::Version
43
- version: '0'
52
+ version: 2.1.0
44
53
  required_rubygems_version: !ruby/object:Gem::Requirement
45
54
  requirements:
46
55
  - - ">="
47
56
  - !ruby/object:Gem::Version
48
57
  version: '0'
49
58
  requirements: []
50
- rubygems_version: 3.1.2
51
- signing_key:
59
+ rubygems_version: 3.2.3
60
+ signing_key:
52
61
  specification_version: 4
53
- summary: Interactive incremental windows search & selection for X Window
62
+ summary: 'A window switcher: an interactive incremental windows search & selection
63
+ for X Window'
54
64
  test_files: []
data/focus.sh DELETED
@@ -1,7 +0,0 @@
1
- #!/bin/sh
2
-
3
- id=`echo "$1" | awk -F'|' '{print $NF} END { exit $NF == "" ? 1 : 0}'` || {
4
- echo "usage: `basename "$0"` 'name | class | id'"
5
- exit 1
6
- }
7
- xdotool windowactivate "$id" mousemove --window "$id" 0 0
data/lib.rb DELETED
@@ -1,105 +0,0 @@
1
- module FvwmWindowSearch; end
2
-
3
- class FvwmWindowSearch::Window
4
- def initialize xwininfo_line
5
- @line = xwininfo_line.match(/^([x0-9a-f]+)\s+(["\(].+["\)]):\s+\((.*)\)\s+([x0-9+-]+)\s+([0-9+-]+)$/)
6
- raise "invalid xwininfo line" unless @line
7
- @dim = parse
8
- end
9
-
10
- def parse
11
- dim = {}
12
- if @line[4]
13
- m4 = @line[4].match(/^([0-9]+)x([0-9]+)\+([0-9-]+)\+([0-9-]+)$/)
14
- if m4
15
- dim[:w] = m4[1].to_i
16
- dim[:h] = m4[2].to_i
17
- dim[:x_rel] = m4[3].to_i
18
- dim[:y_rel] = m4[4].to_i
19
- end
20
- end
21
-
22
- if @line[5]
23
- m5 = @line[5].match(/^\+([0-9-]+)\+([0-9-]+)$/)
24
- if m5
25
- dim[:x] = m5[1].to_i
26
- dim[:y] = m5[2].to_i
27
- end
28
- end
29
-
30
- dim
31
- end
32
-
33
- def id; @line[1]; end
34
-
35
- def name;
36
- return unless @line[2]
37
- @line[2] == '(has no name)' ? nil : @line[2][1..-2]
38
- end
39
-
40
- def resource; @line[3]&.split(' ')&.dig(0)&.slice(1..-2); end
41
- def class; @line[3]&.split(' ')&.dig(1)&.slice(1..-2); end
42
- def width; @dim[:w]; end
43
- def height; @dim[:h]; end
44
- def x; @dim[:x]; end # an absolute upper-left X
45
- def y; @dim[:y]; end # an absolute upper-left Y
46
- def x_rel; @dim[:x_rel]; end
47
- def y_rel; @dim[:y_rel]; end
48
-
49
- def useful?
50
- return false unless @line
51
- return false if width == 0 || height == 0
52
- return false if (x == x_rel) && (y == y_rel)
53
- return false if x_rel > 0 || y_rel > 0
54
- return false unless self.class
55
- true
56
- end
57
-
58
- def inspect
59
- "#<Window> id=#{id}, name=#{name}, resource=#{resource}, class=#{self.class}"
60
- end
61
- end
62
-
63
- module FvwmWindowSearch
64
- def windows
65
- `xwininfo -root -tree`.split("\n")
66
- .select {|v| v.match(/^\s*0x.+/)}
67
- .map(&:strip)
68
- .map {|v| Window.new(v)}
69
- .select(&:useful?)
70
- end
71
-
72
- def windows_filter patterns, winlist
73
- desired = -> (type, value) {
74
- include = patterns[type].filter{|v| v[0] != '!'}
75
- exclude = patterns[type].filter{|v| v[0] == '!'}.map {|v| v[1..-1]}
76
-
77
- exclude.each do |pattern|
78
- return true if value.match pattern
79
- end
80
- include.each do |pattern|
81
- return false if value.match pattern
82
- end
83
- true
84
- }
85
-
86
- winlist.filter { |w| desired.call "class", w.class }
87
- .filter{ |w| desired.call "resource", w.resource }
88
- .filter{ |w| desired.call "name", w.name }
89
- end
90
-
91
- def deep_merge first, second
92
- merger = proc { |_, v1, v2| Hash === v1 && Hash === v2 ? v1.merge(v2, &merger) : v2 }
93
- first.merge(second, &merger)
94
- end
95
-
96
- def errx exit_code, msg
97
- $stderr.puts "#{File.basename $0} error: #{msg}"
98
- exit exit_code
99
- end
100
-
101
- def which cmd
102
- ENV['PATH'].split(File::PATH_SEPARATOR).map {|v| File.join v, cmd }
103
- .find {|v| File.executable?(v) && !File.directory?(v) }
104
- end
105
- end