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