1# Copyright (c) 2017-2021 Cedric Bellegarde <cedric.bellegarde@adishatz.org>
2# This program is free software: you can redistribute it and/or modify
3# it under the terms of the GNU General Public License as published by
4# the Free Software Foundation, either version 3 of the License, or
5# (at your option) any later version.
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU General Public License for more details.
10# You should have received a copy of the GNU General Public License
11# along with this program. If not, see <http://www.gnu.org/licenses/>.
12
13from gi.repository import Gtk, Gdk, GLib, Pango, Gio
14
15from gettext import gettext as _
16from urllib.parse import urlparse
17from time import time
18
19from eolie.define import App
20from eolie.utils import emit_signal
21from eolie.helper_task import TaskHelper
22from eolie.popover_uri import UriPopover
23from eolie.widget_smooth_progressbar import SmoothProgressBar
24from eolie.widget_uri_entry_icons import UriEntryIcons
25from eolie.helper_gestures import GesturesHelper
26from eolie.helper_size_allocation import SizeAllocationHelper
27
28
29class UriEntry(Gtk.Overlay, SizeAllocationHelper):
30    """
31        URI Entry for toolbar
32    """
33
34    def __init__(self, window):
35        """
36            Init toolbar
37            @param window as Window
38        """
39        Gtk.Overlay.__init__(self)
40        self.get_style_context().add_class("input")
41        self.__cancellable = Gio.Cancellable.new()
42        self.__window = window
43        self.__task_helper = TaskHelper()
44        self.__input_warning_shown = False
45        self.__entry_changed_id = None
46        self.__secure_content = True
47        self.__size_allocation_timeout = None
48        self.__current_value = ""
49        self.__entry = Gtk.Entry.new()
50        self.__entry.show()
51        self.__entry.get_style_context().add_class("uribar")
52        self.__signal_id = self.__entry.connect("changed",
53                                                self.__on_entry_changed)
54        self.__entry.connect("populate-popup", self.__on_entry_populate_popup)
55        self.__entry.connect("icon-release", self.__on_entry_icon_release)
56        self.__entry_gesture = GesturesHelper(
57            self.__entry,
58            primary_press_callback=self.__on_entry_press)
59        self.__entry_controller = Gtk.EventControllerKey.new(self.__entry)
60        self.__entry_controller.connect("focus-in", self.__on_entry_focus_in)
61        self.__entry_controller.connect("focus-out", self.__on_entry_focus_out)
62        self.__entry_controller.connect("key-pressed",
63                                        self.__on_entry_key_pressed)
64
65        self.__icons = UriEntryIcons(self.__entry, window)
66        self.__icons.show()
67
68        self.__placeholder = Gtk.Label.new()
69        self.__placeholder.show()
70        self.__placeholder.set_property("halign", Gtk.Align.START)
71        self.__placeholder.set_hexpand(True)
72        self.__placeholder.set_ellipsize(Pango.EllipsizeMode.END)
73        self.__placeholder.get_style_context().add_class("placeholder")
74        self.__placeholder.set_opacity(0.8)
75
76        grid = Gtk.Grid()
77        grid.show()
78        grid.set_valign(Gtk.Align.CENTER)
79        grid.add(self.__placeholder)
80        grid.add(self.__icons)
81
82        self.__progress = SmoothProgressBar()
83
84        self.add(self.__entry)
85        self.add_overlay(grid)
86        self.add_overlay(self.__progress)
87        self.set_overlay_pass_through(grid, True)
88
89        # Inline completion
90        self.__completion_model = Gtk.ListStore(str)
91        self.__completion = Gtk.EntryCompletion.new()
92        self.__completion.set_model(self.__completion_model)
93        self.__completion.set_text_column(0)
94        self.__completion.set_inline_completion(True)
95        self.__completion.set_popup_completion(False)
96        self.__entry.set_completion(self.__completion)
97
98        self.__popover = UriPopover(window)
99        self.__popover.set_relative_to(self.__entry)
100        self.__popover.connect("closed", self.__on_popover_closed)
101
102        # Some on the fly css styling
103        context = self.__entry.get_style_context()
104        self.__css_allocation = Gtk.CssProvider()
105        self.__css_color = Gtk.CssProvider()
106        context.add_provider_for_screen(Gdk.Screen.get_default(),
107                                        self.__css_allocation,
108                                        Gtk.STYLE_PROVIDER_PRIORITY_USER)
109        context.add_provider_for_screen(Gdk.Screen.get_default(),
110                                        self.__css_color,
111                                        Gtk.STYLE_PROVIDER_PRIORITY_USER)
112        SizeAllocationHelper.__init__(self, self.__icons)
113
114    def set_text_entry(self, text):
115        """
116            Set uri in Gtk.Entry
117            @param text as str
118        """
119        if self.__entry.get_text() == text:
120            return
121        if self.__signal_id is not None:
122            self.__entry.disconnect(self.__signal_id)
123        # Do not show this in titlebar
124        parsed = urlparse(text)
125        if parsed.scheme in ["populars", "about"]:
126            text = ""
127        # Should not be needed but set_text("") do not clear text
128        self.__entry.delete_text(0, -1)
129        self.__entry.set_text(text)
130        self.__entry.set_position(-1)
131        if text:
132            self.__placeholder.set_opacity(0)
133        else:
134            self.__placeholder.set_opacity(0.8)
135        self.__signal_id = self.__entry.connect("changed",
136                                                self.__on_entry_changed)
137
138    def set_uri(self, uri):
139        """
140            Set toolbar URI
141            @param uri as str
142        """
143        self.set_tooltip_text(uri)
144        self.__input_warning_shown = False
145        self.__secure_content = True
146        self.__update_secure_content_indicator()
147        bookmark_id = App().bookmarks.get_id(uri)
148        self.__icons.set_bookmarked(bookmark_id is not None)
149
150    def set_title(self, title):
151        """
152            Set toolbar title
153            @param title as str
154        """
155        if title is None:
156            self.set_default_placeholder()
157        else:
158            self.__window.set_title(title)
159            self.__placeholder.set_text(title)
160            self.set_text_entry("")
161
162    def set_insecure_content(self):
163        """
164            Mark uri as insecure
165        """
166        self.__secure_content = False
167        self.__entry.set_icon_tooltip_text(
168            Gtk.EntryIconPosition.PRIMARY,
169            _("This page contains insecure content"))
170        self.__entry.set_icon_from_icon_name(
171            Gtk.EntryIconPosition.PRIMARY,
172            "channel-insecure-symbolic")
173
174    def focus(self, child="bookmarks"):
175        """
176            Focus wanted child entry
177            @param child as str
178        """
179        self.get_toplevel().set_focus(self.__entry)
180        uri = self.__window.container.webview.uri
181        parsed = urlparse(uri)
182        if parsed.scheme in ["http", "https", "file"]:
183            self.set_text_entry(uri)
184            self.__entry.select_region(0, -1)
185        self.__popover.popup(child)
186
187    def set_default_placeholder(self):
188        """
189            Show search placeholder
190        """
191        if self.__placeholder.get_text() == _("Search or enter address"):
192            return
193        self.set_text_entry("")
194        self.__placeholder.set_text(_("Search or enter address"))
195        self.__entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY,
196                                             "system-search-symbolic")
197        self.__entry.set_icon_tooltip_text(Gtk.EntryIconPosition.PRIMARY,
198                                           "")
199
200    def update_style(self):
201        """
202            Update color based on parent values
203            Used for dark headerbar (Ubuntu like)
204        """
205        # Getting color for headerbar as not possible in pure CSS
206        style = self.get_parent().get_style_context()
207        color = style.get_color(Gtk.StateFlags.ACTIVE).to_string()
208        css = ".uribar { color: %s; caret-color:%s}" % (color, color)
209        self.__css_color.load_from_data(css.encode("utf-8"))
210
211    @property
212    def widget(self):
213        """
214            Get main entry widget
215            @return Gtk.entry
216        """
217        return self.__entry
218
219    @property
220    def placeholder(self):
221        """
222            Get placeholder
223            @return Gtk.Label
224        """
225        return self.__placeholder
226
227    @property
228    def popover(self):
229        """
230            Get main URI popover
231            @return UriPopover
232        """
233        return self.__popover
234
235    @property
236    def icons(self):
237        """
238            Get entry icons
239            @return UriEntryIcons
240        """
241        return self.__icons
242
243    @property
244    def progress(self):
245        """
246            Get progress bar
247            @return Gtk.ProgressBar
248        """
249        return self.__progress
250
251#######################
252# PRIVATE             #
253#######################
254    def __focus_out(self):
255        """
256            Focus out widget
257        """
258        webview = self.__window.container.webview
259        self.set_text_entry("")
260        uri = webview.uri
261        if uri is not None:
262            bookmark_id = App().bookmarks.get_id(uri)
263            self.__icons.set_bookmarked(bookmark_id is not None)
264
265    def __update_secure_content_indicator(self):
266        """
267            Update PRIMARY icon, Gtk.Entry should be set
268        """
269        webview = self.__window.container.webview
270        if time() - webview.ctime < 10:
271            self.__entry.set_icon_from_icon_name(
272                Gtk.EntryIconPosition.PRIMARY, "dialog-password-symbolic")
273            self.__entry.set_icon_tooltip_text(
274                Gtk.EntryIconPosition.PRIMARY,
275                _("Save password for: %s") % webview.credentials_uri)
276        else:
277            uri = webview.uri
278            parsed = urlparse(uri)
279            self.__entry.set_icon_tooltip_text(Gtk.EntryIconPosition.PRIMARY,
280                                               "")
281            if (parsed.scheme == "https" and self.__secure_content) or\
282                    parsed.scheme == "file":
283                self.__entry.set_icon_from_icon_name(
284                    Gtk.EntryIconPosition.PRIMARY,
285                    "channel-secure-symbolic")
286            elif parsed.scheme in ["http", "https"]:
287                self.set_insecure_content()
288            else:
289                self.__entry.set_icon_from_icon_name(
290                    Gtk.EntryIconPosition.PRIMARY,
291                    "system-search-symbolic")
292
293    def __populate_completion(self, value):
294        """
295            @param value as str
296            @thread safe
297        """
298        def populate(match, value):
299            if match is not None and self.__current_value == value:
300                iterator = get_iterator()
301                self.__completion_model.set_value(iterator,
302                                                  0,
303                                                  match)
304                self.__completion.insert_prefix()
305            else:
306                # Only happen if nothing matched
307                self.__completion_model.clear()
308
309        def look_for_match(value):
310            parsed = urlparse(value)
311            # Look for a match in bookmarks
312            match = App().bookmarks.get_match(value)
313            if match is None:
314                # Look for a match in history
315                match = App().history.get_match(value)
316            if match is not None:
317                match = match.split("://")[-1].split("www.")[-1]
318                # We want result to match value slashes
319                slash_count = parsed.path.count("/")
320                split = match.split("/")[:-slash_count - 1]
321                if split:
322                    match = "/".join(split)
323            GLib.idle_add(populate, match, value)
324
325        def get_iterator():
326            iterator = self.__completion_model.get_iter_first()
327            if iterator is None:
328                iterator = self.__completion_model.insert(0)
329            return iterator
330
331        self.__task_helper.run(look_for_match, value)
332
333    def __on_popover_closed(self, popover):
334        """
335            Clean titlebar
336            @param popover as Gtk.popover
337        """
338        webview = self.__window.container.webview
339        webview.grab_focus()
340        self.__focus_out()
341        value = self.__entry.get_text().lstrip().rstrip()
342        if value:
343            webview.add_text_entry(value)
344        self.__entry.delete_selection()
345
346    def __on_entry_changed(self, entry):
347        """
348            Delayed entry changed
349            @param entry as Gtk.Entry
350        """
351        value = entry.get_text()
352        # Block completion
353        for completion in self.__completion_model:
354            if completion[0] == value:
355                return
356        do_completion = len(value) > len(self.__current_value)
357        self.__current_value = value
358        self.__cancellable.cancel()
359        self.__cancellable = Gio.Cancellable.new()
360        if value:
361            self.__placeholder.set_opacity(0)
362            if not self.__popover.is_visible():
363                self.__popover.popup("bookmarks")
364        else:
365            webview = self.__window.container.webview
366            webview.clear_text_entry()
367        if self.__entry_changed_id is not None:
368            GLib.source_remove(self.__entry_changed_id)
369        self.__entry_changed_id = GLib.timeout_add(
370            100,
371            self.__on_entry_changed_timeout,
372            entry,
373            do_completion)
374
375    def __on_entry_changed_timeout(self, entry, do_completion):
376        """
377            Update popover search if needed
378            @param entry as Gtk.Entry
379            @param do_completion as bool
380        """
381        self.__entry_changed_id = None
382        self.__window.container.webview.add_text_entry(self.__current_value)
383        if do_completion:
384            self.__populate_completion(self.__current_value)
385        parsed = urlparse(self.__current_value)
386        is_uri = parsed.scheme in ["about, http", "file", "https", "populars"]
387        if is_uri:
388            self.__popover.set_search_text(parsed.netloc + parsed.path)
389        else:
390            self.__popover.set_search_text(self.__current_value)
391        self.__entry.set_icon_from_icon_name(Gtk.EntryIconPosition.PRIMARY,
392                                             "system-search-symbolic")
393        self.__entry.set_icon_tooltip_text(Gtk.EntryIconPosition.PRIMARY, "")
394
395    def __on_entry_focus_in(self, entry, event):
396        """
397            Block entry on uri
398            @param entry as Gtk.Entry
399            @param event as Gdk.Event
400        """
401        if self.__popover.is_visible():
402            return
403        webview = self.__window.container.webview
404        self.__icons.show_clear_button()
405        uri = self.__window.container.webview.uri
406        parsed = urlparse(uri)
407        value = webview.get_current_text_entry()
408        if value:
409            self.set_text_entry(value)
410        elif parsed.scheme in ["http", "https", "file"]:
411            self.set_text_entry(uri)
412            self.__placeholder.set_opacity(0)
413        else:
414            self.set_default_placeholder()
415
416    def __on_entry_focus_out(self, entry, event):
417        """
418            Show title
419            @param entry as Gtk.Entry
420            @param event as Gdk.Event (do not use)
421        """
422        if self.__popover.is_visible():
423            return
424        self.__focus_out()
425
426    def __on_entry_populate_popup(self, entry, menu):
427        """
428            @param entry as Gtk.Entry
429            @param menu as Gtk.Menu
430        """
431        def on_item_activate(item, clipboard):
432            self.__window.container.webview.load_uri(clipboard)
433        clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).wait_for_text()
434        if clipboard is not None:
435            item = Gtk.MenuItem.new_with_label(_("Paste and load"))
436            item.connect("activate", on_item_activate, clipboard)
437            item.show()
438            menu.attach(item, 0, 1, 3, 4)
439
440    def __on_entry_icon_release(self, entry, position, event):
441        """
442            Show popover related to icon
443            @param entry as Gtk.Entry
444            @param position as Gtk.EntryIconPosition
445            @param event as Gdk.EventButton
446        """
447        if self.__entry.get_icon_name(Gtk.EntryIconPosition.PRIMARY) ==\
448                "dialog-password-symbolic":
449            self.__update_secure_content_indicator()
450            from eolie.popover_credentials import CredentialsPopover
451            credentials_popover = CredentialsPopover(self.__window)
452            credentials_popover.set_relative_to(self.__entry)
453            credentials_popover.set_pointing_to(
454                self.__entry.get_icon_area(Gtk.EntryIconPosition.PRIMARY))
455            credentials_popover.popup()
456        else:
457            from eolie.popover_tls import TLSPopover
458            tls_popover = TLSPopover(self.__window)
459            tls_popover.set_relative_to(self.__entry)
460            tls_popover.set_pointing_to(
461                self.__entry.get_icon_area(Gtk.EntryIconPosition.PRIMARY))
462            tls_popover.popup()
463
464    def __on_entry_press(self, x, y, event):
465        """
466            Show popover if hidden
467            @param x as int
468            @param y as int
469            @param event as Gdk.EventButton
470        """
471        # 30 for primary icon
472        if x > 30 and not self.__popover.get_visible():
473            self.__on_entry_focus_in(self.__entry, event)
474            self.__popover.popup("bookmarks")
475
476    def __on_entry_key_pressed(self, controller, keyval, keycode, state):
477        """
478            Forward to popover history listbox if needed
479            @param controller as Gtk.EventControllerKey.
480            @param keyval as int
481            @param keycode as int
482            @param state as Gdk.ModifierType
483        """
484        webview = self.__window.container.webview
485        uri = self.__entry.get_text().lstrip().rstrip()
486
487        # Walk history if Ctrl + [zZ]
488        if state & Gdk.ModifierType.CONTROL_MASK:
489            value = None
490            if keyval == Gdk.KEY_z:
491                value = webview.get_prev_text_entry(uri)
492            elif keyval == Gdk.KEY_Z:
493                value = webview.get_next_text_entry()
494            elif keyval == Gdk.KEY_Return:
495                bounds = self.__entry.get_selection_bounds()
496                if bounds:
497                    current = self.__entry.get_text()[:bounds[0]]
498                else:
499                    current = self.__entry.get_text()
500                value = current + ".com"
501            if value is not None:
502                self.set_text_entry(value)
503                emit_signal(self.__entry, "changed")
504            return
505
506        # Forward event to popover, if not used, handle input
507        forwarded = self.__popover.forward_event(keyval, state)
508        if forwarded:
509            return True
510        else:
511            # Close popover and save current entry
512            if keyval == Gdk.KEY_Escape:
513                self.set_text_entry("")
514                webview.clear_text_entry()
515                GLib.idle_add(self.__window.close_popovers)
516                return True
517            # Close popover, save current entry and load text content
518            elif keyval in [Gdk.KEY_Return, Gdk.KEY_KP_Enter]:
519                webview.clear_text_entry()
520                GLib.idle_add(self.__window.close_popovers)
521                parsed = urlparse(uri)
522                # Search a missing scheme
523                if uri.find(".") != -1 and\
524                        uri.find(" ") == -1 and\
525                        parsed.scheme not in ["http", "https"]:
526                    # Add missing www.
527                    if not uri.startswith("www."):
528                        db_uri = App().history.get_match("www." + uri)
529                        if db_uri is not None:
530                            uri = "www." + uri
531                    # Add missing scheme
532                    db_uri = App().history.get_match(uri, True)
533                    if db_uri is None:
534                        uri = "http://" + uri
535                    else:
536                        uri = "https://" + uri
537                self.__window.container.load_uri(uri)
538                self.__window.container.set_expose(False)
539                if self.__entry_changed_id is not None:
540                    GLib.source_remove(self.__entry_changed_id)
541                    self.__entry_changed_id = None
542                webview.grab_focus()
543                return True
544
545    def _handle_width_allocate(self, allocation):
546        """
547            @param allocation as Gtk.Allocation
548            @return True if allocation is valid
549        """
550        if SizeAllocationHelper._handle_width_allocate(self, allocation):
551            style = self.__entry.get_style_context()
552            if Gtk.Widget.get_default_direction() == Gtk.TextDirection.RTL:
553                state = Gtk.StateFlags.NORMAL | Gtk.StateFlags.DIR_RTL
554            else:
555                state = Gtk.StateFlags.NORMAL | Gtk.StateFlags.DIR_LTR
556            border = style.get_border(state).bottom
557            padding_start = style.get_padding(state).left
558            padding_end = style.get_padding(state).right
559            margin_start = style.get_margin(state).left
560            margin_end = style.get_margin(state).right
561            margin_bottom = style.get_margin(state).bottom
562            css = ".progressbar { margin-bottom: %spx;\
563                   margin-left: %spx;\
564                   margin-right: %spx; }" % (margin_bottom,
565                                             margin_start + border,
566                                             margin_end + border)
567            # 5 is grid margin (see ui file)
568            uribar_padding = allocation.width + 5
569            css += ".uribar { padding-right: %spx; }" % (uribar_padding)
570            css += ".uribar:dir(rtl)"\
571                   "{ padding-left: %spx;padding-right: %spx}" %\
572                   (uribar_padding, padding_end)
573            # 22 is Gtk.EntryIconPosition.PRIMARY
574            if Gtk.Widget.get_default_direction() == Gtk.TextDirection.RTL:
575                placeholder_margin = padding_end + 22 + border
576            else:
577                placeholder_margin = padding_start + 22 + border
578            css += ".placeholder {margin-left: %spx;}" % placeholder_margin
579            css += ".placeholder:dir(rtl)"\
580                   "{margin-right: %spx;\
581                     margin-left: 0px;}" % placeholder_margin
582            self.__css_allocation.load_from_data(css.encode("utf-8"))
583            self.update_style()
584