chiark / gitweb /
Delay search initiation for a bit after the last keypress, to avoid
[disorder] / disobedience / choose-search.c
1 /*
2  * This file is part of DisOrder
3  * Copyright (C) 2008 Richard Kettlewell
4  *
5  * This program is free software; you can redistribute it and/or modify
6  * it under the terms of the GNU General Public License as published by
7  * the Free Software Foundation; either version 2 of the License, or
8  * (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful, but
11  * WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13  * General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License
16  * along with this program; if not, write to the Free Software
17  * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
18  * USA
19  */
20 /** @file disobedience/search.c
21  * @brief Search support
22  *
23  * TODO:
24  * - cleverer focus to implement typeahead find
25  * - don't steal ^A
26  */
27 #include "disobedience.h"
28 #include "choose.h"
29
30 static GtkWidget *choose_search_entry;
31 static GtkWidget *choose_next;
32 static GtkWidget *choose_prev;
33 static GtkWidget *choose_clear;
34
35 /** @brief True if a search command is in flight */
36 static int choose_searching;
37
38 /** @brief True if in-flight search is now known to be obsolete */
39 static int choose_search_obsolete;
40
41 /** @brief Current search terms */
42 static char *choose_search_terms;
43
44 /** @brief Hash of all search result */
45 static hash *choose_search_hash;
46
47 /** @brief List of invisible search results
48  *
49  * This only lists search results not yet known to be visible, and is
50  * gradually depleted.
51  */
52 static char **choose_search_results;
53
54 /** @brief Length of @ref choose_search_results */
55 static int choose_n_search_results;
56
57 /** @brief Row references for search results */
58 static GtkTreeRowReference **choose_search_references;
59
60 /** @brief Length of @ref choose_search_references */
61 static int choose_n_search_references;
62
63 /** @brief Event handle for monitoring newly inserted tracks */
64 static event_handle choose_inserted_handle;
65
66 /** @brief Time of last search entry keypress (or 0.0) */
67 static struct timeval choose_search_last_keypress;
68
69 /** @brief Timeout ID for search delay */
70 static guint choose_search_timeout_id;
71
72 static void choose_search_entry_changed(GtkEditable *editable,
73                                         gpointer user_data);
74
75 int choose_is_search_result(const char *track) {
76   return choose_search_hash && hash_find(choose_search_hash, track);
77 }
78
79 static int is_prefix(const char *dir, const char *track) {
80   size_t nd = strlen(dir);
81
82   if(nd < strlen(track)
83      && track[nd] == '/'
84      && !strncmp(track, dir, nd))
85     return 1;
86   else
87     return 0;
88 }
89
90 /** @brief Do some work towards making @p track visible
91  * @return True if we made it visible or it was missing
92  */
93 static int choose_make_one_visible(const char *track) {
94   //fprintf(stderr, " choose_make_one_visible %s\n", track);
95   /* We walk through nodes at the top level looking for directories that are
96    * prefixes of the target track.
97    *
98    * - if we find one and it's expanded we walk through its children
99    * - if we find one and it's NOT expanded then we expand it, and arrange
100    *   to be revisited
101    * - if we don't find one then we're probably out of date
102    */
103   GtkTreeIter it[1];
104   gboolean itv = gtk_tree_model_get_iter_first(GTK_TREE_MODEL(choose_store),
105                                                it);
106   while(itv) {
107     const char *dir = choose_get_track(it);
108
109     //fprintf(stderr, "  %s\n", dir);
110     if(!dir) {
111       /* Placeholder */
112       itv = gtk_tree_model_iter_next(GTK_TREE_MODEL(choose_store), it);
113       continue;
114     }
115     GtkTreePath *path = gtk_tree_model_get_path(GTK_TREE_MODEL(choose_store),
116                                                 it);
117     if(!strcmp(dir, track)) {
118       /* We found the track.  If everything above it was expanded, it will be
119        * too.  So we can report it as visible. */
120       //fprintf(stderr, "   found %s\n", track);
121       choose_search_references[choose_n_search_references++]
122         = gtk_tree_row_reference_new(GTK_TREE_MODEL(choose_store), path);
123       gtk_tree_path_free(path);
124       return 1;
125     }
126     if(is_prefix(dir, track)) {
127       /* We found a prefix of the target track. */
128       //fprintf(stderr, "   is a prefix\n");
129       const gboolean expanded
130         = gtk_tree_view_row_expanded(GTK_TREE_VIEW(choose_view), path);
131       if(expanded) {
132         //fprintf(stderr, "   is apparently expanded\n");
133         /* This directory is expanded, let's make like Augustus Gibbons and
134          * take it to the next level. */
135         GtkTreeIter child[1];           /* don't know if parent==iter allowed */
136         itv = gtk_tree_model_iter_children(GTK_TREE_MODEL(choose_store),
137                                            child,
138                                            it);
139         *it = *child;
140         if(choose_is_placeholder(it)) {
141           //fprintf(stderr, "   %s is expanded, has a placeholder child\n", dir);
142           /* We assume that placeholder children of expanded rows are about to
143            * be replaced */
144           gtk_tree_path_free(path);
145           return 0;
146         }
147       } else {
148         //fprintf(stderr, "   requesting expansion of %s\n", dir);
149         /* Track is below a non-expanded directory.  So let's expand it.
150          * choose_make_visible() will arrange a revisit in due course. */
151         gtk_tree_view_expand_row(GTK_TREE_VIEW(choose_view),
152                                  path,
153                                  FALSE/*open_all*/);
154         gtk_tree_path_free(path);
155         /* TODO: the old version would remember which rows had been expanded
156          * just to show search results and collapse them again.  We should
157          * probably do that. */
158         return 0;
159       }
160     } else
161       itv = gtk_tree_model_iter_next(GTK_TREE_MODEL(choose_store), it);
162     gtk_tree_path_free(path);
163   }
164   /* If we reach the end then we didn't find the track at all. */
165   fprintf(stderr, "choose_make_one_visible: could not find %s\n",
166           track);
167   return 1;
168 }
169
170 /** @brief Compare two GtkTreeRowReferences
171  *
172  * Not very efficient since it does multiple memory operations per
173  * comparison!
174  */
175 static int choose_compare_references(const void *av, const void *bv) {
176   GtkTreeRowReference *a = *(GtkTreeRowReference **)av;
177   GtkTreeRowReference *b = *(GtkTreeRowReference **)bv;
178   GtkTreePath *pa = gtk_tree_row_reference_get_path(a);
179   GtkTreePath *pb = gtk_tree_row_reference_get_path(b);
180   const int rc = gtk_tree_path_compare(pa, pb);
181   gtk_tree_path_free(pa);
182   gtk_tree_path_free(pb);
183   return rc;
184 }
185
186 /** @brief Make @p path visible
187  * @param path Row reference to make visible
188  * @param row_align Row alignment (or -ve)
189  * @return 0 on success, nonzero if @p ref has gone stale
190  *
191  * If @p row_align is negative no row alignemt is performed.  Otherwise
192  * it must be between 0 (the top) and 1 (the bottom).
193  */
194 static int choose_make_path_visible(GtkTreePath *path,
195                                     gfloat row_align) {
196   /* Make sure that the target's parents are all expanded */
197   gtk_tree_view_expand_to_path(GTK_TREE_VIEW(choose_view), path);
198   /* Make sure the target is visible */
199   gtk_tree_view_scroll_to_cell(GTK_TREE_VIEW(choose_view), path, NULL,
200                                row_align >= 0.0,
201                                row_align,
202                                0);
203   return 0;
204 }
205
206 /** @brief Make @p ref visible
207  * @param ref Row reference to make visible
208  * @param row_align Row alignment (or -ve)
209  * @return 0 on success, nonzero if @p ref has gone stale
210  *
211  * If @p row_align is negative no row alignemt is performed.  Otherwise
212  * it must be between 0 (the top) and 1 (the bottom).
213  */
214 static int choose_make_ref_visible(GtkTreeRowReference *ref,
215                                    gfloat row_align) {
216   GtkTreePath *path = gtk_tree_row_reference_get_path(ref);
217   if(!path)
218     return -1;
219   choose_make_path_visible(path, row_align);
220   gtk_tree_path_free(path);
221   return 0;
222 }
223
224 /** @brief Do some work towards ensuring that all search results are visible
225  *
226  * Assumes there's at least one results!
227  */
228 static void choose_make_visible(const char attribute((unused)) *event,
229                                 void attribute((unused)) *eventdata,
230                                 void attribute((unused)) *callbackdata) {
231   //fprintf(stderr, "choose_make_visible\n");
232   int remaining = 0;
233
234   for(int n = 0; n < choose_n_search_results; ++n) {
235     if(!choose_search_results[n])
236       continue;
237     if(choose_make_one_visible(choose_search_results[n]))
238       choose_search_results[n] = 0;
239     else
240       ++remaining;
241   }
242   //fprintf(stderr, "remaining=%d\n", remaining);
243   if(remaining) {
244     /* If there's work left to be done make sure we get a callback when
245      * something changes */
246     if(!choose_inserted_handle)
247       choose_inserted_handle = event_register("choose-inserted-tracks",
248                                               choose_make_visible, 0);
249   } else {
250     /* Suppress callbacks if there's nothing more to do */
251     event_cancel(choose_inserted_handle);
252     choose_inserted_handle = 0;
253     /* We've expanded everything, now we can mess with the cursor */
254     //fprintf(stderr, "sort %d references\n", choose_n_search_references);
255     qsort(choose_search_references,
256           choose_n_search_references,
257           sizeof (GtkTreeRowReference *),
258           choose_compare_references);
259     choose_make_ref_visible(choose_search_references[0], 0.5);
260   }
261 }
262
263 /** @brief Called with search results */
264 static void choose_search_completed(void attribute((unused)) *v,
265                                     const char *error,
266                                     int nvec, char **vec) {
267   //fprintf(stderr, "choose_search_completed\n");
268   if(error) {
269     popup_protocol_error(0, error);
270     return;
271   }
272   choose_searching = 0;
273   /* If the search was obsoleted initiate another one */
274   if(choose_search_obsolete) {
275     choose_search_obsolete = 0;
276     choose_search_entry_changed(0, 0);
277     return;
278   }
279   //fprintf(stderr, "*** %d search results\n", nvec);
280   choose_search_hash = hash_new(1);
281   if(nvec) {
282     for(int n = 0; n < nvec; ++n)
283       hash_add(choose_search_hash, vec[n], "", HASH_INSERT);
284     /* Stash results for choose_make_visible */
285     choose_n_search_results = nvec;
286     choose_search_results = vec;
287     /* Make a big-enough buffer for the results row reference list */
288     choose_n_search_references = 0;
289     choose_search_references = xcalloc(nvec, sizeof (GtkTreeRowReference *));
290     /* Start making rows visible */
291     choose_make_visible(0, 0, 0);
292     gtk_widget_set_sensitive(choose_next, TRUE);
293     gtk_widget_set_sensitive(choose_prev, TRUE);
294   } else {
295     gtk_widget_set_sensitive(choose_next, FALSE);
296     gtk_widget_set_sensitive(choose_prev, FALSE);
297   }
298   event_raise("search-results-changed", 0);
299 }
300
301 /** @brief Actually initiate a search */
302 static void initiate_search(void) {
303   //fprintf(stderr, "initiate_search\n");
304   /* If a search is in flight don't initiate a new one until it comes back */
305   if(choose_searching) {
306     choose_search_obsolete = 1;
307     return;
308   }
309   char *terms = xstrdup(gtk_entry_get_text(GTK_ENTRY(choose_search_entry)));
310   /* Strip leading and trailing space */
311   while(*terms == ' ')
312     ++terms;
313   char *e = terms + strlen(terms);
314   while(e > terms && e[-1] == ' ')
315     --e;
316   *e = 0;
317   if(choose_search_terms && !strcmp(terms, choose_search_terms)) {
318     /* Search terms have not actually changed in any way that matters */
319     return;
320   }
321   /* Remember the current terms */
322   choose_search_terms = terms;
323   if(!*terms) {
324     /* Nothing to search for.  Fake a completion call. */
325     choose_search_completed(0, 0, 0, 0);
326     return;
327   }
328   if(disorder_eclient_search(client, choose_search_completed, terms, 0)) {
329     /* Bad search terms.  Fake a completion call. */
330     choose_search_completed(0, 0, 0, 0);
331     return;
332   }
333   choose_searching = 1;
334 }
335
336 static gboolean choose_search_timeout(gpointer attribute((unused)) data) {
337   struct timeval now;
338   xgettimeofday(&now, NULL);
339   /*fprintf(stderr, "%ld.%06d choose_search_timeout\n",
340           now.tv_sec, now.tv_usec);*/
341   if(tvdouble(now) - tvdouble(choose_search_last_keypress)
342          < SEARCH_DELAY_MS / 1000.0) {
343     //fprintf(stderr, " ... too soon\n");
344     return TRUE;                        /* Call me again later */
345   }
346   //fprintf(stderr, " ... let's go\n");
347   choose_search_last_keypress.tv_sec = 0;
348   choose_search_last_keypress.tv_usec = 0;
349   choose_search_timeout_id = 0;
350   initiate_search();
351   return FALSE;
352 }
353
354 /** @brief Called when the search entry changes */
355 static void choose_search_entry_changed
356     (GtkEditable attribute((unused)) *editable,
357      gpointer attribute((unused)) user_data) {
358   xgettimeofday(&choose_search_last_keypress, NULL);
359   /*fprintf(stderr, "%ld.%06d choose_search_entry_changed\n",
360           choose_search_last_keypress.tv_sec,
361           choose_search_last_keypress.tv_usec);*/
362   /* If there's already a timeout, remove it */
363   if(choose_search_timeout_id) {
364     g_source_remove(choose_search_timeout_id);
365     choose_search_timeout_id = 0;
366   }
367   /* Add a new timeout */
368   choose_search_timeout_id = g_timeout_add(SEARCH_DELAY_MS / 10,
369                                            choose_search_timeout,
370                                            0);
371   /* We really wanted to tell Glib what time we wanted the callback at rather
372    * than asking for calls at given intervals.  But there's no interface for
373    * that, and defining a new source for it seems like overkill if we can
374    * reasonably avoid it. */
375 }
376
377 /** @brief Identify first and last visible paths
378  *
379  * We'd like to use gtk_tree_view_get_visible_range() for this, but that was
380  * introduced in GTK+ 2.8, and Fink only has 2.6 (which is around three years
381  * out of date at time of writing), and I'm not yet prepared to rule out Fink
382  * support.
383  */
384 static gboolean choose_get_visible_range(GtkTreeView *tree_view,
385                                          GtkTreePath **startpathp,
386                                          GtkTreePath **endpathp) {
387   GdkRectangle visible_tc[1];
388
389   /* Get the visible rectangle in tree coordinates */
390   gtk_tree_view_get_visible_rect(tree_view, visible_tc);
391   /*fprintf(stderr, "visible: %dx%x at %dx%d\n",
392           visible_tc->width, visible_tc->height,
393           visible_tc->x, visible_tc->y);*/
394   if(startpathp) {
395     /* Convert top-left visible point to widget coordinates */
396     int x_wc, y_wc;
397     gtk_tree_view_tree_to_widget_coords(tree_view,
398                                         visible_tc->x, visible_tc->y,
399                                         &x_wc, &y_wc);
400     //fprintf(stderr, " start widget coords: %dx%d\n", x_wc, y_wc);
401     gtk_tree_view_get_path_at_pos(tree_view,
402                                   x_wc, y_wc,
403                                   startpathp,
404                                   NULL,
405                                   NULL, NULL);
406   }
407   if(endpathp) {
408     /* Convert bottom-left visible point to widget coordinates */
409     /* Convert top-left visible point to widget coordinates */
410     int x_wc, y_wc;
411     gtk_tree_view_tree_to_widget_coords(tree_view,
412                                         visible_tc->x,
413                                         visible_tc->y + visible_tc->height - 1,
414                                         &x_wc, &y_wc);
415     //fprintf(stderr, " end widget coords: %dx%d\n", x_wc, y_wc);
416     gtk_tree_view_get_path_at_pos(tree_view,
417                                   x_wc, y_wc,
418                                   endpathp,
419                                   NULL,
420                                   NULL, NULL);
421   }
422   return TRUE;
423 }
424
425 static void choose_next_clicked(GtkButton attribute((unused)) *button,
426                                 gpointer attribute((unused)) userdata) {
427   /* Find the last visible row */
428   GtkTreePath *endpath;
429   gboolean endvalid = choose_get_visible_range(GTK_TREE_VIEW(choose_view),
430                                                NULL,
431                                                &endpath);
432   if(!endvalid)
433     return;
434   /* Find a the first search result later than it.  They're sorted so we could
435    * actually do much better than this if necessary. */
436   for(int n = 0; n < choose_n_search_references; ++n) {
437     GtkTreePath *path
438       = gtk_tree_row_reference_get_path(choose_search_references[n]);
439     if(!path)
440       continue;
441     if(gtk_tree_path_compare(endpath, path) < 0) {
442       choose_make_path_visible(path, 0.5);
443       gtk_tree_path_free(path);
444       return;
445     }
446     gtk_tree_path_free(path);
447   }
448 }
449
450 static void choose_prev_clicked(GtkButton attribute((unused)) *button,
451                                 gpointer attribute((unused)) userdata) {
452   /* TODO can we de-dupe with choose_next_clicked?  Probably yes. */
453   /* Find the first visible row */
454   GtkTreePath *startpath;
455   gboolean startvalid = choose_get_visible_range(GTK_TREE_VIEW(choose_view),
456                                                  &startpath,
457                                                  NULL);
458   if(!startvalid)
459     return;
460   /* Find a the last search result earlier than it.  They're sorted so we could
461    * actually do much better than this if necessary. */
462   for(int n = choose_n_search_references - 1; n >= 0; --n) {
463     GtkTreePath *path
464       = gtk_tree_row_reference_get_path(choose_search_references[n]);
465     if(!path)
466       continue;
467     if(gtk_tree_path_compare(startpath, path) > 0) {
468       choose_make_path_visible(path, 0.5);
469       gtk_tree_path_free(path);
470       return;
471     }
472     gtk_tree_path_free(path);
473   }
474 }
475
476 /** @brief Called when the cancel search button is clicked */
477 static void choose_clear_clicked(GtkButton attribute((unused)) *button,
478                                  gpointer attribute((unused)) userdata) {
479   gtk_entry_set_text(GTK_ENTRY(choose_search_entry), "");
480   /* We start things off straight away in this case */
481   initiate_search();
482 }
483
484 /** @brief Create the search widget */
485 GtkWidget *choose_search_widget(void) {
486
487   /* Text entry box for search terms */
488   choose_search_entry = gtk_entry_new();
489   gtk_widget_set_style(choose_search_entry, tool_style);
490   g_signal_connect(choose_search_entry, "changed",
491                    G_CALLBACK(choose_search_entry_changed), 0);
492   gtk_tooltips_set_tip(tips, choose_search_entry,
493                        "Enter search terms here; search is automatic", "");
494
495   /* Cancel button to clear the search */
496   choose_clear = gtk_button_new_from_stock(GTK_STOCK_CANCEL);
497   gtk_widget_set_style(choose_clear, tool_style);
498   g_signal_connect(G_OBJECT(choose_clear), "clicked",
499                    G_CALLBACK(choose_clear_clicked), 0);
500   gtk_tooltips_set_tip(tips, choose_clear, "Clear search terms", "");
501
502   /* Up and down buttons to find previous/next results; initially they are not
503    * usable as there are no search results. */
504   choose_prev = iconbutton("up.png", "Previous search result");
505   g_signal_connect(G_OBJECT(choose_prev), "clicked",
506                    G_CALLBACK(choose_prev_clicked), 0);
507   gtk_widget_set_style(choose_prev, tool_style);
508   gtk_widget_set_sensitive(choose_prev, 0);
509   choose_next = iconbutton("down.png", "Next search result");
510   g_signal_connect(G_OBJECT(choose_next), "clicked",
511                    G_CALLBACK(choose_next_clicked), 0);
512   gtk_widget_set_style(choose_next, tool_style);
513   gtk_widget_set_sensitive(choose_next, 0);
514   
515   /* Pack the search tools button together on a line */
516   GtkWidget *hbox = gtk_hbox_new(FALSE/*homogeneous*/, 1/*spacing*/);
517   gtk_box_pack_start(GTK_BOX(hbox), choose_search_entry,
518                      TRUE/*expand*/, TRUE/*fill*/, 0/*padding*/);
519   gtk_box_pack_start(GTK_BOX(hbox), choose_prev,
520                      FALSE/*expand*/, FALSE/*fill*/, 0/*padding*/);
521   gtk_box_pack_start(GTK_BOX(hbox), choose_next,
522                      FALSE/*expand*/, FALSE/*fill*/, 0/*padding*/);
523   gtk_box_pack_start(GTK_BOX(hbox), choose_clear,
524                      FALSE/*expand*/, FALSE/*fill*/, 0/*padding*/);
525
526   return hbox;
527 }
528
529 /*
530 Local Variables:
531 c-basic-offset:2
532 comment-column:40
533 fill-column:79
534 indent-tabs-mode:nil
535 End:
536 */