fvwm-window-search 1.1.0 → 2.3.0

Sign up to get free protection for your applications and to get access to all the features.
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