fvwm-window-search 1.1.0 → 2.3.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 +33 -17
  4. data/activate.c +161 -0
  5. data/activate.sh +12 -0
  6. data/dmenu.patch +63 -13
  7. data/fontinfo.c +32 -0
  8. data/fvwm-window-search +112 -31
  9. data/lib.c +97 -0
  10. data/winlist.c +116 -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: 580941b472f2b948d9f6d854a337a1d066646c7b3705986f76f3b429845026a3
4
- data.tar.gz: 2e7b0a76b19a24514641ce603f35eee86f380bc8293c155014e80bfba9b24652
3
+ metadata.gz: 128a87d266fc2fa55f7a0a49e4754ff7026eeb13ea1e9db1c392fa5615e6e8ea
4
+ data.tar.gz: a4da76f60401c5cf6ed56069032b1014efd2224da4deaaf11a1ff4ac5e282870
5
5
  SHA512:
6
- metadata.gz: e85229be05ab43082df6362ca7b4b72c5f57cfdca85835027c979f305d01c332d74d5fa6830338548e08beb0718b4b25f2cd382c03da4ff66b5bce7a05429d79
7
- data.tar.gz: e9926afb792d151259ca4dadd48d6346da71437cbdc7642009392d08ff1e03a610b10b78e12475a58a6b901efb0551192d2977394e08e06aabdf51772e7c46e2
6
+ metadata.gz: a4892c1dde0908b82df3c249aa7768572de54622033286290f92ce0ea251ccbe881538a4a068b2c98a2e21a345bffabb8247b9633ed7ceeb3ee606ee6110246f
7
+ data.tar.gz: e51b7dd7ea7c4184003873004b34d46103a4a38c673ee8368f6aba3758730993ca3578f15c26dcc6af39e091153fa312c161cba4a0b4b1fad457de32275d0d97
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://sigwait.tk/~alex/junk/fvwm-window-search-2.2.0.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
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,27 +43,32 @@ To customise dmenu or filtering, create a yaml file
32
43
  dmenu:
33
44
  fn: Monospace-12
34
45
  b: false
35
- selhook-return-key-focus-only: true
36
- filter:
46
+ selection_hook_activation_return_key_only: true
47
+ filter-out:
37
48
  name: ['System Monitor']
38
49
  resource: []
39
50
  class: []
40
51
  ~~~
41
52
 
42
53
  Subkeys in `dmenu` are the usual CLOs for
43
- [dmenu(1)][]. `selhook-return-key-focus-only` is a custom one, that
44
- enables window focusing on pressing Return only.
54
+ [dmenu(1)][]. `selection_hook_activation_return_key_only` is an
55
+ equivalent of `-r` CLO.
45
56
 
46
57
  [dmenu(1)]: https://manpages.debian.org/unstable/suckless-tools/dmenu.1.en.html
47
58
 
48
- `filter` key tells what windows should be filtered out. Each value in
49
- a subkey is an array of regexes. See the defaults in
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
50
61
  `fvwm-window-search` file.
51
62
 
52
- ## Bugs
63
+ ## Start-up time
64
+
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.
53
68
 
54
- * Tested only w/ Ruby-2.7.0 & Fvwm3.
55
- * 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.
56
72
 
57
73
  ## License
58
74
 
