1# Copyright (c) 2011 John Stowers
2# SPDX-License-Identifier: GPL-3.0+
3# License-Filename: LICENSES/GPL-3.0
4
5import os.path
6import subprocess
7import logging
8
9from gi.repository import Gtk, Gdk, GLib, Gio, GObject
10
11from gtweak.tweakmodel import Tweak
12from gtweak.widgets import ListBoxTweakGroup, UI_BOX_SPACING
13from gtweak.utils import AutostartManager, AutostartFile
14
15def _image_from_gicon(gicon):
16    image = Gtk.Image.new_from_gicon(gicon, Gtk.IconSize.DIALOG)
17    (_, _, h) = Gtk.IconSize.lookup(Gtk.IconSize.DIALOG)
18    image.set_pixel_size(h)
19    return image
20
21def _list_header_func(row, before, user_data):
22    if before and not row.get_header():
23        row.set_header (Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL))
24
25
26class AutostartTitle(Gtk.Box, Tweak):
27
28    def __init__(self, **options):
29        Gtk.Box.__init__(self)
30        desc = _("Startup applications are automatically started when you log in.")
31        Tweak.__init__(self, _("Startup Applications"), desc, **options)
32
33        label = Gtk.Label(desc)
34        label.get_style_context().add_class("dim-label")
35        self.props.margin_bottom = 10
36        self.add(label)
37
38
39class _AppChooser(Gtk.Dialog):
40    def __init__(self, main_window, running_exes, startup_apps):
41        uhb = Gtk.Settings.get_default().props.gtk_dialogs_use_header
42        Gtk.Dialog.__init__(self, title=_("Applications"), use_header_bar=uhb)
43
44        self._running = {}
45        self._all = {}
46
47        self.entry = Gtk.SearchEntry(
48                placeholder_text=_("Search Applications…"))
49        self.entry.set_width_chars(30)
50        self.entry.props.activates_default=True
51        if (Gtk.check_version(3, 22, 20) == None):
52            self.entry.set_input_hints(Gtk.InputHints.NO_EMOJI)
53
54        self.searchbar = Gtk.SearchBar()
55        self.searchbar.add(self.entry)
56        self.searchbar.props.hexpand = True
57        # Translators: This is the accelerator for opening the AppChooser search-bar
58        self._search_key, self._search_mods = Gtk.accelerator_parse(_("<primary>f"))
59
60        lb = Gtk.ListBox()
61        lb.props.activate_on_single_click = False
62        lb.set_sort_func(self._sort_apps, None)
63        lb.set_header_func(_list_header_func, None)
64        lb.set_filter_func(self._list_filter_func, None)
65        self.entry.connect("search-changed", self._on_search_entry_changed)
66        lb.connect("row-activated", lambda b, r: self.response(Gtk.ResponseType.OK) if r.get_mapped() else None)
67        lb.connect("row-selected", self._on_row_selected)
68
69        apps = Gio.app_info_get_all()
70        for a in apps:
71            if a.get_id() not in startup_apps:
72                if a.should_show():
73                    running = a.get_executable() in running_exes
74                    w = self._build_widget(
75                        a,
76                        _("running") if running else "")
77                    if w:
78                        self._all[w] = a
79                        self._running[w] = running
80                        lb.add(w)
81
82        sw = Gtk.ScrolledWindow()
83        sw.props.margin = 2
84        sw.props.hscrollbar_policy = Gtk.PolicyType.NEVER
85        sw.add(lb)
86
87        self.add_button(_("_Close"), Gtk.ResponseType.CANCEL)
88        self.add_button(_("_Add"), Gtk.ResponseType.OK)
89        self.set_default_response(Gtk.ResponseType.OK)
90
91        if self.props.use_header_bar:
92            searchbtn = Gtk.ToggleButton()
93            searchbtn.props.valign = Gtk.Align.CENTER
94            image = Gtk.Image(icon_name = "edit-find-symbolic", icon_size = Gtk.IconSize.MENU)
95            searchbtn.add(image)
96            context = searchbtn.get_style_context()
97            context.add_class("image-button")
98            context.remove_class("text-button")
99            self.get_header_bar().pack_end(searchbtn)
100            self._binding = searchbtn.bind_property("active", self.searchbar, "search-mode-enabled", GObject.BindingFlags.BIDIRECTIONAL)
101
102        self.get_content_area().pack_start(self.searchbar, False, False, 0)
103        self.get_content_area().pack_start(sw, True, True, 0)
104        self.set_modal(True)
105        self.set_transient_for(main_window)
106        self.set_size_request(400,300)
107
108        self.listbox = lb
109
110        self.connect("key-press-event", self._on_key_press)
111
112    def _sort_apps(self, a, b, user_data):
113        arun = self._running.get(a)
114        brun = self._running.get(b)
115
116        if arun and not brun:
117            return -1
118        elif not arun and brun:
119            return 1
120        else:
121            aname = self._all.get(a).get_name()
122            bname = self._all.get(b).get_name()
123
124            if aname < bname:
125                return -1
126            elif aname > bname:
127                return 1
128            else:
129                return 0
130
131    def _build_widget(self, a, extra):
132        row = Gtk.ListBoxRow()
133        g = Gtk.Grid()
134        g.props.margin = 5
135
136        if not a.get_name():
137            return None
138        icn = a.get_icon()
139        if icn:
140            img = _image_from_gicon(icn)
141            g.attach(img, 0, 0, 1, 1)
142            img.props.hexpand = False
143        else:
144             img = None #attach_next_to treats this correctly
145        lbl = Gtk.Label(label=a.get_name(), xalign=0)
146        g.attach_next_to(lbl,img,Gtk.PositionType.RIGHT,1,1)
147        lbl.props.hexpand = True
148        lbl.props.halign = Gtk.Align.START
149        lbl.props.vexpand = False
150        lbl.props.valign = Gtk.Align.CENTER
151        if extra:
152            g.attach_next_to(
153                Gtk.Label(label=extra),
154                lbl,Gtk.PositionType.RIGHT,1,1)
155        row.add(g)
156        #row.get_style_context().add_class('tweak-white')
157        return row
158
159    def _list_filter_func(self, row, unused):
160      txt = self.entry.get_text().lower()
161      grid = row.get_child()
162      for sib in grid.get_children():
163          if type(sib) == Gtk.Label:
164              if txt in sib.get_text().lower():
165                  return True
166      return False
167
168    def _on_search_entry_changed(self, editable):
169        self.listbox.invalidate_filter()
170        selected = self.listbox.get_selected_row()
171        if selected and selected.get_mapped():
172            self.set_response_sensitive(Gtk.ResponseType.OK, True)
173        else:
174            self.set_response_sensitive(Gtk.ResponseType.OK, False)
175
176    def _on_row_selected(self, box, row):
177        if row and row.get_mapped():
178            self.set_response_sensitive(Gtk.ResponseType.OK, True)
179        else:
180            self.set_response_sensitive(Gtk.ResponseType.OK, False)
181
182    def _on_key_press(self, widget, event):
183      mods = event.state & Gtk.accelerator_get_default_mod_mask()
184      if event.keyval == self._search_key and mods == self._search_mods:
185          self.searchbar.set_search_mode(not self.searchbar.get_search_mode())
186          return True
187      keyname = Gdk.keyval_name(event.keyval)
188      if keyname == 'Escape':
189          if self.searchbar.get_search_mode():
190              self.searchbar.set_search_mode(False)
191              return True
192      elif keyname not in ['Up', 'Down']:
193          if not self.entry.is_focus() and self.searchbar.get_search_mode():
194              if self.entry.im_context_filter_keypress(event):
195                  self.entry.grab_focus()
196                  l = self.entry.get_text_length()
197                  self.entry.select_region(l, l)
198                  return True
199
200          return self.searchbar.handle_event(event)
201
202      return False
203
204    def get_selected_app(self):
205        row = self.listbox.get_selected_row()
206        if row:
207            return self._all.get(row)
208        return None
209
210class _StartupTweak(Gtk.ListBoxRow, Tweak):
211    def __init__(self, df, **options):
212
213        Gtk.ListBoxRow.__init__(self)
214        Tweak.__init__(self,
215                        df.get_name(),
216                        df.get_description(),
217                        **options)
218
219        grid = Gtk.Grid(column_spacing=10)
220
221        icn = df.get_icon()
222        if icn:
223            img = _image_from_gicon(icn)
224            grid.attach(img, 0, 0, 1, 1)
225        else:
226            img = None #attach_next_to treats this correctly
227
228        lbl = Gtk.Label(label=df.get_name(), xalign=0.0)
229        grid.attach_next_to(lbl,img,Gtk.PositionType.RIGHT,1,1)
230        lbl.props.hexpand = True
231        lbl.props.halign = Gtk.Align.START
232
233        btn = Gtk.Button(label=_("Remove"))
234        grid.attach_next_to(btn,lbl,Gtk.PositionType.RIGHT,1,1)
235        btn.props.vexpand = False
236        btn.props.valign = Gtk.Align.CENTER
237
238        self.add(grid)
239
240        self.props.margin_start = 1
241        self.props.margin_end = 1
242        self.get_style_context().add_class('tweak-startup')
243
244        self.btn = btn
245        self.app_id = df.get_id()
246        self.connect("key-press-event", self._on_key_press_event)
247
248    def _on_key_press_event(self, row, event):
249        if event.keyval in [Gdk.KEY_Delete, Gdk.KEY_KP_Delete, Gdk.KEY_BackSpace]:
250            self.btn.activate()
251            return True
252        return False
253
254class AddStartupTweak(Gtk.ListBoxRow, Tweak):
255    def __init__(self, **options):
256        Gtk.ListBoxRow.__init__(self)
257        Tweak.__init__(self, _("New startup application"),
258                       _("Add a new application to be run at startup"),
259                       **options)
260
261        img = Gtk.Image()
262        img.set_from_icon_name("list-add-symbolic", Gtk.IconSize.BUTTON)
263        self.btn = Gtk.Button(label="", image=img, always_show_image=True)
264        self.btn.get_style_context().remove_class("button")
265        self.add(self.btn)
266        self.get_style_context().add_class('tweak-startup')
267        self.connect("map", self._on_map)
268        self.connect("unmap", self._on_unmap)
269
270    def _on_map(self, row):
271        toplevel=self.get_toplevel()
272        if toplevel.is_toplevel:
273            for k in [Gdk.KEY_equal, Gdk.KEY_plus, Gdk.KEY_KP_Add]:
274                toplevel.add_mnemonic(k, self.btn)
275
276    def _on_unmap(self, row):
277        toplevel=self.get_toplevel()
278        if toplevel.is_toplevel:
279            for k in [Gdk.KEY_equal, Gdk.KEY_plus, Gdk.KEY_KP_Add]:
280                toplevel.remove_mnemonic(k, self.btn)
281
282class AutostartListBoxTweakGroup(ListBoxTweakGroup):
283    def __init__(self):
284        tweaks = [AutostartTitle()]
285
286        self.asm = AutostartManager()
287        files = self.asm.get_user_autostart_files()
288        for f in files:
289            try:
290                df = Gio.DesktopAppInfo.new_from_filename(f)
291            except TypeError:
292                logging.warning("Error loading desktopfile: %s" % f)
293                continue
294
295            if not AutostartFile(df).is_start_at_login_enabled():
296                continue
297
298            sdf = _StartupTweak(df)
299            sdf.btn.connect("clicked", self._on_remove_clicked, sdf, df)
300            tweaks.append( sdf )
301
302        add = AddStartupTweak()
303        add.btn.connect("clicked", self._on_add_clicked)
304        tweaks.append(add)
305
306        ListBoxTweakGroup.__init__(self,
307            _("Startup Applications"),
308            *tweaks,
309            css_class='tweak-group-startup')
310        self.set_header_func(_list_header_func, None)
311        self.connect("row-activated", lambda b, row: add.btn.activate() if row == add else None)
312
313    def _on_remove_clicked(self, btn, widget, df):
314        self.remove(widget)
315        AutostartFile(df).update_start_at_login(False)
316
317    def _on_add_clicked(self, btn):
318        Gio.Application.get_default().mark_busy()
319        startup_apps = set()
320        self.foreach(lambda row: startup_apps.add(row.app_id) if type(row) is _StartupTweak else None)
321        a = _AppChooser(
322                self.main_window,
323                set(self._get_running_executables()),
324                startup_apps)
325        a.show_all()
326        Gio.Application.get_default().unmark_busy()
327        resp = a.run()
328        if resp == Gtk.ResponseType.OK:
329            df = a.get_selected_app()
330            if df:
331                AutostartFile(df).update_start_at_login(True)
332                sdf = _StartupTweak(df)
333                sdf.btn.connect("clicked", self._on_remove_clicked, sdf, df)
334                self.add_tweak_row(sdf, 0).show_all()
335        a.destroy()
336
337    def _get_running_executables(self):
338        exes = []
339        cmd = subprocess.Popen([
340                    'ps','-e','-w','-w','-U',
341                    str(os.getuid()),'-o','args'],
342                    stdout=subprocess.PIPE)
343        out = cmd.communicate()[0]
344        for l in out.decode('utf8').split('\n'):
345            exe = l.split(' ')[0]
346            if exe and exe[0] != '[': #kernel process
347                exes.append( os.path.basename(exe) )
348
349        return exes
350
351TWEAK_GROUPS = [
352    AutostartListBoxTweakGroup(),
353]
354