├── .gitignore
├── README.md
├── fari
├── fari.gif
└── fari.png
/.gitignore:
--------------------------------------------------------------------------------
1 | data
2 | inspiration
3 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # fari
2 |
3 | fari is a console application for quickly browsing, searching, and opening Safari tabs. I find it faster to navigate things with the keyboard, and fari is even faster than using Safari's built-in tab navigation shortcuts.
4 |
5 | If you care, it's written in Python 3 and should work out of the box on any modern Mac.
6 |
7 | ## Screenshot GIF
8 |
9 | 
10 |
11 | ## Overview video
12 |
13 |
14 |
15 | ## Usage
16 |
17 | Keystrokes are labeled in the application itself, but for reference:
18 |
19 | `0` through `9`: Open a tab from the current page
20 |
21 | `<` & `>`: Page back/forward through tabs
22 |
23 | `/`: Search all tabs (`` to exit search, `Fn-Delete to backspace`)
24 |
25 | `q`: Quit
26 |
27 | `↑` & `↓`: Move selection through current page of tabs
28 |
29 | `→`: Open currently selected tab
30 |
31 | ## Future plans
32 |
33 | - Fix bugs & quirks.
34 | - Allow closing & rearrangement of tabs.
35 | - Allow splitting & combining of windows.
36 | - Allow browsing, pulling from, and pushing to iCloud device tabs (i.e. other Safari instances of yours).
37 |
--------------------------------------------------------------------------------
/fari:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import argparse
4 | import curses
5 | import json
6 | import re
7 | import subprocess
8 | import sys
9 |
10 | tabs = []
11 |
12 | def get_tab_count():
13 | capture = subprocess.run([
14 | 'osascript',
15 | '-e', 'tell app "Safari"',
16 | '-e', ' get count of tabs of window 1',
17 | '-e', 'end tell'
18 | ], capture_output=True)
19 | if capture.stderr:
20 | return 0
21 | else:
22 | return int(capture.stdout.decode('utf-8').strip())
23 |
24 | def load_tabs():
25 | global tabs
26 | tabs = []
27 | capture = subprocess.run([
28 | 'osascript', '-s', 's',
29 | '-e', 'tell app "Safari"',
30 | '-e', ' get {name, URL} of tabs of window 1',
31 | '-e', 'end tell'
32 | ], capture_output=True)
33 | if not capture.stderr:
34 | details = capture.stdout.decode('utf-8').strip()
35 | details = re.sub('},\s{', '],[',
36 | re.sub('}}$', ']]',
37 | re.sub('^{{', '[[',
38 | re.sub('missing value', '"(blank tab)"', details))))
39 | details_json = json.loads(details)
40 | for i in range(len(details_json[0])):
41 | tabs.append({'name': details_json[0][i], 'url': details_json[1][i]})
42 |
43 | def get_display_count(s):
44 | return min(args.count, s.getmaxyx()[0] - 5)
45 |
46 | def paint_urls(s, tabs, first, highlighted=-1):
47 | x = s.getmaxyx()[1]
48 | lines = get_display_count(s)
49 | count = len(tabs)
50 | show = lines if count - first >= lines else count - first
51 | show_range = f"{first + 1}-{first + show} of {count}" if show > 1 else str(show)
52 | s.addstr(
53 | 0, 0,
54 | f"Choose a tab to switch to "
55 | f"[showing {show_range}]:".ljust(x),
56 | curses.A_STANDOUT
57 | )
58 | label_length = round(x / 2)
59 | url_length = x - label_length - 5
60 | for clear_row in range(lines):
61 | s.hline(clear_row + 2, 0, ' ', x)
62 | for row in range(show):
63 | label = tabs[first + row]['name']
64 | label = label[:label_length].ljust(label_length)
65 | url = tabs[first + row]['url']
66 | url = re.sub('^https?://(www\.)?', '', url)
67 | url = re.sub('/$', '', url)
68 | url = url[:url_length]
69 | base = f"{row}. ".ljust(4) + label + ' '
70 | if row == highlighted:
71 | s.addstr(
72 | row + 2, 0,
73 | base + url.ljust(url_length),
74 | curses.A_STANDOUT
75 | )
76 | else:
77 | s.addstr(row + 2, 0, base)
78 | s.addstr(url, curses.A_UNDERLINE)
79 | last = f"-{min(show - 1, 9)}" if show >= 2 else ''
80 | toolbar_row = lines + 3
81 | s.hline(toolbar_row, 0, ' ', x)
82 | s.addstr(
83 | toolbar_row, 0,
84 | f"[↑↓: Nav] "
85 | f"[0{last} →: Go] "
86 | f"[<: Prev] "
87 | f"[>: Next] "
88 | f"[/: ?] "
89 | f"[q: Quit]".ljust(x),
90 | curses.A_STANDOUT
91 | )
92 | s.refresh()
93 | return (count, show)
94 |
95 | def open_url(url):
96 | capture = subprocess.run(['open', url], capture_output=True)
97 |
98 | def nav_up_down(key, highlighted, last):
99 | if highlighted == -1:
100 | highlighted = 0 if key == 'KEY_DOWN' else last
101 | elif highlighted > 0 and key == 'KEY_UP':
102 | highlighted -= 1
103 | elif highlighted == 0 and key == 'KEY_UP':
104 | highlighted = last
105 | elif highlighted < last and key == 'KEY_DOWN':
106 | highlighted += 1
107 | elif highlighted == last and key == 'KEY_DOWN':
108 | highlighted = 0
109 | return highlighted
110 |
111 | def main(s):
112 | s.timeout(3000)
113 | first_pass = True
114 | while True:
115 | tab_count = get_tab_count()
116 | if tab_count == 0:
117 | s.clear()
118 | (y, x) = s.getmaxyx()
119 | message = 'No open tabs!'
120 | s.addstr(round(y / 2) - 1, 0, message.center(x))
121 | next
122 | prompt_line = get_display_count(s) + 4
123 | key = None
124 | if first_pass:
125 | first_pass = False
126 | else:
127 | try:
128 | key = s.getkey(prompt_line, 0)
129 | except:
130 | pass
131 | if tab_count != len(tabs):
132 | load_tabs()
133 | first = 0
134 | highlighted = -1
135 | s.clear()
136 | (count, show) = paint_urls(s, tabs, first, highlighted)
137 | if key == '<':
138 | first -= get_display_count(s)
139 | first = 0 if first < 0 else first
140 | highlighted = -1
141 | elif key == '>':
142 | lines = get_display_count(s)
143 | if first + lines < count:
144 | first += lines
145 | highlighted = -1
146 | elif key == 'q':
147 | break
148 | elif key == 'KEY_UP' or key == 'KEY_DOWN':
149 | highlighted = nav_up_down(key, highlighted, show - 1)
150 | elif key == 'KEY_RIGHT' and highlighted > -1:
151 | open_url(tabs[first + highlighted]['url'])
152 | elif key == '/':
153 | highlighted = -1
154 | (count, show) = paint_urls(s, tabs, 0)
155 | old_count = count
156 | old_first = first
157 | s.addstr('Search [space to exit]:', curses.A_STANDOUT)
158 | s.addstr(' ')
159 | searching = True
160 | term = ''
161 | pos = s.getyx()
162 | while searching:
163 | key = None
164 | try:
165 | key = s.getkey()
166 | except:
167 | pass
168 | if (key == 'KEY_DC' or key =='KEY_LEFT') and len(term) > 0:
169 | term = term[0:len(term)-1]
170 | s.addstr(pos[0], pos[1] + len(term), ' ')
171 | s.move(pos[0], pos[1] + len(term))
172 | elif key == 'KEY_UP' or key == 'KEY_DOWN':
173 | highlighted = nav_up_down(key, highlighted, show - 1)
174 | elif key == 'KEY_RIGHT' and highlighted > -1:
175 | visible_tabs = search_tabs if search_tabs else tabs
176 | open_url(visible_tabs[first + highlighted]['url'])
177 | elif key == ' ':
178 | searching = False
179 | highlighted = -1
180 | elif (key and re.match('KEY_', key)) or key == '>':
181 | pass
182 | elif key and re.match('[a-z0-9/=-_#\.\?]', key):
183 | term += key
184 | s.addstr(pos[0], pos[1] + len(term) - 1, key)
185 | highlighted = -1
186 | if len(term) > 0:
187 | term = term.lower()
188 | search_tabs = []
189 | for tab in tabs:
190 | if tab['name'].lower().find(term) > -1 or re.sub('^https?://(www\.)?', '', tab['url']).lower().find(term) > -1:
191 | search_tabs.append(tab)
192 | (count, show) = paint_urls(s, search_tabs, 0, highlighted)
193 | s.hline(prompt_line, 0, ' ', s.getmaxyx()[1])
194 | count = old_count
195 | first = old_first
196 | elif key and re.match('[0-9]', key):
197 | open_url(tabs[first + int(key)]['url'])
198 | if key and tab_count:
199 | (count, show) = paint_urls(s, tabs, first, highlighted)
200 |
201 | parser = argparse.ArgumentParser()
202 | parser.add_argument(
203 | '-c',
204 | action='store',
205 | help='number of tabs to show per page',
206 | dest='count',
207 | type=int,
208 | metavar='COUNT',
209 | default=sys.maxsize
210 | )
211 | args = parser.parse_args()
212 | curses.wrapper(main)
--------------------------------------------------------------------------------
/fari.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/incanus/fari/aedc8c588435c6dc59afca3951e133d628ca8cbe/fari.gif
--------------------------------------------------------------------------------
/fari.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/incanus/fari/aedc8c588435c6dc59afca3951e133d628ca8cbe/fari.png
--------------------------------------------------------------------------------