├── .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 | ![](fari.gif) 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 --------------------------------------------------------------------------------