data/activate.c ADDED
@@ -0,0 +1,161 @@
1
+ #include <err.h>
2
+ #include <stdio.h>
3
+ #include <stdbool.h>
4
+ #include <unistd.h>
5
+ #include <fcntl.h>
6
+ #include <string.h>
7
+ #include <limits.h>
8
+ #include <libgen.h>
9
+ #include <sys/utsname.h>
10
+
11
+ #include <jansson.h>
12
+
13
+ #include "lib.c"
14
+
15
+ ulong str2id(const char *s) {
16
+ ulong id;
17
+ if (sscanf(s, "0x%lx", &id) != 1 &&
18
+ sscanf(s, "0X%lx", &id) != 1 &&
19
+ sscanf(s, "%lu", &id) != 1) return 0;
20
+ return id;
21
+ }
22
+
23
+ bool client_msg(Display *dpy, Window id, const char *msg,
24
+ unsigned long data0, unsigned long data1,
25
+ unsigned long data2, unsigned long data3,
26
+ unsigned long data4) {
27
+ XEvent event;
28
+ long mask = SubstructureRedirectMask | SubstructureNotifyMask;
29
+
30
+ event.xclient.type = ClientMessage;
31
+ event.xclient.serial = 0;
32
+ event.xclient.send_event = True;
33
+ event.xclient.message_type = XInternAtom(dpy, msg, False);
34
+ event.xclient.window = id;
35
+ event.xclient.format = 32;
36
+ event.xclient.data.l[0] = data0;
37
+ event.xclient.data.l[1] = data1;
38
+ event.xclient.data.l[2] = data2;
39
+ event.xclient.data.l[3] = data3;
40
+ event.xclient.data.l[4] = data4;
41
+
42
+ if (XSendEvent(dpy, DefaultRootWindow(dpy), False, mask, &event))
43
+ return true;
44
+ warnx("cannot send %s event", msg);
45
+ return false;
46
+ }
47
+
48
+ bool window_activate(Display *dpy, Window id) {
49
+ long desk = desktop(dpy, id);
50
+ if (-1 != desk) {
51
+ client_msg(dpy, DefaultRootWindow(dpy), "_NET_CURRENT_DESKTOP",
52
+ desk, 0, 0, 0, 0);
53
+ }
54
+
55
+ bool active = client_msg(dpy, id, "_NET_ACTIVE_WINDOW", 0, 0, 0, 0, 0);
56
+
57
+ const int _net_wm_state_rm = 0;
58
+ bool unshaded = client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_rm,
59
+ myAtoms._NET_WM_STATE_SHADED, 0, 0, 0);
60
+
61
+ XMapRaised(dpy, id);
62
+ return active && unshaded;
63
+ }
64
+
65
+ bool window_center_mouse(Display *dpy, ulong id) {
66
+ XWindowAttributes attrs;
67
+ if (!XGetWindowAttributes(dpy, id, &attrs)) return false;
68
+ if (!XWarpPointer(dpy, 0, id, 0, 0, 0, 0, attrs.width/2, attrs.height/2))
69
+ return false;
70
+ XFlush(dpy);
71
+ return true;
72
+ }
73
+
74
+ // the result shout be freed
75
+ char* config() {
76
+ char xdg_runtime_home[PATH_MAX-64];
77
+ if (getenv("XDG_RUNTIME_HOME")) {
78
+ snprintf(xdg_runtime_home, PATH_MAX-64, "%s", getenv("XDG_RUNTIME_HOME"));
79
+ } else {
80
+ struct utsname info;
81
+ uname(&info);
82
+ char *template = 0 == strcmp(info.sysname, "Linux") ? "/run/user/%d" : "/tmp/user/%d";
83
+ snprintf(xdg_runtime_home, PATH_MAX-64, template, getuid());
84
+ }
85
+ char *file = (char*)malloc(PATH_MAX);
86
+ snprintf(file, PATH_MAX, "%s/%s/%s",
87
+ xdg_runtime_home, "fvwm-window-search", "last_window.json");
88
+
89
+ char *dir = dirname(strdup(file));
90
+ if (!mkdir_p(dir, 0700)) {
91
+ warn("failed to create %s", dir);
92
+ return NULL;
93
+ }
94
+ free(dir);
95
+ return file;
96
+ }
97
+
98
+ void state_save(Display *dpy, Window id) {
99
+ char *file = config();
100
+ int fd = open(file, O_WRONLY | O_CREAT | O_TRUNC, 0600); if (-1 == fd) {
101
+ warn("failed to truncate %s", file);
102
+ return;
103
+ }
104
+ free(file);
105
+
106
+ WindowState ws = state(dpy, id);
107
+ json_t *o = json_object();
108
+ json_object_set_new(o, "id", json_integer(ws.id));
109
+ json_object_set_new(o, "_NET_WM_STATE_SHADED", json_boolean(ws._NET_WM_STATE_SHADED));
110
+ json_object_set_new(o, "_NET_WM_STATE_HIDDEN", json_boolean(ws._NET_WM_STATE_HIDDEN));
111
+
112
+ char *dump = json_dumps(o, JSON_COMPACT);
113
+ write(fd, dump, strlen(dump));
114
+ free(dump);
115
+ json_decref(o);
116
+
117
+ close(fd);
118
+ }
119
+
120
+ Window state_load(Display *dpy, Window id_current) {
121
+ char *file = config();
122
+ json_t *root = json_load_file(file, 0, NULL);
123
+ free(file);
124
+ if (!root) return 0;
125
+
126
+ Window id = json_integer_value(json_object_get(root, "id"));
127
+ if (id == id_current) return id;
128
+
129
+ const int _net_wm_state_add = 1;
130
+ bool is_shaded = json_boolean_value(json_object_get(root, "_NET_WM_STATE_SHADED"));
131
+ if (is_shaded) client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_add,
132
+ myAtoms._NET_WM_STATE_SHADED, 0, 0, 0);
133
+ bool is_hidden = json_boolean_value(json_object_get(root, "_NET_WM_STATE_HIDDEN"));
134
+ if (is_hidden) client_msg(dpy, id, "_NET_WM_STATE", _net_wm_state_add,
135
+ myAtoms._NET_WM_STATE_HIDDEN, 0, 0, 0);
136
+
137
+ json_decref(root);
138
+ return id;
139
+ }
140
+
141
+
142
+
143
+ int main(int argc, char **argv) {
144
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
145
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
146
+ if (argc != 2) errx(1, "usage: activate window-id");
147
+
148
+ mk_atoms(dpy);
149
+
150
+ ulong id = str2id(argv[1]);
151
+ if (!id) errx(1, "invalid window id: `%s`", argv[1]);
152
+
153
+ Window prev_id = state_load(dpy, id);
154
+ if (prev_id != id) state_save(dpy, id);
155
+
156
+ XSynchronize(dpy, True); // snake oil?
157
+ bool r = window_activate(dpy, id);
158
+ if (!r) return 1;
159
+ r = window_center_mouse(dpy, id);
160
+ return !r;
161
+ }
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,19 +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..1920110 100644
17
+ index 1edb647..65c831f 100644
18
18
  --- a/config.def.h
