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