1 /*
2 This file is part of sirula.
3 
4 Copyright (C) 2020 Dorian Rudolph
5 
6 sirula is free software: you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation, either version 3 of the License, or
9 (at your option) any later version.
10 
11 sirula is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14 GNU General Public License for more details.
15 
16 You should have received a copy of the GNU General Public License
17 along with sirula.  If not, see <https://www.gnu.org/licenses/>.
18 */
19 
20 use pango::{Attribute, EllipsizeMode, AttrList};
21 use std::cmp::Ordering;
22 use std::collections::HashMap;
23 use gtk::{IconTheme, ListBoxRow, Label, prelude::*, BoxBuilder, IconLookupFlags, ImageBuilder,
24     Orientation};
25 use fuzzy_matcher::{skim::SkimMatcherV2, FuzzyMatcher};
26 use gio::AppInfo;
27 use glib::shell_unquote;
28 use futures::prelude::*;
29 use crate::locale::string_collate;
30 
31 use super::{clone, consts::*, Config, Field, History};
32 use regex::RegexSet;
33 
34 #[derive(Eq)]
35 pub struct AppEntry {
36     pub display_string: String,
37     pub search_string: String,
38     pub extra_range: Option<(u32, u32)>,
39     pub info: AppInfo,
40     pub label: Label,
41     pub score: i64,
42     pub last_used: u64,
43 }
44 
45 impl AppEntry {
update_match(&mut self, pattern: &str, matcher: &SkimMatcherV2, config: &Config)46     pub fn update_match(&mut self, pattern: &str, matcher: &SkimMatcherV2, config: &Config) {
47         self.set_markup(config);
48 
49         let attr_list = self.label.attributes().unwrap_or(AttrList::new());
50         self.score = if pattern.is_empty() {
51             self.label.set_attributes(None);
52             100
53         } else if let Some((score, indices)) = matcher.fuzzy_indices(&self.search_string, pattern) {
54             for i in indices {
55                 if i < self.display_string.len() {
56                     let i = i as u32;
57                     add_attrs(&attr_list, &config.markup_highlight, i,  i + 1);
58                 }
59             }
60             score
61         } else {
62             0
63         };
64 
65         self.label.set_attributes(Some(&attr_list));
66     }
67 
hide(&mut self)68     pub fn hide(&mut self) {
69         self.score = 0;
70     }
71 
set_markup(&self, config: &Config)72     fn set_markup(&self, config: &Config) {
73         let attr_list = AttrList::new();
74 
75         add_attrs(&attr_list, &config.markup_default, 0, self.display_string.len() as u32);
76         if let Some((lo, hi)) = self.extra_range {
77             add_attrs(&attr_list, &config.markup_extra, lo, hi);
78         }
79         self.label.set_attributes(Some(&attr_list));
80     }
81 }
82 
83 impl PartialEq for AppEntry {
eq(&self, other: &Self) -> bool84     fn eq(&self, other: &Self) -> bool {
85         self.score.eq(&other.score) && self.last_used.eq(&other.last_used)
86     }
87 }
88 
89 impl Ord for AppEntry {
cmp(&self, other: &Self) -> Ordering90     fn cmp(&self, other: &Self) -> Ordering {
91         match self.score.cmp(&other.score) {
92             Ordering::Equal => match self.last_used.cmp(&other.last_used) {
93                 Ordering::Equal => string_collate(&self.display_string, &other.display_string),
94                 ord => ord.reverse()
95             }
96             ord => ord.reverse()
97         }
98     }
99 }
100 
101 impl PartialOrd for AppEntry {
partial_cmp(&self, other: &Self) -> Option<Ordering>102     fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
103         Some(self.cmp(other))
104     }
105 }
106 
get_app_field(app: &AppInfo, field: Field) -> Option<String>107 fn get_app_field(app: &AppInfo, field: Field) -> Option<String> {
108     match field {
109         Field::Comment => app.description().map(Into::into),
110         Field::Id => app.id().and_then(|s| s.to_string().strip_suffix(".desktop").map(Into::into)),
111         Field::IdSuffix => app.id().and_then(|id| {
112             let id = id.to_string();
113             let parts : Vec<&str> = id.split('.').collect();
114             parts.get(parts.len()-2).map(|s| s.to_string())
115         }),
116         Field::Executable => app.executable().file_name()
117             .and_then(|e| shell_unquote(e).ok())
118             .map(|s| s.to_string_lossy().to_string()),
119         //TODO: clean up command line from %
120         Field::Commandline => app.commandline().map(|s| s.to_string_lossy().to_string())
121     }
122 }
123 
add_attrs(list: &AttrList, attrs: &Vec<Attribute>, start: u32, end: u32)124 fn add_attrs(list: &AttrList, attrs: &Vec<Attribute>, start: u32, end: u32) {
125     for attr in attrs {
126         let mut attr = attr.clone();
127         attr.set_start_index(start);
128         attr.set_end_index(end);
129         list.insert(attr);
130     }
131 }
132 
load_entries(config: &Config, history: &History) -> HashMap<ListBoxRow, AppEntry>133 pub fn load_entries(config: &Config, history: &History) -> HashMap<ListBoxRow, AppEntry> {
134     let mut entries = HashMap::new();
135     let icon_theme = IconTheme::default().unwrap();
136     let apps = gio::AppInfo::all();
137     let main_context = glib::MainContext::default();
138     let exclude = RegexSet::new(&config.exclude).expect("Invalid regex");
139 
140     for app in apps {
141         if !app.should_show() {
142             continue
143         }
144 
145         let name = app.display_name().to_string();
146 
147         let id = match app.id() {
148             Some(id) => id.to_string(),
149             _ => continue
150         };
151 
152         if exclude.is_match(&id) {
153             continue
154         }
155 
156         let (display_string, extra_range) = if let Some(name)
157                 = get_app_field(&app, Field::Id).and_then(|id| config.name_overrides.get(&id)) {
158             let i = name.find('\r');
159             (name.replace('\r', " "), i.map(|i| (i as u32 +1, name.len() as u32)))
160         } else {
161             let extra = config.extra_field.get(0).and_then(|f| get_app_field(&app, *f));
162             match extra {
163                 Some(e) if (!config.hide_extra_if_contained || !name.to_lowercase().contains(&e.to_lowercase())) => (format!("{} {}", name, e),
164                     Some((name.len() as u32 + 1, name.len() as u32 + 1 + e.len() as u32))),
165                 _ => (name, None)
166             }
167         };
168 
169         let hidden = config.hidden_fields.iter()
170             .map(|f| get_app_field(&app, *f).unwrap_or_default())
171             .collect::<Vec<String>>().join(" ");
172 
173         let search_string = if hidden.is_empty() {
174             display_string.clone()
175         } else {
176             format!("{} {}", display_string, hidden)
177         };
178 
179         let label = gtk::LabelBuilder::new()
180             .xalign(0.0f32)
181             .label(&display_string)
182             .wrap(true)
183             .ellipsize(EllipsizeMode::End)
184             .lines(config.lines)
185             .build();
186         label.style_context().add_class(APP_LABEL_CLASS);
187 
188         let image = ImageBuilder::new().pixel_size(config.icon_size).build();
189         if let Some(icon) = app
190             .icon()
191             .and_then(|icon| icon_theme.lookup_by_gicon(&icon, config.icon_size, IconLookupFlags::FORCE_SIZE))
192         {
193             main_context.spawn_local(icon.load_icon_async_future().map(
194                 clone!(image => move |pb| {
195                     if let Ok(pb) = pb {
196                         image.set_from_pixbuf(Some(&pb));
197                     }
198                 }),
199             ));
200         }
201         image.style_context().add_class(APP_ICON_CLASS);
202 
203         let hbox = BoxBuilder::new()
204             .orientation(Orientation::Horizontal)
205             .build();
206         hbox.pack_start(&image, false, false, 0);
207         hbox.pack_end(&label, true, true, 0);
208 
209         let row = ListBoxRow::new();
210         row.add(&hbox);
211         row.style_context().add_class(APP_ROW_CLASS);
212 
213         let last_used = if config.recent_first {
214             history.last_used.get(&id).copied().unwrap_or_default()
215         } else { 0 };
216 
217         let app_entry = AppEntry {
218             display_string,
219             search_string,
220             extra_range,
221             info: app,
222             label,
223             score: 100,
224             last_used,
225         };
226         app_entry.set_markup(config);
227         entries.insert(row, app_entry);
228     }
229     entries
230 }
231