PageRenderTime 78ms CodeModel.GetById 2ms app.highlight 69ms RepoModel.GetById 2ms app.codeStats 0ms

/lib/App/DuckDuckGo/UI.pm

https://github.com/nospampleasemam/p5-app-duckduckgo-ui
Perl | 449 lines | 376 code | 43 blank | 30 comment | 28 complexity | a74a765f39122bf156646c4f9415d07a MD5 | raw file
  1package App::DuckDuckGo::UI;
  2
  3use Moo;
  4
  5our $VERSION = 'devel';
  6
  7use Curses;
  8use Curses::UI::POE;
  9use Curses::UI::TextEditor;
 10use WWW::DuckDuckGo;
 11use URI::Encode qw/uri_decode uri_encode/;
 12use JSON;
 13use POE 'Component::Client::HTTP';
 14use HTTP::Request;
 15
 16use App::DuckDuckGo::UI::Config;
 17
 18has config => (
 19    is => 'ro',
 20    default => sub { App::DuckDuckGo::UI::Config->new },
 21);
 22
 23has ui => (
 24    is => 'ro',
 25    default => sub {
 26        my $self = shift;
 27        Curses::UI::POE->new(
 28            -clear_on_exit => 1,
 29            #-debug => 1,
 30            -color_support => 1,
 31            inline_states => {
 32                _start => sub {
 33                    $_[HEAP]->{ua} = POE::Component::Client::HTTP->spawn(Alias => 'ua', Timeout => 20, FollowRedirects => 1);
 34                },
 35                http_response => sub { 
 36                    my $method = $_[10]->[1];
 37                    $self->$method(@_);
 38                },
 39            }
 40        )
 41    }
 42);
 43
 44has window => (
 45    is => 'ro',
 46    builder => 1,
 47    lazy => 1,
 48);
 49
 50sub _build_window {
 51    shift->ui->add(
 52        'window', 'Window',
 53        -title => "DuckDuckGo",
 54        -titlefullwidth => 1,
 55        -border => 1,
 56        -bfg => "black",
 57        -titlereverse => 0,
 58    )
 59}
 60
 61has widgets => (
 62    is => 'ro',
 63    builder => 1,
 64    lazy => 1,
 65);
 66
 67has result_wrapper => (
 68    is => 'ro',
 69    builder => 1,
 70    lazy => 1,
 71);
 72
 73sub _build_result_wrapper {
 74    my $self = shift;
 75    $self->window->add(
 76        'res_wrap', 'Container',
 77        -vscrollbar => 'right',
 78        #-height => $self->window->height - $self->widgets->{searchbox}->height - 3, # make it fit between the entry and the "statusbar"
 79    ) 
 80}
 81
 82sub _build_widgets {
 83    my $self = shift;
 84    {
 85        searchbox => $self->window->add(
 86            undef, 'TextEntry',
 87            -border => 1,
 88            -bfg => 'red',
 89            -onblur => sub { $self->ui->clear_binding(KEY_ENTER) }
 90        ),
 91        zci_box => $self->result_wrapper->add(
 92            undef, 'Listbox',
 93            -htmltext => 1,
 94            -border => 1,
 95            -titlereverse => 0,
 96            -userdata => {name => 'zci'}
 97        ),
 98        deep_box => $self->result_wrapper->add(
 99            undef, 'ResultBox',
100            -htmltext => 1,
101            #-vscrollbar => 'right',
102            -userdata => {name => 'deep'}
103        ),
104        statusbar => $self->window->add(
105            undef, 'TextViewer',
106            -singleline => 1,
107            -text => "",
108            -y => $self->window->height - 3,
109            -fg => 'blue',
110        ),
111        autocompleter => $self->window->add(
112            undef, 'Listbox',
113            -htmltext => 1,
114            -y => $self->result_wrapper->{-y},
115            -height => 0,
116        ),
117        window => $self->window,
118        result_wrapper => $self->result_wrapper,
119    }
120}
121
122has ddg => (
123    is => 'ro',
124    default => sub {
125        WWW::DuckDuckGo->new(
126            params => { t => 'cli', %{shift->config->{params}} },
127            http_agent_name => "App::DuckDuckGo::UI/$VERSION",
128        )
129    }
130);
131
132sub scale {
133    # properly scale the two result listboxes
134    my $self = shift;
135    my $top = $self->widgets->{searchbox}{-y} + $self->widgets->{searchbox}->height;
136    $self->result_wrapper->{-height} = $self->window->height - $self->widgets->{searchbox}->height - 3;
137    $self->result_wrapper->{-y} = $top;
138    $self->widgets->{zci_box}{-height} = ($#{$self->widgets->{zci_box}->values})+$top;
139    $self->widgets->{zci_box}->layout;
140    $self->widgets->{deep_box}{-y} = $top + $self->widgets->{zci_box}->canvasheight;
141    #$deep_box->{-height} = $res_wrap->canvasheight - $zci_box->height;
142    $_->layout and $_->draw for ($self->widgets->{zci_box}, $self->widgets->{deep_box}, $self->window);
143}
144
145sub set_results {
146    my ($self, $box) = (shift, shift);
147    # takes the name of a listbox, and an array of hashrefs ({ URL => description })
148    my @values;
149    my %labels;
150    for my $result (@_) {
151        push @values, $_ for keys %{$result};
152        for (keys %{$result}) {
153            my $desc = $$result{$_};
154            $desc =~ s/'''//g;
155            $labels{$_} = $desc;
156        }
157    }
158    if (!@values) {
159        print STDERR "No values, hiding box $box\n";
160        $self->widgets->{$box}->hide;
161    } else {
162        $self->widgets->{$box}->show;
163        $self->widgets->{$box}->values(\@values);
164        $self->widgets->{$box}->labels(\%labels);
165    }
166    $self->scale;
167}
168
169sub autocomplete_and_add {
170    my ($self, $searchbox, $char) = @_;
171
172    $searchbox->add_string($char);
173
174    my $results = $self->autocomplete($searchbox->text);
175
176    return $searchbox;
177}
178
179#
180# Semi-logical part
181# 
182
183# Deep results API
184sub fill_deep {
185    my ($self, $request, $response) = @_[OBJECT, ARG0+1, ARG1+1];
186    my @out;
187
188    return unless $response->[0]->content; # no results?
189
190    my $results;
191    eval { $results = from_json($response->[0]->content); };
192    return if $@; # this likes to whine about incomplete or malformed json, just return if it does
193
194    for my $result (@$results) {
195        push @out, { $result->{c} => "<bold>".$result->{t}."</bold>\n".($result->{a} ? $result->{a} : $result->{c}) } if defined $result->{c} and defined $result->{t};
196    }
197    $self->set_results(deep_box => @out);
198}
199
200sub deep {
201    my ($self, $call) = @_;
202    my $request = HTTP::Request->new(GET => "https://api.duckduckgo.com/$call");
203    POE::Kernel->post('ua', 'request', 'http_response', $request, 'fill_deep');
204}
205
206# Autocompletion!
207sub fill_ac {
208    my ($self, $request, $response) = @_[OBJECT, ARG0+1, ARG1+1];
209    $self->widgets->{zci_box}->values(from_json($response->[0]->content)->[1]);
210    $self->widgets->{zci_box}->title(undef);
211
212    $self->scale;
213}
214
215sub autocomplete {
216    my ($self, $text) = @_;
217    my $request = HTTP::Request->new(GET => 'https://duckduckgo.com/ac/?type=list&q=' . uri_encode($text));
218    POE::Kernel->post('ua', 'request', 'http_response', $request, 'fill_ac');
219}
220
221sub duck {
222    my $self = shift;
223    $self->widgets->{searchbox}->text($_[0]);
224    my $zci;
225    eval { $zci = $self->ddg->zci(shift); };
226    if ($@) {
227        $self->ui->error("$@");
228        return;
229    }
230
231    if ($zci->has_redirect) {
232        $self->browse($zci->redirect);
233        return;
234    }
235
236    my @results;
237    
238    if (defined $zci->_json->{Calls} && $zci->_json->{Calls}{deep}) {
239        $self->deep($zci->_json->{Calls}{deep});
240    }
241    
242    $self->widgets->{zci_box}->title(""); # clear the title, in case there is no heading
243    $self->widgets->{zci_box}->title($zci->heading) if $zci->has_heading;
244
245    if ($zci->has_results) {
246        for my $zci_box (@{$zci->results}) {
247            push @results, { $zci_box->first_url => "<bold>".$zci_box->text."</bold>" } if $zci_box->has_first_url && $zci_box->has_text;
248        }
249    }
250
251    if ($zci->has_answer) {
252        push @results, { 0 => "<bold>Answer: </bold>".$zci->answer };
253    }
254
255    if ($zci->has_abstract_text && $zci->has_abstract_url) {
256        push @results, { $zci->abstract_url->as_string => "<bold>Abstract: </bold>".$zci->abstract_text };
257    }
258
259    if ($zci->has_definition && $zci->has_definition_url) {
260        push @results, { $zci->definition_url->as_string => $zci->definition };
261    }
262
263    if ($zci->has_related_topics_sections) {
264        for my $sec (keys %{$zci->related_topics_sections}) {
265            my $section = $zci->related_topics_sections;
266            $section = $$section{$sec};
267            for my $zci_box (@{$section}) {
268                push @results, { $zci_box->first_url => $zci_box->text } if $zci_box->has_first_url && $zci_box->has_text;
269            }
270        }
271    }
272
273    if (scalar @results) {
274        #$self->widgets->{zci_box}->show;
275        $self->set_results(zci_box => @results);
276    } else {
277        # FIXME: Hide the ZCI box when it isn't needed
278        #$self->widgets->{zci_box}->hide;
279        #$self->scale;
280    }
281}
282
283
284#
285# Launch a browser!
286#
287sub browse {
288    my ($self, $URI) = @_;
289    $self->ui->leave_curses;
290    system split(
291        /\s+/,
292        sprintf($self->config->{browser}, "$URI")
293    );
294    $self->ui->error("Error $? ($!) in browser") if $?;
295    $self->ui->reset_curses;
296}
297
298
299#
300# Builtin keybindings
301#
302
303# TODO: Mouse support - directly clicking on results to visit them?
304#$zci_box->onSelectionChange(sub {
305#    browse shift->get_active_value;
306#});
307
308sub default_bindings {
309    my $self = shift;
310    my ($cui, $zci_box, $deep_box, $searchbox, $statusbar) = ($self->ui, $self->widgets->{zci_box}, $self->widgets->{deep_box}, $self->widgets->{searchbox}, $self->widgets->{statusbar});
311    $cui->set_binding(sub {exit}, "\cq");
312    $cui->set_binding(sub {exit}, "\cc");
313
314    $cui->set_binding(sub {
315        my $cui = shift;
316        $cui->layout;
317        $cui->draw;
318    }, "\cl");
319
320    $searchbox->set_binding(sub { $self->duck($searchbox->get) if $searchbox->get; }, KEY_ENTER);
321    $searchbox->set_binding(sub { $zci_box->focus }, KEY_DOWN);
322
323    $_->set_binding(sub { $searchbox->focus }, '/') for ($zci_box, $deep_box);
324
325# Bind space to show a dialog containing the full result
326    $deep_box->set_binding(sub {
327        my $this = shift;
328        my $message = $this->labels->{$this->get_active_value};
329        $message =~ s{^<bold>(.+?)</bold>(?: - )?}{};
330        $cui->dialog(
331            -title => $1,
332            -message => $message . " (".$this->get_active_value.")",
333        );
334    }, ' ');
335
336    $zci_box->set_binding(sub {
337        my $URL = shift->get_active_value;# or ($cui->dialog(shift->get_active_) and return); #TODO: handle value==0 somehow
338        if ($URL !~ m{^[a-z]+://}) { # FIXME: make this handle category pages and post-disambig results
339            #my $q = $1 // $URL; $q =~ s/_/ /g;
340            $self->duck(uri_decode($1 // $URL));
341        } else {
342            $self->browse($URL);
343        }
344    }, $_) for (KEY_ENTER, KEY_RIGHT, "l");
345
346    $deep_box->set_binding(sub {
347        $self->browse(shift->get_active_value);
348    }, $_) for (KEY_ENTER, KEY_RIGHT, "l");
349
350    $deep_box->set_mouse_binding(sub {
351        my ($this, $event, $x, $y) = @_;
352        my $newypos = $this->{-yscrpos} + $y;
353        my $i = (($newypos - ($newypos%2 ? 1 : 0)) + ($this->{-yscrpos} ? $this->{-yscrpos}+0.5 : 0 ) ) /2; print STDERR "clicked: $i\n";
354        #$self->browse($this->values->[$i]) if (@{$this->{-values}} and $newypos >= 0); # FIXME
355        $self->ui->error("Clicker support is buggy and rather broken.\nURL I think you clicked: ".$this->values->[$i]);
356    }, BUTTON1_CLICKED);
357
358# Show the URL
359    $_->onSelectionChange(sub {
360        $statusbar->text(shift->get_active_value);
361        $deep_box->layout; $deep_box->draw;
362        $statusbar->layout; $statusbar->draw;
363    }) for ($zci_box, $deep_box);
364
365    $_->onFocus(sub {
366        $statusbar->text(shift->get_active_value or "");
367        $statusbar->draw;
368    }) for ($zci_box, $deep_box);
369
370#
371# Override the up and down handlers on the listboxes to handle moving between them
372#
373    $_->set_binding(sub {
374        my $this = shift;
375        if ($this->{-ypos} >= $this->{-max_selected} and $this->userdata->{name} eq 'zci') {
376            $deep_box->focus;
377        } else {
378            $this->{-routines}{'option-next'}->($this);
379            #print STDERR "ypos: ".($this->{-ypos}*($this->userdata->{name} eq 'deep' ?2:1) % $this->canvasheight).", canvasheight: ".$this->canvasheight.", yscrpos: ";
380            #$this->{-yscrpos}+=($this->userdata->{name} eq 'deep' ?2:1) unless ($this->{-ypos}*($this->userdata->{name} eq 'deep' ?2:1) % $this->canvasheight);
381            #p $this->{-yscrpos};
382            #$this->draw;
383        }
384    }, KEY_DOWN) for ($zci_box, $deep_box);
385
386    $_->set_binding(sub {
387        my $this = shift;
388        if ($this->{-ypos} == 0) {
389            my $target = $this->userdata->{name} eq 'zci' ? $searchbox : $zci_box;
390            $target->focus;
391        } else {
392            $this->{-routines}{'option-prev'}->($this);
393        }
394    }, KEY_UP) for ($zci_box, $deep_box);
395
396
397    # Autocompleter
398    $searchbox->set_binding(sub { $self->autocomplete_and_add(@_) }, '');
399}
400
401sub configure_widgets {
402    my $self = shift;
403    return unless defined $self->config->{interface};
404    for my $widget (keys %{$self->config->{interface}}) {
405        print STDERR "\"$widget\" is not a valid widget name.\n" and return if not defined $self->widgets->{$widget};
406        for my $key (keys %{$self->config->{interface}{$widget}}) {
407            print STDERR "Setting $key on $widget ...\n";
408
409            $self->widgets->{$widget}{$key} = $self->config->{interface}{$widget}{$key} and next
410                if $key =~ /^-\w+$/;
411
412            if ($key eq 'keys') {
413                for (keys %{$self->config->{interface}{$widget}{keys}}) {
414                    my $key_name = $_;
415                    if (/^<(\w+)>$/) {
416                        $key_name = "KEY_" . uc $1;
417                        $key_name = Curses->$key_name;
418                    }
419                    $self->widgets->{$widget}->set_binding($self->config->{interface}{$widget}{keys}{$_}, $key_name);
420                }
421            }
422            else {
423                print STDERR "Unknown option!";
424            }
425        }
426    }
427}
428
429sub run {
430    my $self = shift;
431    $self->window->layout; $self->window->draw;
432
433    $self->default_bindings;
434    $self->configure_widgets;
435
436    $self->set_results(
437        zci_box =>
438            {'https://duckduckgo.com/'         => '<bold>Homepage</bold>'},
439            {'https://duckduckgo.com/about'    => '<bold>About</bold>'},
440            {'https://duckduckgo.com/goodies/' => '<bold>Goodies</bold>'},
441            {'https://duckduckgo.com/feedback' => '<bold>Feedback</bold>'},
442            {'https://duckduckgo.com/privacy'  => '<bold>Privacy</bold>'},
443    );
444
445    POE::Kernel->run;
446}
447
448
4491;