19
19
  +++ b/config.def.h
20
- @@ -21,3 +21,7 @@ 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_return_key_focus_only = 0;
27
+ +static int selection_hook_activation = 1;
28
+ +static int selection_hook_activation_return_key_only = 0;
28
29
  diff --git a/dmenu.c b/dmenu.c
29
- index 65f25ce..274668a 100644
30
+ index 65f25ce..47a6b37 100644
30
31
  --- a/dmenu.c
31
32
  +++ b/dmenu.c
32
33
  @@ -304,6 +304,62 @@ movewordedge(int dir)
@@ -92,7 +93,45 @@ index 65f25ce..274668a 100644
92
93
  static void
93
94
  keypress(XKeyEvent *ev)
94
95
  {
95
- @@ -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:
96
135
  break;
97
136
  case XK_Return:
98
137
  case XK_KP_Enter:
@@ -100,29 +139,40 @@ index 65f25ce..274668a 100644
100
139
  puts((sel && !(ev->state & ShiftMask)) ? sel->text : text);
101
140
  if (!(ev->state & ControlMask)) {
102
141
  cleanup();
103
- @@ -572,6 +629,8 @@ 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)
104
151
  break;
105
152
  case KeyPress:
106
153
  keypress(&ev.xkey);
107
- + if (!selection_hook_return_key_focus_only)
154
+ + if (!selection_hook_activation_return_key_only &&
155
+ + selection_hook_activation)
108
156
  + selhook(selection_hook, sel);
157
+ +
158
+ + selection_hook_activation = 1;
109
159
  break;
110
160
  case SelectionNotify:
111
161
  if (ev.xselection.property == utf8)
112
- @@ -712,6 +771,8 @@ main(int argc, char *argv[])
162
+ @@ -712,6 +780,8 @@ main(int argc, char *argv[])
113
163
  else if (!strcmp(argv[i], "-i")) { /* case-insensitive item matching */
114
164
  fstrncmp = strncasecmp;
115
165
  fstrstr = cistrstr;
116
- + } else if (!strcmp(argv[i], "-selhook-return-key-focus-only")) {
117
- + selection_hook_return_key_focus_only = 1;
166
+ + } else if (!strcmp(argv[i], "-selection_hook_activation_return_key_only")) {
167
+ + selection_hook_activation_return_key_only = 1;
118
168
  } else if (i + 1 == argc)
119
169
  usage();
120
170
  /* these options take one argument */
121
- @@ -733,6 +794,8 @@ main(int argc, char *argv[])
171
+ @@ -733,6 +803,8 @@ main(int argc, char *argv[])
122
172
  colors[SchemeSel][ColFg] = argv[++i];
123
173
  else if (!strcmp(argv[i], "-w")) /* embedding window id */
124
174
  embed = argv[++i];
125
- + else if (!strcmp(argv[i], "-selhook")) /* a command to run */
175
+ + else if (!strcmp(argv[i], "-selection_hook")) /* a command to run */
126
176
  + selection_hook = argv[++i];
127
177
  else
128
178
  usage();
data/fontinfo.c ADDED
@@ -0,0 +1,32 @@
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 "lib.c"
7
+
8
+ long desktop_width(Display *dpy) {
9
+ u_char *prop_val = NULL;
10
+ ulong prop_size;
11
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_CARDINAL, "_NET_DESKTOP_GEOMETRY", &prop_val, &prop_size))
12
+ return -1;
13
+
14
+ long r = ((long*)prop_val)[0];
15
+ free(prop_val);
16
+ return r;
17
+ }
18
+
19
+ int main(int argc, char **argv) {
20
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
21
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
22
+ if (argc != 3) errx(1, "usage: fontinfo font text-string");
23
+
24
+ XftFont *font = XftFontOpenName(dpy, DefaultScreen(dpy), argv[1]);
25
+ if (!font) errx(1, "no font match");
26
+
27
+ XGlyphInfo info_text, info_char;
28
+ XftTextExtentsUtf8(dpy, font, (FcChar8*)"@", 1, &info_char);
29
+ XftTextExtentsUtf8(dpy, font, (FcChar8*)argv[2], strlen(argv[2]), &info_text);
30
+
31
+ printf("%ld %d %d\n", desktop_width(dpy), info_char.width, info_text.width);
32
+ }
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,97 @@
1
+ #include <stdlib.h>
2
+ #include <string.h>
3
+ #include <errno.h>
4
+ #include <sys/stat.h>
5
+
6
+ #include <X11/Xlib.h>
7
+ #include <X11/Xatom.h>
8
+
9
+ bool prop(Display *dpy, Window wid, Atom expected_type, const char *name,
10
+ u_char **result, ulong *size) {
11
+ Atom type;
12
+ int format;
13
+ ulong bytes_after;
14
+
15
+ Atom atom = XInternAtom(dpy, name, False);
16
+ int r = XGetWindowProperty(dpy, wid, atom, 0L, ~0L, False,
17
+ expected_type, &type, &format,
18
+ size, &bytes_after, result);
19
+ return r == Success && result;
20
+ }
21
+
22
+ long desktop(Display *dpy, Window wid) {
23
+ u_char *prop_val = NULL;
24
+ ulong prop_size;
25
+ if (!prop(dpy, wid, XA_CARDINAL, "_NET_WM_DESKTOP", &prop_val, &prop_size))
26
+ return -2;
27
+
28
+ long r = -1; // means a window is in a 'sticky' mode
29
+ if (prop_val) r = ((long*)prop_val)[0];
30
+ free(prop_val);
31
+ return r;
32
+ }
33
+
34
+ typedef struct {
35
+ bool _NET_WM_STATE_SHADED;
36
+ bool _NET_WM_STATE_HIDDEN;
37
+ Window id;
38
+ } WindowState;
39
+
40
+ typedef struct {
41
+ Atom _NET_WM_STATE_SHADED;
42
+ Atom _NET_WM_STATE_HIDDEN;
43
+ Atom UTF8_STRING;
44
+ } MyAtoms;
45
+
46
+ MyAtoms myAtoms;
47
+
48
+ void mk_atoms(Display *dpy) {
49
+ myAtoms._NET_WM_STATE_SHADED = XInternAtom(dpy, "_NET_WM_STATE_SHADED", False);
50
+ myAtoms._NET_WM_STATE_HIDDEN = XInternAtom(dpy, "_NET_WM_STATE_HIDDEN", False);
51
+ myAtoms.UTF8_STRING = XInternAtom(dpy, "UTF8_STRING", False);
52
+ }
53
+
54
+ WindowState state(Display *dpy, Window id) {
55
+ WindowState r = { .id = id };
56
+ u_char *prop_val = NULL;
57
+ ulong prop_size;
58
+ if (!prop(dpy, id, XA_ATOM, "_NET_WM_STATE", &prop_val, &prop_size)) return r;
59
+
60
+ Atom *atoms = (Atom*)prop_val;
61
+ for (int idx = 0; idx < prop_size; idx++) {
62
+ if (atoms[idx] == myAtoms._NET_WM_STATE_SHADED) r._NET_WM_STATE_SHADED = true;
63
+ if (atoms[idx] == myAtoms._NET_WM_STATE_HIDDEN) r._NET_WM_STATE_HIDDEN = true;
64
+ }
65
+ XFree(prop_val);
66
+
67
+ return r;
68
+ }
69
+
70
+ bool mkdir_p(const char *s, mode_t mode) {
71
+ char *component = strdup(s);
72
+ char *p = component;
73
+
74
+ bool status = true;
75
+ while (*p && *p == '/') p++; // skip leading '/'
76
+
77
+ do {
78
+ while (*p && *p != '/') p++;
79
+
80
+ if (!*p)
81
+ p = NULL;
82
+ else
83
+ *p = '\0';
84
+
85
+ if (-1 == mkdir(component, mode) && errno != EEXIST) {
86
+ status = false;
87
+ break;
88
+ } else if (p) {
89
+ *p++ = '/';
90
+ while (*p && *p == '/') p++;
91
+ }
92
+
93
+ } while (p);
94
+
95
+ free(component);
96
+ return status;
97
+ }
data/winlist.c ADDED
@@ -0,0 +1,116 @@
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 <err.h>
9
+ #include <stdio.h>
10
+ #include <stdbool.h>
11
+ #include <math.h>
12
+
13
+ #include <X11/Xutil.h>
14
+ #include <jansson.h>
15
+
16
+ #include "lib.c"
17
+
18
+ typedef struct {
19
+ Window *ids;
20
+ ulong size;
21
+ } WinList;
22
+
23
+ // result (WinList.ids) should be freed
24
+ WinList winlist(Display *dpy) {
25
+ WinList list = { .ids = NULL };
26
+ u_char *result;
27
+
28
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_WINDOW, "_NET_CLIENT_LIST_STACKING",
29
+ &result, &list.size)) {
30
+ return list;
31
+ }
32
+
33
+ list.ids = (Window*)result;
34
+ return list;
35
+ }
36
+
37
+ // result should be freed
38
+ char* wm_client_machine(Display *dpy, Window wid) {
39
+ u_char *prop_val = NULL;
40
+ ulong prop_size;
41
+ prop(dpy, wid, XA_STRING, "WM_CLIENT_MACHINE", &prop_val, &prop_size);
42
+ return prop_val ? (char*)prop_val : strdup("nil");
43
+ }
44
+
45
+ // result (XClassHint.*) should be freed
46
+ XClassHint wm_class(Display *dpy, Window wid) {
47
+ XClassHint r = { .res_name = NULL };
48
+ XGetClassHint(dpy, wid, &r);
49
+ if (!r.res_name) r.res_name = strdup("nil");
50
+ if (!r.res_class) r.res_class = strdup("nil");
51
+ return r;
52
+ }
53
+
54
+ // result should be freed
55
+ char* wm_name(Display *dpy, Window wid) {
56
+ u_char *prop_val = NULL;
57
+ ulong prop_size;
58
+
59
+ bool r = prop(dpy, wid, myAtoms.UTF8_STRING, "_NET_WM_NAME", &prop_val, &prop_size);
60
+ if (r && prop_val) return (char*)prop_val;
61
+
62
+ prop(dpy, wid, XA_STRING, "WM_NAME", &prop_val, &prop_size);
63
+ return prop_val ? (char*)prop_val : strdup("nil");
64
+ }
65
+
66
+ long desktop_current(Display *dpy) {
67
+ u_char *prop_val = NULL;
68
+ ulong prop_size;
69
+ long r = -1;
70
+ if (!prop(dpy, DefaultRootWindow(dpy), XA_CARDINAL, "_NET_CURRENT_DESKTOP",
71
+ &prop_val, &prop_size))
72
+ return r;
73
+
74
+ if (prop_val) r = ((long*)prop_val)[0];
75
+ free(prop_val);
76
+ return r;
77
+ }
78
+
79
+
80
+
81
+ int main() {
82
+ Display *dpy = XOpenDisplay(getenv("DISPLAY"));
83
+ if (!dpy) errx(1, "failed to open display %s", getenv("DISPLAY"));
84
+ mk_atoms(dpy);
85
+
86
+ WinList list = winlist(dpy);
87
+ for (long idx = list.size-1; idx >= 0; idx--) {
88
+ ulong wid = list.ids[idx];
89
+
90
+ char *host = wm_client_machine(dpy, wid);
91
+ char *name = wm_name(dpy, wid);
92
+ XClassHint rc = wm_class(dpy, wid);
93
+ long desk = desktop(dpy, wid);
94
+ bool is_desk_cur = desk < 0 || desk == desktop_current(dpy);
95
+
96
+ json_t *line = json_object();
97
+ json_object_set_new(line, "desk", json_integer(desk));
98
+ json_object_set_new(line, "desk_cur", json_boolean(is_desk_cur));
99
+ json_object_set_new(line, "host", json_string(host));
100
+ json_object_set_new(line, "name", json_string(name));
101
+ json_object_set_new(line, "resource", json_string(rc.res_name));
102
+ json_object_set_new(line, "class", json_string(rc.res_class));
103
+ json_object_set_new(line, "id", json_integer(wid));
104
+
105
+ char *dump = json_dumps(line, JSON_COMPACT);
106
+ printf("%s\n", dump);
107
+ free(dump);
108
+ json_decref(line);
109
+
110
+ free(host);
111
+ free(name);
112
+ free(rc.res_name);
113
+ free(rc.res_class);
114
+ }
115
+ XFree(list.ids);
116
+ }
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.1.0
4
+ version: 2.3.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-19 00:00:00.000000000 Z
11
+ date: 2021-04-10 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