├── README.md ├── devdocs.png └── devdocs.py /README.md: -------------------------------------------------------------------------------- 1 | # DevDocs Shell 2 | 3 | A shell for [DevDocs](http://devdocs.io). 4 | 5 | ![Screenshot](http://i.imgur.com/W6Iw0ux.png) 6 | ![Screenshot](http://i.imgur.com/HL1zdXB.png) 7 | ![Screenshot](http://i.imgur.com/wSUny4b.png) 8 | 9 | ## Motivation 10 | 11 | I liked [DevDocs](http://devdocs.io) and wanted to use it from 12 | [VIM](http://www.vim.org). I've tried browser automation with Chromium 13 | and separate profile with `--remote-shell-port`, but that didn't work 14 | out so I made this script. 15 | 16 | ## Prerequisites 17 | 18 | * Python 2.7+ or 3+ 19 | * WebKit2Gtk 20 | * Gtk3 along with Python bindings (the new ones: GObject Inrospection) 21 | 22 | ## Usage 23 | 24 | Just run `devdocs.py`. If you provide an argument then first argument 25 | will be searched (all other arguments are ignored). 26 | 27 | ## VIM integration 28 | 29 | Just add this to your .vimrc: 30 | 31 | ``` 32 | command! -nargs=? DevDocs :call system('devdocs.py &') 33 | 34 | au FileType python,ruby,javascript,html,php,eruby,coffee nmap K :exec "DevDocs " . fnameescape(expand('')) 35 | 36 | ``` 37 | 38 | You'll have a command `DevDocs` and supported languages with use it for 39 | providing help (K in normal mode, usually runs `man`). 40 | 41 | **NOTE:** `devdocs.py` doesn't go to background hence one needs to use 42 | `&` (shell background job) and its not supported on Win32. 43 | 44 | ## TODO 45 | 46 | * After https://bugs.webkit.org/show_bug.cgi?id=127410 make sure HTML5 47 | database is in app config directory. 48 | * Maybe add tabs to open multiple terms? 49 | -------------------------------------------------------------------------------- /devdocs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/naquad/devdocs-shell/1e7b8122c3239b2fb8ad1ac5f87712d54c9220aa/devdocs.png -------------------------------------------------------------------------------- /devdocs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from gi.repository import Gtk, WebKit2, GLib, Gdk, Gio, GdkPixbuf 4 | import json 5 | import os 6 | 7 | try: 8 | from urllib.parse import quote 9 | except ImportError: 10 | from urllib import quote 11 | 12 | class Search(Gtk.HBox): 13 | "Search bar implementation." 14 | 15 | CSS = """ 16 | GtkSearchEntry#search_failed { 17 | background: #ff6666; 18 | color: #111; 19 | } 20 | """.encode('UTF-8') 21 | 22 | def __init__(self, controller): 23 | Gtk.HBox.__init__(self, False) 24 | self.controller = controller 25 | self.setup_ui() 26 | self.setup_css() 27 | 28 | self.controller.connect('failed-to-find-text', self.on_fail) 29 | self.controller.connect('found-text', self.on_found) 30 | 31 | def setup_css(self): 32 | provider = Gtk.CssProvider() 33 | provider.load_from_data(self.CSS) 34 | self.get_style_context().\ 35 | add_provider_for_screen( 36 | Gdk.Screen.get_default(), 37 | provider, 38 | Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION 39 | ) 40 | 41 | def setup_ui(self): 42 | self.entry = Gtk.SearchEntry() 43 | self.entry.connect('key-press-event', self.on_keypress) 44 | self.entry.connect_after('notify::text', self.on_search) 45 | self.entry.show() 46 | self.pack_start(self.entry, False, False, 0) 47 | 48 | btn = Gtk.Button.new_from_icon_name(Gtk.STOCK_GO_DOWN, Gtk.IconSize.BUTTON) 49 | btn.connect('clicked', self.on_next) 50 | btn.show() 51 | self.pack_start(btn, False, False, 0) 52 | 53 | btn = Gtk.Button.new_from_icon_name(Gtk.STOCK_GO_UP, Gtk.IconSize.BUTTON) 54 | btn.connect('clicked', self.on_prev) 55 | btn.show() 56 | self.pack_start(btn, False, False, 0) 57 | 58 | btn = Gtk.Button.new_from_icon_name(Gtk.STOCK_CLOSE, Gtk.IconSize.BUTTON) 59 | btn.connect('clicked', self.toggle) 60 | btn.show() 61 | self.pack_start(btn, False, False, 0) 62 | 63 | def toggle(self, *unused): 64 | "show/hide search bar" 65 | 66 | if self.is_visible(): 67 | self.controller.search_finish() 68 | self.hide() 69 | else: 70 | self.show() 71 | self.entry.select_region(0, -1) 72 | self.entry.grab_focus() 73 | self.on_search() 74 | 75 | def on_search(self, *unused): 76 | text = self.entry.get_text() 77 | if text: 78 | self.search_text(text) 79 | 80 | def on_keypress(self, entry, evt): 81 | if evt.keyval == Gdk.KEY_Escape: 82 | self.toggle() 83 | elif evt.keyval == Gdk.KEY_Return or evt.keyval == Gdk.KEY_KP_Enter: 84 | if evt.state & Gdk.ModifierType.SHIFT_MASK != 0: 85 | self.controller.search_previous() 86 | else: 87 | self.controller.search_next() 88 | 89 | def search_text(self, text): 90 | self.controller.search(text, WebKit2.FindOptions.CASE_INSENSITIVE | WebKit2.FindOptions.WRAP_AROUND, GLib.MAXINT32) 91 | 92 | def on_found(self, ctrl, cnt): 93 | self.entry.set_name('search') 94 | 95 | def on_fail(self, finder): 96 | self.entry.set_name('search_failed') 97 | 98 | def on_next(self, btn): 99 | self.controller.search_next() 100 | 101 | def on_prev(self, btn): 102 | self.controller.search_previous() 103 | 104 | 105 | class MainWindow(Gtk.Window): 106 | BASE_URL = 'http://devdocs.io' 107 | DEFAULT_TITLE = 'DevDocs' 108 | 109 | def __init__(self, app): 110 | Gtk.Window.__init__(self, Gtk.WindowType.TOPLEVEL) 111 | self.settings = app 112 | self.setup_ui() 113 | 114 | def navigate(self, term=None): 115 | url = self.BASE_URL 116 | 117 | if term is not None: 118 | url = '%s#q=%s' % (url, quote(term)) 119 | 120 | self.web_view.load_uri(url) 121 | 122 | def setup_ui(self): 123 | self.set_title(self.DEFAULT_TITLE) 124 | 125 | self.logo = GdkPixbuf.Pixbuf.new_from_file(os.path.join(self.settings.app_dir, 'devdocs.png')) 126 | self.set_icon(self.logo) 127 | 128 | # WebKit2 WebView setup. 129 | # TODO: after https://bugs.webkit.org/show_bug.cgi?id=127410 130 | # make database to be stored in app config dir 131 | 132 | self.web_view = WebKit2.WebView() 133 | self.web_view.get_context().\ 134 | get_cookie_manager().\ 135 | set_persistent_storage( 136 | self.settings.cookie_path, 137 | WebKit2.CookiePersistentStorage.SQLITE 138 | ) 139 | self.web_view.show() 140 | 141 | layout = Gtk.VBox() 142 | 143 | toolbar = Gtk.Toolbar() 144 | 145 | button = Gtk.ToolButton(Gtk.STOCK_HOME) 146 | button.connect('clicked', self.on_home) 147 | tool_item = Gtk.ToolItem() 148 | tool_item.add(button) 149 | toolbar.insert(tool_item, -1) 150 | 151 | button = Gtk.ToolButton(Gtk.STOCK_COPY) 152 | button.connect('clicked', self.on_copy) 153 | tool_item = Gtk.ToolItem() 154 | tool_item.add(button) 155 | toolbar.insert(tool_item, -1) 156 | 157 | self.address = Gtk.Entry() 158 | self.address.set_editable(False) 159 | tool_item = Gtk.ToolItem() 160 | tool_item.set_expand(True) 161 | tool_item.add(self.address) 162 | 163 | toolbar.insert(tool_item, -1) 164 | 165 | self.back = Gtk.ToolButton(Gtk.STOCK_GO_BACK) 166 | self.back.connect('clicked', self.on_back) 167 | self.back.set_sensitive(False) 168 | tool_item = Gtk.ToolItem() 169 | tool_item.add(self.back) 170 | toolbar.insert(tool_item, -1) 171 | 172 | self.forward = Gtk.ToolButton(Gtk.STOCK_GO_FORWARD) 173 | self.forward.connect('clicked', self.on_forward) 174 | self.forward.set_sensitive(False) 175 | tool_item = Gtk.ToolItem() 176 | tool_item.add(self.forward) 177 | toolbar.insert(tool_item, -1) 178 | 179 | button = Gtk.ToolButton(Gtk.STOCK_REFRESH) 180 | button.connect('clicked', self.on_refresh) 181 | tool_item = Gtk.ToolItem() 182 | tool_item.add(button) 183 | toolbar.insert(tool_item, -1) 184 | 185 | accel = Gtk.AccelGroup() 186 | self.add_accel_group(accel) 187 | 188 | button = Gtk.ToolButton(Gtk.STOCK_FIND) 189 | button.connect('clicked', self.on_search) 190 | 191 | key, mod = Gtk.accelerator_parse("f") 192 | button.add_accelerator('clicked', accel, key, mod, Gtk.AccelFlags.VISIBLE) 193 | 194 | tool_item = Gtk.ToolItem() 195 | tool_item.add(button) 196 | toolbar.insert(tool_item, -1) 197 | 198 | button = Gtk.ToolButton(Gtk.STOCK_ABOUT) 199 | button.connect('clicked', self.on_about) 200 | tool_item = Gtk.ToolItem() 201 | tool_item.add(button) 202 | toolbar.insert(tool_item, -1) 203 | 204 | toolbar.show_all() 205 | 206 | layout.pack_start(toolbar, False, True, 0) 207 | 208 | overlay = Gtk.Overlay() 209 | overlay.add(self.web_view) 210 | 211 | self.link_address = Gtk.Label() 212 | self.link_address.set_halign(Gtk.Align.START) 213 | self.link_address.set_valign(Gtk.Align.END) 214 | self.link_address.show() 215 | overlay.add_overlay(self.link_address) 216 | 217 | self.search = Search(self.web_view.get_find_controller()) 218 | self.search.set_halign(Gtk.Align.END) 219 | self.search.set_valign(Gtk.Align.START) 220 | self.search.set_margin_right(50) 221 | overlay.add_overlay(self.search) 222 | overlay.show() 223 | 224 | layout.pack_start(overlay, True, True, 0) 225 | layout.show() 226 | 227 | self.add(layout) 228 | 229 | self.set_default_size(self.settings.width, self.settings.height) 230 | self.move(self.settings.left, self.settings.top) 231 | 232 | if self.settings.maximized: 233 | self.maximize() 234 | 235 | if self.settings.fullscreen: 236 | self.fullscreen() 237 | 238 | self.show() 239 | 240 | self.connect('delete-event', self.on_exit) 241 | self.web_view.connect('decide-policy', self.on_navigate, False) 242 | self.web_view.connect('create', self.on_create) 243 | self.web_view.connect('mouse-target-changed', self.on_link_url) 244 | 245 | self.web_view.connect_after('notify::title', self.on_title) 246 | self.web_view.connect_after('notify::uri', self.on_uri) 247 | 248 | def open_in_browser(self, url): 249 | Gtk.show_uri(None, url, Gdk.CURRENT_TIME) 250 | 251 | def on_search(self, btn): 252 | self.search.toggle() 253 | 254 | def on_link_url(self, view, hit, modifiers): 255 | link = hit.get_link_uri() 256 | 257 | if link: 258 | self.link_address.set_text(link) 259 | self.link_address.show() 260 | else: 261 | self.link_address.hide() 262 | 263 | def on_home(self, btn): 264 | self.web_view.load_uri(self.BASE_URL) 265 | 266 | def on_copy(self, btn): 267 | text = self.address.get_text() 268 | Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD).set_text(text, len(text)) 269 | 270 | def on_back(self, btn): 271 | self.web_view.go_back() 272 | 273 | def on_forward(self, btn): 274 | self.web_view.go_forward() 275 | 276 | def on_refresh(self, btn): 277 | self.web_view.reload() 278 | 279 | def on_about(self, btn): 280 | about = Gtk.AboutDialog() 281 | about.set_program_name("DevDocs Shell") 282 | about.set_version('1.0') 283 | about.set_copyright('2014') 284 | about.set_license_type(Gtk.License.BSD) 285 | about.set_website('https://github.com/naquad/devdocs-shell') 286 | about.set_website_label('DevDocs Shell home') 287 | about.set_authors(['Naquad ']) 288 | about.set_logo(self.logo) 289 | about.set_comments('A GTK+ shell for http://devdocs.io') 290 | about.set_transient_for(self) 291 | about.run() 292 | about.destroy() 293 | 294 | def on_title(self, view, title): 295 | self.set_title(view.get_property('title') or self.DEFAULT_TITLE) 296 | 297 | def on_uri(self, view, url): 298 | self.address.set_text(view.get_uri()) 299 | self.back.set_sensitive(view.can_go_back()) 300 | self.forward.set_sensitive(view.can_go_forward()) 301 | 302 | def on_create(self, view): 303 | view = WebKit2.WebView() 304 | view.connect('decide-policy', self.on_navigate, True) 305 | return view 306 | 307 | def on_navigate(self, view, decision, dtype, always): 308 | if dtype == WebKit2.PolicyDecisionType.RESPONSE: 309 | return False 310 | 311 | url = decision.get_request().get_uri() 312 | navtype = decision.get_property('navigation-type') 313 | 314 | if always or dtype == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or \ 315 | (navtype != WebKit2.NavigationType.OTHER and not url.startswith(self.BASE_URL)): 316 | self.open_in_browser(url) 317 | decision.ignore() 318 | return True 319 | 320 | return False 321 | 322 | def on_exit(self, window, evt): 323 | window_state = self.get_window().get_state() 324 | width, height = self.get_size() 325 | left, top = self.get_position() 326 | 327 | self.settings.save_state({ 328 | 'width': width, 329 | 'height': height, 330 | 'top': top, 331 | 'left': left, 332 | 'maximized': bool(window_state & Gdk.WindowState.MAXIMIZED), 333 | 'fullscreen': bool(window_state & Gdk.WindowState.FULLSCREEN) 334 | }) 335 | 336 | class Application(Gtk.Application): 337 | APP_NAME = 'devdocs' 338 | APP_ID = 'me.naquad.%s' % APP_NAME 339 | 340 | DEFAULT_SETTINGS = { 341 | 'width': 800, 342 | 'height': 600, 343 | 'top': 0, 344 | 'left': 0, 345 | 'maximized': False, 346 | 'fullscreen': False 347 | } 348 | 349 | def __init__(self): 350 | Gtk.Application.__init__(self, application_id=self.APP_ID, flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE) 351 | 352 | self.base_path = os.path.join(GLib.get_user_config_dir(), self.APP_NAME) 353 | self.cookie_path = os.path.join(self.base_path, 'cookies.db') 354 | self.db_path = os.path.join(self.base_path, 'storage.db') 355 | self.config_path = os.path.join(self.base_path, 'state.json') 356 | self.config = self.DEFAULT_SETTINGS.copy() 357 | 358 | if not os.path.exists(self.base_path): 359 | os.makedirs(self.base_path) 360 | 361 | if os.path.exists(self.config_path): 362 | with open(self.config_path) as f: 363 | self.config.update(json.load(f)) 364 | 365 | self.connect('activate', self.on_activate) 366 | self.connect('command-line', self.on_command_line) 367 | 368 | def navigate(self, term=None): 369 | self.get_active_window().navigate(term) 370 | 371 | def on_activate(self, app=None): 372 | window = MainWindow(self) 373 | window.navigate() 374 | self.add_window(window) 375 | 376 | def on_command_line(self, app, cmd): 377 | if not self.get_active_window() and not self.get_is_remote(): 378 | self.activate() 379 | else: 380 | self.get_active_window().present() 381 | 382 | args = cmd.get_arguments() 383 | term = args[1] if len(args) > 1 else None 384 | self.navigate(term) 385 | 386 | return 0 387 | 388 | def save_state(self, state): 389 | self.config.update(state) 390 | with open(self.config_path, 'w') as f: 391 | json.dump(self.config, f) 392 | 393 | @property 394 | def app_dir(self): 395 | return os.path.dirname(os.path.realpath(__file__)) 396 | 397 | def __getattr__(self, name): 398 | if name in self.DEFAULT_SETTINGS: 399 | return self.config[name] 400 | 401 | raise AttributeError('no property %s' % (name,)) 402 | 403 | 404 | if __name__ == '__main__': 405 | import sys 406 | app = Application() 407 | app.register(None) 408 | sys.exit(app.run(sys.argv)) 409 | --------------------------------------------------------------------------------