├── browser ├── tslint.json ├── lib.d.ts ├── tsconfig.json ├── main.ts └── menu.ts ├── resources ├── doc-title.png ├── tilectron.png └── tilectron.svg ├── bower.json ├── .gitignore ├── renderer ├── store.js ├── index.jsx ├── components │ ├── web-page.jsx │ ├── tile.jsx │ ├── omni-input.jsx │ ├── container.jsx │ ├── app.jsx │ ├── address-bar.jsx │ └── start-page.jsx ├── history.js ├── page-actions.js ├── .eslintrc ├── key-handler.js ├── actions.js ├── page-state.js ├── reducers.js └── tile-tree.js ├── .npmignore ├── Guardfile ├── tsd.json ├── index.html ├── bin └── cli.js ├── tests ├── .eslintrc └── renderer │ ├── tile_test.jsx │ ├── start-page_test.jsx │ ├── container_test.jsx │ ├── history_test.js │ └── tile-tree_test.js ├── LICENSE.txt ├── .travis.yml ├── package.json ├── README.md ├── Rakefile └── style └── style.css /browser/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "quotemark": [true, "single"] 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /resources/doc-title.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhysd/Tilectron/HEAD/resources/doc-title.png -------------------------------------------------------------------------------- /resources/tilectron.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rhysd/Tilectron/HEAD/resources/tilectron.png -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tilectron", 3 | "dependencies": { 4 | "photon": "~0.1.2-alpha" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /bower_components 3 | /npm-debug.log 4 | /app.asar 5 | /typings 6 | /build 7 | /tests/renderer/out 8 | -------------------------------------------------------------------------------- /renderer/store.js: -------------------------------------------------------------------------------- 1 | import {createStore} from 'redux'; 2 | import tilectron from './reducers'; 3 | const store = createStore(tilectron); 4 | 5 | export default store; 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /Guardfile 2 | /Rakefile 3 | /tsd.json 4 | /typings 5 | /browser 6 | /renderer 7 | /.git 8 | /.travis.yml 9 | /.gitignore 10 | /tests 11 | /bower.json 12 | -------------------------------------------------------------------------------- /browser/lib.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | declare module GitHubElectron { 4 | interface MenuItemOptions { 5 | role?: string; 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | ignore /^node_modules/, /^build/, /^typings/ 2 | 3 | guard :shell do 4 | watch %r[^browser/.+\.ts$] do |m| 5 | puts "#{Time.now}: #{m[0]}" 6 | system 'rake build_browser_src' 7 | end 8 | 9 | watch %r[^renderer/.+\.jsx?$] do |m| 10 | puts "#{Time.now}: #{m[0]}" 11 | system 'rake build_renderer_src' 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "removeComments": true, 5 | "preserveConstEnums": true, 6 | "outDir": "../build/browser", 7 | "noImplicitAny": true, 8 | "target": "es5", 9 | "sourceMap": true 10 | }, 11 | "files": [ 12 | "menu.ts", 13 | "main.ts", 14 | "lib.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /renderer/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import {Provider} from 'react-redux'; 4 | import App from './components/app.jsx'; 5 | import Store from './store'; 6 | 7 | require('electron-cookies'); 8 | 9 | ReactDOM.render( 10 | 11 | 12 | , 13 | document.querySelector('.app') 14 | ); 15 | -------------------------------------------------------------------------------- /tsd.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v4", 3 | "repo": "borisyankov/DefinitelyTyped", 4 | "ref": "master", 5 | "path": "typings", 6 | "bundle": "typings/tsd.d.ts", 7 | "installed": { 8 | "node/node.d.ts": { 9 | "commit": "0dd29bf8253536ae24e61c109524e924ea510046" 10 | }, 11 | "github-electron/github-electron.d.ts": { 12 | "commit": "0dd29bf8253536ae24e61c109524e924ea510046" 13 | }, 14 | "github-electron/github-electron-main.d.ts": { 15 | "commit": "0dd29bf8253536ae24e61c109524e924ea510046" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tilectron 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /bin/cli.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | 'use strict'; 4 | 5 | var child_process = require('child_process'); 6 | var electron = require('electron-prebuilt'); 7 | var join = require('path').join; 8 | 9 | var argv = process.argv; 10 | 11 | var detach_idx = argv.indexOf('--detach'); 12 | var detached = detach_idx !== -1; 13 | if (detached) { 14 | argv.splice(detach_idx, 1); 15 | } 16 | 17 | argv.unshift(join(__dirname, '..')); 18 | 19 | if (detached) { 20 | child_process.spawn(electron, argv, { 21 | stdio: 'ignore', 22 | detached: true 23 | }).unref(); 24 | } else { 25 | child_process.spawn(electron, argv, { 26 | stdio: 'inherit' 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /browser/main.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as app from 'app'; 3 | import * as BrowserWindow from 'browser-window'; 4 | import setMenu from './menu'; 5 | 6 | const index_html = 'file://' + path.join(__dirname, '..', '..', 'index.html'); 7 | 8 | app.on('ready', () => { 9 | const display_size = require('screen').getPrimaryDisplay().workAreaSize; 10 | 11 | let win = new BrowserWindow({ 12 | width: display_size.width, 13 | height: display_size.height, 14 | 'title-bar-style': 'hidden-inset', 15 | }); 16 | 17 | win.on('closed', () => { 18 | win = null; 19 | app.quit(); 20 | }); 21 | 22 | win.loadUrl(index_html); 23 | 24 | setMenu(); 25 | }); 26 | -------------------------------------------------------------------------------- /renderer/components/web-page.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | 3 | export default class WebPage extends Component { 4 | componentDidMount() { 5 | this.mountWebView(); 6 | } 7 | 8 | componentDidUpdate() { 9 | this.mountWebView(); 10 | } 11 | 12 | mountWebView() { 13 | const {focused, webview} = this.props; 14 | this.refs.body.appendChild(webview); 15 | if (focused) { 16 | webview.focus(); 17 | } 18 | } 19 | 20 | render() { 21 | return ( 22 |
23 | ); 24 | } 25 | } 26 | 27 | WebPage.propTypes = { 28 | focused: PropTypes.bool, 29 | webview: PropTypes.instanceOf(HTMLElement) 30 | }; 31 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 2, 5 | 4 6 | ], 7 | "quotes": [ 8 | 2, 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "semi": [ 16 | 2, 17 | "always" 18 | ], 19 | "jsx-quotes": 2, 20 | "mocha/no-exclusive-tests": 2, 21 | "react/jsx-uses-react": 2, 22 | "react/jsx-uses-vars": 2, 23 | }, 24 | "env": { 25 | "mocha": true, 26 | "es6": true, 27 | "node": true, 28 | "browser": true 29 | }, 30 | "extends": "eslint:recommended", 31 | "ecmaFeatures": { 32 | "modules": true, 33 | "jsx": true, 34 | "experimentalObjectRestSpread": true 35 | }, 36 | "plugins": [ 37 | "mocha", 38 | "react" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /renderer/components/tile.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WebPage from './web-page.jsx'; 3 | import {changeFocus} from '../actions'; 4 | import StartPage from './start-page.jsx'; 5 | 6 | export default function Tile(p) { 7 | const {current_id, leaf, pages, dispatch, searches, style} = p; 8 | const focused = current_id === leaf.id; 9 | const page = pages.get(leaf.id); 10 | 11 | const props = { 12 | className: focused ? 'tile focused' : 'tile', 13 | style, 14 | onMouseOver: () => dispatch(changeFocus(leaf.id)) 15 | }; 16 | 17 | if (page) { 18 | return ( 19 |
20 | 21 |
22 | ); 23 | } else { 24 | return ( 25 |
26 | 27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 rhysd 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 14 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR 15 | PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 16 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 17 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR 18 | THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | 20 | -------------------------------------------------------------------------------- /renderer/history.js: -------------------------------------------------------------------------------- 1 | import Dexie from 'dexie'; 2 | import {PropTypes} from 'react'; 3 | 4 | export const HistoryEntryType = PropTypes.objectOf( 5 | PropTypes.oneOfType([ 6 | PropTypes.string, 7 | PropTypes.number 8 | ]) 9 | ); 10 | 11 | export class PageHistory { 12 | constructor() { 13 | this.db = new Dexie('Tilectron'); 14 | this.db.on('error', err => console.error(err)); 15 | this.db.version(1).stores({ 16 | histories: '++id,&url,title,created_at' 17 | }); 18 | this.db.open(); 19 | this.histories = this.db.histories; 20 | global.__hist = this.histories; 21 | } 22 | 23 | add(url, title) { 24 | return this.histories.add({ 25 | url, 26 | title, 27 | created_at: Date.now() 28 | }).then(() => console.log('History added: ' + url)) 29 | .catch(err => console.log(`Error on add URL ${url}: ${err.message}`)); 30 | } 31 | 32 | all() { 33 | return this.histories.toArray(); 34 | } 35 | 36 | search(word) { 37 | return this 38 | .histories 39 | .filter(h => h.url.indexOf(word) !== -1 || h.title.indexOf(word) !== -1) 40 | .toArray(); 41 | } 42 | } 43 | 44 | const HistorySinglton = new PageHistory(); 45 | export default HistorySinglton; 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | sudo: false 5 | install: 6 | - npm install -g tslint tsd eslint eslint-plugin-react electron-prebuilt eslint-plugin-mocha 7 | - npm install 8 | - tsd install 9 | - tslint --version 10 | - eslint --version 11 | before_script: 12 | - mkdir -p ./tests/renderer/out 13 | - mkdir -p ./build/renderer 14 | - "export DISPLAY=:99.0" 15 | - "sh -e /etc/init.d/xvfb start" 16 | script: 17 | - ./node_modules/.bin/browserify -t babelify -d -o ./tests/renderer/out/tile-tree_test.js ./tests/renderer/tile-tree_test.js 18 | - ./node_modules/.bin/browserify -t babelify -d -o ./tests/renderer/out/history_test.js ./tests/renderer/history_test.js 19 | - ./node_modules/.bin/browserify -t babelify -d -o ./tests/renderer/out/start-page_test.js ./tests/renderer/start-page_test.jsx 20 | - ./node_modules/.bin/browserify -t babelify -d -o ./tests/renderer/out/container_test.js ./tests/renderer/container_test.jsx 21 | - ./node_modules/.bin/browserify -t babelify -d -o ./tests/renderer/out/tile_test.js ./tests/renderer/tile_test.jsx 22 | - tsc -p browser/ 23 | - ./node_modules/.bin/browserify -t babelify -d -o ./build/renderer/index.js ./renderer/index.jsx 24 | - tslint $(git ls-files | grep '.ts$') 25 | - eslint $(git ls-files | grep -E '\.jsx?$') 26 | - ./node_modules/.bin/electron-mocha --renderer ./tests/renderer/out 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tilectron", 3 | "version": "0.0.5", 4 | "description": "Tiling window browser built on Electron", 5 | "main": "build/browser/main.js", 6 | "bin": { 7 | "tilectron": "./bin/cli.js" 8 | }, 9 | "scripts": { 10 | "test": "echo \"Error: no test specified\" && exit 1" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/rhysd/Tilectron.git" 15 | }, 16 | "keywords": [ 17 | "browser", 18 | "electron", 19 | "tile", 20 | "window" 21 | ], 22 | "author": "rhysd ", 23 | "license": "MIT", 24 | "bugs": { 25 | "url": "https://github.com/rhysd/Tilectron/issues" 26 | }, 27 | "homepage": "https://github.com/rhysd/Tilectron#readme", 28 | "dependencies": { 29 | "dexie": "^1.2.0", 30 | "electron-cookies": "^1.1.0", 31 | "font-awesome": "^4.4.0", 32 | "immutable": "^3.7.5", 33 | "mousetrap": "^1.5.3", 34 | "react": "^0.14.2", 35 | "react-dom": "^0.14.2", 36 | "react-redux": "^3.1.0", 37 | "redux": "^3.0.2" 38 | }, 39 | "devDependencies": { 40 | "babelify": "^6.3.0", 41 | "browserify": "^11.2.0", 42 | "chai": "^3.4.0", 43 | "electron-mocha": "^0.5.1", 44 | "electron-prebuilt": "^0.33.7", 45 | "eslint": "^1.8.0", 46 | "eslint-plugin-mocha": "^1.0.0", 47 | "react-addons-test-utils": "^0.14.2", 48 | "typescript": "^1.6.2" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Tilectron: Tiling Window Browser](resources/doc-title.png) 2 | ================================ 3 | [![Build Status](https://travis-ci.org/rhysd/Tilectron.svg)](https://travis-ci.org/rhysd/Tilectron) 4 | 5 | Tilectron is tiling window browser built on [Electron](https://github.com/atom/electron). 6 | 7 | Recently our desktop PC gets very wide screen (4K monitar, 5K iMac, ...). However major browsers provides only tabs feature and we can only one web page at once. When we want to see multiple pages at once, we must open them in another window. 8 | 9 | Goals: 10 | - Flexible and space-effective tiling window management (fallback to mobile page on narrow window) 11 | - Powerful features for keyboard control junkies (Keyboard-controllable free cursor) 12 | - Highly customizable keyboard mappings 13 | - Robust browser built on Electron (based on Chromium) 14 | 15 | Currently being **heavily** constructed. Below is current screenshot. 16 | 17 | ![current implementation screen shot](https://raw.githubusercontent.com/rhysd/ss/master/Tilectron/current-progress.gif) 18 | 19 | ## Installation 20 | 21 | This app is not ready for release. Only [npm](https://www.npmjs.com/) package is available. 22 | 23 | ```bash 24 | $ npm install electron-prebuilt tilectron 25 | $ tilectron 26 | ``` 27 | 28 | ## Work in Progress 29 | 30 | - Do not reload page on changing layout 31 | - When window width is narrow, use mobile browser (controlled by UserAgent) 32 | 33 | ## License 34 | 35 | [MIT License](LICENSE.txt) 36 | -------------------------------------------------------------------------------- /renderer/page-actions.js: -------------------------------------------------------------------------------- 1 | import Store from './store'; 2 | 3 | function getCurrentWebview() { 4 | const {pages, current_id} = Store.getState(); 5 | const page = pages.get(current_id); 6 | if (!page) { 7 | return null; 8 | } 9 | return page.webview; 10 | } 11 | 12 | function scrollCurrentPageBy(scroll_args) { 13 | const webview = getCurrentWebview(); 14 | if (webview !== null) { 15 | webview.executeJavaScript(`window.scrollBy(${scroll_args})`); 16 | } 17 | } 18 | 19 | function scrollCurrentPageTo(scroll_args) { 20 | const webview = getCurrentWebview(); 21 | if (webview !== null) { 22 | webview.executeJavaScript(`window.scrollTo(${scroll_args})`); 23 | } 24 | } 25 | 26 | export function scrollDownPage() { 27 | scrollCurrentPageBy('0, window.innerHeight / 5'); 28 | } 29 | export function scrollUpPage() { 30 | scrollCurrentPageBy('0, -window.innerHeight / 5'); 31 | } 32 | export function scrollRightPage() { 33 | scrollCurrentPageBy('window.innerWidth / 5, 0'); 34 | } 35 | export function scrollLeftPage() { 36 | scrollCurrentPageBy('-window.innerWidth / 5, 0'); 37 | } 38 | export function scrollToTop() { 39 | scrollCurrentPageTo('0, 0'); 40 | } 41 | export function scrollToBottom() { 42 | scrollCurrentPageTo('0, document.body.clientHeight'); 43 | } 44 | 45 | export function toggleDevTools() { 46 | const webview = getCurrentWebview(); 47 | if (webview === null) { 48 | return; 49 | } 50 | 51 | if (webview.isDevToolsOpened()) { 52 | webview.closeDevTools(); 53 | } else { 54 | webview.openDevTools({detach: true}); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /renderer/components/omni-input.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react'; 2 | import {openPage} from '../actions'; 3 | import PageState from '../page-state'; 4 | 5 | export default class OmniInput extends Component { 6 | componentDidMount() { 7 | this.setURL(); 8 | } 9 | componentDidUpdate() { 10 | this.setURL(); 11 | } 12 | 13 | setURL() { 14 | // Note: 15 | // is unavailable because is stateful component. 16 | // ref: http://qiita.com/koba04/items/40cc217ab925ef651113 17 | if (this.props.page) { 18 | this.refs.body.value = this.props.page.url; 19 | } 20 | } 21 | 22 | onInputChar(event) { 23 | if (String.fromCharCode(event.charCode) !== '\r') { 24 | return; 25 | } 26 | 27 | const input = event.target.value; 28 | if (!input) { 29 | return; 30 | } 31 | 32 | event.preventDefault(); 33 | 34 | const {dispatch, page, tileId} = this.props; 35 | if (page) { 36 | page.open(input); 37 | } else { 38 | dispatch(openPage(new PageState(input, tileId, dispatch))); 39 | } 40 | this.refs.body.blur(); 41 | } 42 | 43 | render() { 44 | return ( 45 | 52 | ); 53 | } 54 | } 55 | 56 | OmniInput.propTypes = { 57 | dispatch: PropTypes.func, 58 | page: PropTypes.instanceOf(PageState), 59 | tileId: PropTypes.number 60 | }; 61 | -------------------------------------------------------------------------------- /renderer/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "indent": [ 4 | 2, 5 | 4 6 | ], 7 | "quotes": [ 8 | 2, 9 | "single" 10 | ], 11 | "linebreak-style": [ 12 | 2, 13 | "unix" 14 | ], 15 | "semi": [ 16 | 2, 17 | "always" 18 | ], 19 | "no-console": 0, 20 | "jsx-quotes": 2, 21 | "react/display-name": 0, 22 | "react/forbid-prop-types": 2, 23 | "react/jsx-boolean-value": 2, 24 | "react/jsx-closing-bracket-location": 2, 25 | "react/jsx-curly-spacing": 2, 26 | "react/jsx-indent-props": 0, 27 | "react/jsx-max-props-per-line": 0, 28 | "react/jsx-no-duplicate-props": 2, 29 | "react/jsx-no-literals": 0, 30 | "react/jsx-no-undef": 2, 31 | "react/jsx-sort-prop-types": 2, 32 | "react/jsx-sort-props": 0, 33 | "react/jsx-uses-react": 2, 34 | "react/jsx-uses-vars": 2, 35 | "react/no-danger": 2, 36 | "react/no-did-mount-set-state": 2, 37 | "react/no-did-update-set-state": 2, 38 | "react/no-direct-mutation-state": 2, 39 | "react/no-multi-comp": 2, 40 | "react/no-set-state": 2, 41 | "react/no-unknown-property": 2, 42 | "react/prop-types": 2, 43 | "react/react-in-jsx-scope": 2, 44 | "react/require-extension": 2, 45 | "react/self-closing-comp": 2, 46 | "react/sort-comp": 2, 47 | "react/wrap-multilines": 2, 48 | "react/no-multi-comp": 0 49 | }, 50 | "env": { 51 | "es6": true, 52 | "node": true, 53 | "browser": true 54 | }, 55 | "extends": "eslint:recommended", 56 | "ecmaFeatures": { 57 | "jsx": true, 58 | "modules": true, 59 | "experimentalObjectRestSpread": true 60 | }, 61 | "plugins": [ 62 | "react" 63 | ] 64 | } 65 | -------------------------------------------------------------------------------- /renderer/components/container.jsx: -------------------------------------------------------------------------------- 1 | import React, {PropTypes, Component} from 'react'; 2 | import Immutable from 'immutable'; 3 | import Tile from './tile.jsx'; 4 | import {ContainerKnot, TileLeaf, SplitType} from '../tile-tree'; 5 | 6 | export default class Container extends Component { 7 | getDirection() { 8 | return this.props.knot.split_type === SplitType.Vertical ? 9 | 'row' : 10 | 'column'; 11 | } 12 | 13 | getChildStyle() { 14 | if (this.props.knot.split_type === SplitType.Vertical) { 15 | return { 16 | width: '50%', 17 | height: '100%' 18 | }; 19 | } else { 20 | return { 21 | width: '100%', 22 | height: '50%' 23 | }; 24 | } 25 | } 26 | 27 | renderTree(tree) { 28 | const {current_id, pages, searches, dispatch} = this.props; 29 | const common_props = { 30 | style: this.getChildStyle(), 31 | current_id, 32 | dispatch, 33 | pages, 34 | searches 35 | }; 36 | 37 | if (tree instanceof TileLeaf) { 38 | return ; 39 | } else { 40 | return ; 41 | } 42 | } 43 | 44 | render() { 45 | const s = { 46 | ...this.props.style, 47 | flexDirection: this.getDirection() 48 | }; 49 | 50 | return ( 51 |
52 | {this.renderTree(this.props.knot.left)} 53 | {this.renderTree(this.props.knot.right)} 54 |
55 | ); 56 | } 57 | } 58 | 59 | Container.propTypes = { 60 | current_id: PropTypes.number, 61 | dispatch: PropTypes.func, 62 | knot: PropTypes.instanceOf(ContainerKnot), 63 | pages: PropTypes.instanceOf(Immutable.Map), 64 | searches: PropTypes.instanceOf(Immutable.Map), 65 | style: PropTypes.objectOf(PropTypes.string) 66 | }; 67 | -------------------------------------------------------------------------------- /renderer/components/app.jsx: -------------------------------------------------------------------------------- 1 | import React,{PropTypes, Component} from 'react'; 2 | import {connect} from 'react-redux'; 3 | import Immutable from 'immutable'; 4 | import Tile from './tile.jsx'; 5 | import Container from './container.jsx'; 6 | import AddressBar from './address-bar.jsx'; 7 | import {TileLeaf, ContainerKnot} from '../tile-tree'; 8 | import KeyHandler from '../key-handler'; 9 | 10 | class App extends Component { 11 | constructor(props) { 12 | super(props); 13 | KeyHandler.start(this.props.dispatch); 14 | } 15 | 16 | renderTree() { 17 | const {dispatch, root, current_id, pages, searches} = this.props; 18 | const common_props = { 19 | style: {flex: 'auto'}, 20 | current_id, 21 | dispatch, 22 | pages, 23 | searches 24 | }; 25 | 26 | if (root instanceof TileLeaf) { 27 | return ; 28 | } else { 29 | return ; 30 | } 31 | } 32 | 33 | render() { 34 | const {current_id, dispatch, pages} = this.props; 35 | return ( 36 |
37 | 38 |
39 | {this.renderTree()} 40 |
41 |
42 | ); 43 | } 44 | } 45 | 46 | App.propTypes = { 47 | current_id: PropTypes.number, 48 | dispatch: PropTypes.func, 49 | pages: PropTypes.instanceOf(Immutable.Map), 50 | root: PropTypes.oneOfType([ 51 | PropTypes.instanceOf(TileLeaf), 52 | PropTypes.instanceOf(ContainerKnot) 53 | ]), 54 | searches: PropTypes.instanceOf(Immutable.Map) 55 | }; 56 | 57 | function select(state) { 58 | const {current_id, dispatch, tree, pages, searches} = state; 59 | return { 60 | root: tree.root, 61 | current_id, 62 | dispatch, 63 | pages, 64 | searches 65 | }; 66 | } 67 | 68 | export default connect(select)(App); 69 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'fileutils' 2 | include FileUtils 3 | 4 | ROOT = __dir__.freeze 5 | BIN = "#{ROOT}/node_modules/.bin".freeze 6 | 7 | def cmd_exists?(cmd) 8 | File.exists?(cmd) && File.executable?(cmd) 9 | end 10 | 11 | def ensure_cmd(cmd) 12 | $cmd_cache ||= [] 13 | return true if $cmd_cache.include? cmd 14 | 15 | paths = ENV['PATH'].split(':').uniq 16 | unless paths.any?{|p| cmd_exists? "#{p}/#{cmd}" } 17 | raise "'#{cmd}' command doesn't exist" 18 | else 19 | $cmd_cache << cmd 20 | end 21 | end 22 | 23 | file 'node_modules' do 24 | ensure_cmd 'npm' 25 | sh 'npm install' 26 | end 27 | 28 | file 'bower_components' do 29 | ensure_cmd 'bower' 30 | sh 'bower install' 31 | end 32 | 33 | file "typings" do 34 | ensure_cmd 'tsd' 35 | sh 'tsd install' 36 | end 37 | 38 | task :dep => %i(node_modules bower_components typings) 39 | 40 | task :build_browser_src => %i(typings) do 41 | sh "#{BIN}/tsc -p #{ROOT}/browser" 42 | end 43 | 44 | task :build_renderer_src do 45 | mkdir_p 'build/renderer' 46 | 47 | sh "#{BIN}/browserify -t babelify -d -o #{ROOT}/build/renderer/index.js ./renderer/index.jsx" 48 | end 49 | 50 | task :build => %i(dep build_browser_src build_renderer_src) 51 | 52 | task :run do 53 | sh "#{ROOT}/bin/cli.js" 54 | end 55 | 56 | task :default => %i(build run) 57 | 58 | task :asar do 59 | mkdir_p 'archive/resource' 60 | begin 61 | %w(bower.json package.json index.html style build).each{|p| cp_r p, 'archive/' } 62 | %w(emoji trayicon).each{|p| cp_r "resource/#{p}", 'archive/resource/'} 63 | cd 'archive' do 64 | sh 'npm install --production' 65 | sh 'bower install --production' 66 | end 67 | sh "#{BIN}/asar pack archive app.asar" 68 | ensure 69 | rm_rf 'archive' 70 | end 71 | end 72 | 73 | task :lint do 74 | Dir['browser/**/*.ts'].each do |f| 75 | sh "tslint #{f}" 76 | end 77 | Dir['renderer/**/*.js', 'renderer/**/*.jsx', 'tests/renderer/*.js', 'tests/renderer/*.jsx'].each do |f| 78 | sh "eslint #{f}" 79 | end 80 | end 81 | 82 | task :test do 83 | mkdir_p "#{ROOT}/tests/renderer/out" 84 | Dir["#{ROOT}/tests/renderer/*.js", "#{ROOT}/tests/renderer/*.jsx"].each do |p| 85 | js = File.basename p 86 | puts "TEST: #{js}" 87 | sh "#{BIN}/browserify -t babelify -o #{ROOT}/tests/renderer/out/#{js} #{p}" 88 | sh "#{BIN}/electron-mocha --renderer #{ROOT}/tests/renderer/out/#{js}" 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /tests/renderer/tile_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Immutable from 'immutable'; 3 | import {createRenderer} from 'react-addons-test-utils'; 4 | import {assert} from 'chai'; 5 | import Tile from '../../renderer/components/tile.jsx'; 6 | import StartPage from '../../renderer/components/start-page.jsx'; 7 | import WebPage from '../../renderer/components/web-page.jsx'; 8 | import {TileLeaf} from '../../renderer/tile-tree'; 9 | import PageState from '../../renderer/page-state'; 10 | 11 | function render(component) { 12 | const renderer = createRenderer(); 13 | renderer.render(component); 14 | return renderer.getRenderOutput(); 15 | } 16 | 17 | describe(' (shallow renderer)', () => { 18 | it('renders on no page information', () => { 19 | const tree = render( 20 | {}} 24 | leaf={new TileLeaf(null, 10)} 25 | pages={Immutable.Map()} 26 | searches={Immutable.Map()} 27 | /> 28 | ); 29 | assert.strictEqual(tree.type, 'div'); 30 | const child = tree.props.children; 31 | assert.strictEqual(child.type, StartPage); 32 | assert.strictEqual(child.props.tileId, 10); 33 | assert.strictEqual(child.props.focused, false); 34 | }); 35 | 36 | it('renders on page information', () => { 37 | const pages = Immutable.Map().set(42, new PageState()); 38 | const tree = render( 39 | {}} 43 | leaf={new TileLeaf(null, 42)} 44 | pages={pages} 45 | searches={Immutable.Map()} 46 | /> 47 | ); 48 | const child = tree.props.children; 49 | assert.strictEqual(child.type, WebPage); 50 | }); 51 | 52 | it('check the tile is focused', () => { 53 | const tree = render( 54 | {}} 58 | leaf={new TileLeaf(null, 42)} 59 | pages={Immutable.Map()} 60 | searches={Immutable.Map()} 61 | /> 62 | ); 63 | const child = tree.props.children; 64 | assert.strictEqual(child.props.focused, true); 65 | }); 66 | }); 67 | 68 | -------------------------------------------------------------------------------- /tests/renderer/start-page_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {createRenderer} from 'react-addons-test-utils'; 3 | import {assert} from 'chai'; 4 | import StartPage from '../../renderer/components/start-page.jsx'; 5 | import History from '../../renderer/history'; 6 | 7 | describe(' (shallow renderer)', () => { 8 | it('renders
component', () => { 9 | const renderer = createRenderer(); 10 | renderer.render( 11 | {}} 13 | search={[]} 14 | tileId={0} 15 | /> 16 | ); 17 | const tree = renderer.getRenderOutput(); 18 | assert.strictEqual(tree.type, 'div'); 19 | assert.strictEqual(tree.props.className, 'start-page'); 20 | const c = tree.props.children; 21 | assert.strictEqual(c[0].props.className, 'favorites'); 22 | assert.strictEqual(c[1].props.className, 'history-input'); 23 | assert.strictEqual(c[2].props.className, 'history-candidates'); 24 | }); 25 | 26 | it('renderers search results', () => { 27 | const renderer = createRenderer(); 28 | renderer.render( 29 | {throw new Error('foo!');}} 31 | search={[ 32 | {url: 'https://github.com', title: 'GitHub', created_at: Date.now()} 33 | ]} 34 | tileId={0} 35 | /> 36 | ); 37 | const tree = renderer.getRenderOutput(); 38 | const search_node = tree.props.children[2]; 39 | assert.strictEqual(search_node.props.children.length, 1); 40 | const item_node = search_node.props.children[0]; 41 | assert.strictEqual(item_node.props.className, 'history-item'); 42 | const url_node = item_node.props.children[1]; 43 | assert.strictEqual(url_node.props.className, 'history-url'); 44 | assert.strictEqual(url_node.props.href, 'https://github.com'); 45 | }); 46 | 47 | it('renderers all histories on no search input', done => { 48 | const renderer = createRenderer(); 49 | renderer.render( 50 | {}} 52 | tileId={0} 53 | /> 54 | ); 55 | const tree = renderer.getRenderOutput(); 56 | const items = tree.props.children[2].props.children; 57 | History.all().then(data => { 58 | assert.strictEqual(data.length, items.length); 59 | for (const i in data) { 60 | assert.strictEqual(data[i].url, items[i].props.children[1].props.href); 61 | } 62 | done(); 63 | }).catch(err => done(err)); 64 | }); 65 | }); 66 | 67 | -------------------------------------------------------------------------------- /renderer/components/address-bar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import OmniInput from './omni-input.jsx'; 3 | import {splitVertical, splitHorizontal, closeTile} from '../actions'; 4 | 5 | const bar_style = { 6 | paddingLeft: global.process.platform === 'darwin' ? '80px' : undefined 7 | }; 8 | 9 | function getButtonClass(enabled) { 10 | if (enabled) { 11 | return 'btn btn-default'; 12 | } else { 13 | return 'btn btn-default disabled'; 14 | } 15 | } 16 | 17 | function renderRefreshButton(page) { 18 | if (page && page.loading) { 19 | return ( 20 | 23 | ); 24 | } else { 25 | return ( 26 | 29 | ); 30 | } 31 | } 32 | 33 | export default function AddressBar(props) { 34 | const {dispatch, page, tileId} = props; 35 | // TODO: 36 | // When platform is not OS X, remove the padding 37 | return ( 38 |
39 |
40 |
41 | 44 | 47 |
48 |
49 | 52 | 55 |
56 | {renderRefreshButton(page)} 57 | 58 |
dispatch(closeTile(tileId))}> 59 | 60 |
61 |
62 | 63 |
64 |
65 |
66 | ); 67 | } 68 | 69 | -------------------------------------------------------------------------------- /renderer/key-handler.js: -------------------------------------------------------------------------------- 1 | import Mousetrap from 'mousetrap'; 2 | import { 3 | splitVertical, 4 | splitHorizontal, 5 | closeTile, 6 | focusLeft, 7 | focusRight, 8 | focusUp, 9 | focusDown, 10 | switchSplit, 11 | swapTiles, 12 | splitVerticalWithCurrentPage, 13 | splitHorizontalWithCurrentPage 14 | } from './actions'; 15 | import { 16 | scrollDownPage, 17 | scrollUpPage, 18 | scrollRightPage, 19 | scrollLeftPage, 20 | scrollToTop, 21 | scrollToBottom, 22 | toggleDevTools 23 | } from './page-actions'; 24 | 25 | export const ActionMap = { 26 | splitVertical, 27 | splitHorizontal, 28 | splitVerticalWithCurrentPage, 29 | splitHorizontalWithCurrentPage, 30 | closeTile, 31 | focusLeft, 32 | focusRight, 33 | focusUp, 34 | focusDown, 35 | switchSplit, 36 | swapTiles 37 | }; 38 | 39 | export const PageActionMap = { 40 | toggleDevTools, 41 | scrollDownPage, 42 | scrollUpPage, 43 | scrollRightPage, 44 | scrollLeftPage, 45 | scrollToTop, 46 | scrollToBottom 47 | }; 48 | 49 | export class KeyHandler { 50 | constructor(default_maps) { 51 | this.keymaps = default_maps; 52 | } 53 | 54 | register(map) { 55 | for (const key in map) { 56 | this.keymaps[key] = map[key]; 57 | } 58 | } 59 | 60 | start(dispatch) { 61 | for (const key in this.keymaps) { 62 | const action_name = this.keymaps[key]; 63 | if (ActionMap[action_name]) { 64 | Mousetrap.bind(key, () => dispatch(ActionMap[action_name]())); 65 | continue; 66 | } 67 | 68 | if (PageActionMap[action_name]) { 69 | Mousetrap.bind(key, PageActionMap[action_name]); 70 | continue; 71 | } 72 | 73 | console.log('Invalid action name: ' + action_name); 74 | } 75 | } 76 | 77 | unregister(key) { 78 | Mousetrap.unbind(key); 79 | } 80 | 81 | unregisterAll() { 82 | Mousetrap.reset(); 83 | } 84 | } 85 | 86 | const KeyHandlerSinglton = new KeyHandler({ 87 | 'h': 'scrollLeftPage', 88 | 'j': 'scrollDownPage', 89 | 'k': 'scrollUpPage', 90 | 'l': 'scrollRightPage', 91 | 'g g': 'scrollToTop', 92 | 'G': 'scrollToBottom', 93 | 'ctrl+w v': 'splitVertical', 94 | 'ctrl+w h': 'splitHorizontal', 95 | 'ctrl+w ctrl+v': 'splitVerticalWithCurrentPage', 96 | 'ctrl+w ctrl+h': 'splitHorizontalWithCurrentPage', 97 | 'x': 'closeTile', 98 | 'ctrl+h': 'focusLeft', 99 | 'ctrl+l': 'focusRight', 100 | 'ctrl+j': 'focusDown', 101 | 'ctrl+k': 'focusUp', 102 | 'ctrl+s': 'switchSplit', 103 | 'ctrl+w s': 'swapTiles', 104 | 'mod+shift+i': 'toggleDevTools' 105 | }); 106 | 107 | export default KeyHandlerSinglton; 108 | -------------------------------------------------------------------------------- /resources/tilectron.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 18 | 20 | 38 | 40 | 41 | 43 | image/svg+xml 44 | 46 | 47 | 48 | 49 | 50 | 55 | 63 | 71 | 79 | 87 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /renderer/actions.js: -------------------------------------------------------------------------------- 1 | export const SPLIT_VERTICAL = Symbol('action-split-vertical'); 2 | export const SPLIT_HORIZONTAL = Symbol('action-split-horizontal'); 3 | export const OPEN_PAGE = Symbol('action-open-page'); 4 | export const CHANGE_FOCUS = Symbol('action-change-focus'); 5 | export const CLOSE_TILE = Symbol('action-close-tile'); 6 | export const FOCUS_LEFT = Symbol('action-focus-left'); 7 | export const FOCUS_RIGHT = Symbol('action-focus-right'); 8 | export const FOCUS_UP = Symbol('action-focus-up'); 9 | export const FOCUS_DOWN = Symbol('action-focus-down'); 10 | export const SWITCH_SPLIT = Symbol('action-switch-split'); 11 | export const SWAP_TILES = Symbol('action-swap-tiles'); 12 | export const NOTIFY_START_LOADING = Symbol('action-notify-start-loading'); 13 | export const NOTIFY_END_LOADING = Symbol('action-notify-end-loading'); 14 | export const UPDATE_SEARCH = Symbol('action-update-search'); 15 | export const SPLIT_VERTICAL_WITH_CURRENT_PAGE = Symbol('action-split-vertical-with-current-page'); 16 | export const SPLIT_HORIZONTAL_WITH_CURRENT_PAGE = Symbol('action-split-horizontal-with-current-page'); 17 | 18 | export function splitVertical(tile_id) { 19 | return { 20 | type: SPLIT_VERTICAL, 21 | tile_id 22 | }; 23 | } 24 | 25 | export function splitHorizontal(tile_id) { 26 | return { 27 | type: SPLIT_HORIZONTAL, 28 | tile_id 29 | }; 30 | } 31 | 32 | export function openPage(page, from_start_page = false) { 33 | return { 34 | type: OPEN_PAGE, 35 | page, 36 | from_start_page 37 | }; 38 | } 39 | 40 | export function changeFocus(tile_id) { 41 | return { 42 | type: CHANGE_FOCUS, 43 | tile_id 44 | }; 45 | } 46 | 47 | export function closeTile(tile_id) { 48 | return { 49 | type: CLOSE_TILE, 50 | tile_id 51 | }; 52 | } 53 | 54 | export function focusLeft() { 55 | return {type: FOCUS_LEFT}; 56 | } 57 | export function focusRight() { 58 | return {type: FOCUS_RIGHT}; 59 | } 60 | export function focusUp() { 61 | return {type: FOCUS_UP}; 62 | } 63 | export function focusDown() { 64 | return {type: FOCUS_DOWN}; 65 | } 66 | 67 | export function switchSplit(tile_id) { 68 | return { 69 | type: SWITCH_SPLIT, 70 | tile_id 71 | }; 72 | } 73 | 74 | export function swapTiles(tile_id) { 75 | return { 76 | type: SWAP_TILES, 77 | tile_id 78 | }; 79 | } 80 | 81 | export function notifyStartLoading(tile_id, url) { 82 | return { 83 | type: NOTIFY_START_LOADING, 84 | tile_id, 85 | url 86 | }; 87 | } 88 | 89 | export function notifyEndLoading(tile_id) { 90 | return { 91 | type: NOTIFY_END_LOADING, 92 | tile_id 93 | }; 94 | } 95 | export function updateSearch(tile_id, result) { 96 | return { 97 | type: UPDATE_SEARCH, 98 | tile_id, 99 | result 100 | }; 101 | } 102 | 103 | 104 | export function splitVerticalWithCurrentPage(tile_id) { 105 | return { 106 | type: SPLIT_VERTICAL_WITH_CURRENT_PAGE, 107 | tile_id 108 | }; 109 | } 110 | export function splitHorizontalWithCurrentPage(tile_id) { 111 | return { 112 | type: SPLIT_HORIZONTAL_WITH_CURRENT_PAGE, 113 | tile_id 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /style/style.css: -------------------------------------------------------------------------------- 1 | html,body { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | body { 7 | margin: 0px; 8 | font-family: "Helvetica Neue", Helvetica, "Segoe UI", Arial, freesans, sans-serif; 9 | overflow: hidden; 10 | } 11 | 12 | .app { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | .spacer { 18 | flex: auto; 19 | } 20 | 21 | .icon-button { 22 | margin: 0em 0.2em; 23 | cursor: pointer; 24 | -webkit-user-select: none; 25 | } 26 | 27 | .pages { 28 | width: 100%; 29 | height: calc(100% - 35px); 30 | } 31 | 32 | .knot { 33 | width: 100%; 34 | height: 100%; 35 | display: flex; 36 | } 37 | 38 | .tile { 39 | border: solid 1px #666666; 40 | width: 100%; 41 | height: 100%; 42 | flex: auto; 43 | display: flex; 44 | flex-direction: column; 45 | box-sizing: border-box; 46 | } 47 | .tile.focused { 48 | border: solid 1px #aa3311; 49 | } 50 | .new-window { 51 | display: flex; 52 | flex-direction: row; 53 | align-items: center; 54 | height: 100%; 55 | background-color: #eeeeee; 56 | padding: 0px 8px; 57 | } 58 | .inner-view { 59 | width: 100%; 60 | height: 100%; /*Consider height of address bar*/ 61 | flex: auto; 62 | outline: none; 63 | } 64 | 65 | .toolbar.toolbar-header { 66 | height: 35px; 67 | } 68 | .toolbar-actions { 69 | display: flex; 70 | } 71 | .toolbar-actions > * { 72 | flex: none; 73 | } 74 | .btn.btn-default.disabled > * { 75 | color: #d6d8da; 76 | } 77 | .icon.icon-arrow-combo.vertical { 78 | transform: rotate(90deg); 79 | } 80 | .flatbutton { 81 | display: inline-block; 82 | margin: 0px 7px; 83 | cursor: pointer; 84 | } 85 | .flatbutton > span { 86 | font-size: 1.2em; 87 | } 88 | 89 | .omni-input { 90 | box-sizing: border-box; 91 | flex: auto; 92 | -webkit-app-region: no-drag; 93 | } 94 | 95 | .web-page { 96 | height: 100%; 97 | width: 100%; 98 | } 99 | 100 | .start-page { 101 | display: flex; 102 | flex-direction: column; 103 | height: 100%; 104 | width: 100%; 105 | box-sizing: border-box; 106 | width: 100%; 107 | background-color: #eeeeee; 108 | padding: 0px 8px; 109 | } 110 | .start-page .favorites { 111 | height: calc((100% - 1.5em - 16px) / 2); 112 | text-align: center; 113 | } 114 | .start-page .favorites > img { 115 | opacity: 0.3; 116 | max-width: 100%; 117 | max-height: 100%; 118 | } 119 | .start-page .history-input { 120 | box-sizing: border-box; 121 | width: 100%; 122 | height: 1.5em; 123 | margin: 8px 0px; 124 | } 125 | .start-page .history-candidates { 126 | height: calc((100% - 1.5em - 16px) / 2); 127 | box-sizing: border-box; 128 | margin: 8px; 129 | border-top: solid 1px #bbbbbb; 130 | overflow: hidden; 131 | } 132 | .start-page .history-item { 133 | box-sizing: border-box; 134 | height: 2.5em; 135 | border-bottom: solid 1px #bbbbbb; 136 | border-left: solid 1px #bbbbbb; 137 | border-right: solid 1px #bbbbbb; 138 | background-color: white; 139 | display: flex; 140 | align-items: center; 141 | white-space: nowrap; 142 | } 143 | .start-page .history-item > * { 144 | text-overflow: ellipsis; 145 | padding: 0px 8px; 146 | } 147 | .start-page .history-title { 148 | color: #333333; 149 | font-weight: bold; 150 | flex: none; 151 | } 152 | .start-page .history-url { 153 | color: #2185d0; 154 | flex: auto; 155 | } 156 | .start-page .history-visited-at { 157 | color: #999999; 158 | flex: none; 159 | } 160 | -------------------------------------------------------------------------------- /renderer/page-state.js: -------------------------------------------------------------------------------- 1 | import {notifyStartLoading, notifyEndLoading, closeTile} from './actions'; 2 | 3 | export default class PageState { 4 | constructor(start_url, tile_id, dispatch) { 5 | if (!start_url && !tile_id && !dispatch) { 6 | // For cloning 7 | return; 8 | } 9 | 10 | this.tile_id = tile_id; 11 | this.webview = document.createElement('webview'); 12 | this.webview.className = 'inner-view'; 13 | 14 | // TODO: Inject JavaScript here using 'preload' 15 | 16 | this.url = this.getURL(start_url); 17 | this.webview.src = this.url; 18 | this.title = ''; 19 | this.loading = true; 20 | this.can_go_back = false; 21 | this.can_go_forward = false; 22 | this.is_crashed = false; 23 | this.dispatch = dispatch; 24 | 25 | this.registerCallbacks(dispatch); 26 | } 27 | 28 | clone() { 29 | const cloned = new PageState(); 30 | for (const prop in this) { 31 | cloned[prop] = this[prop]; 32 | } 33 | return cloned; 34 | } 35 | 36 | getURL(input) { 37 | if (!input.startsWith('?') && (input.startsWith('http://') || input.startsWith('https://'))) { 38 | return input; 39 | } else { 40 | return 'https://www.google.com/search?q=' + encodeURIComponent(input); 41 | } 42 | } 43 | 44 | // Note: 45 | // This class preserves first dispatch function. But dispatch function is passed from 46 | // redux runtime every update. So preserving dispatch function may occur some problems 47 | // if dispatch function is updated by redux runtime. 48 | registerCallbacks(dispatch) { 49 | this.webview.addEventListener('load-commit', event => { 50 | if (event.isMainFrame) { 51 | dispatch(notifyStartLoading(this.tile_id, event.url)); 52 | } 53 | }); 54 | 55 | this.webview.addEventListener( 56 | 'did-finish-load', 57 | () => dispatch(notifyEndLoading(this.tile_id)) 58 | ); 59 | 60 | this.webview.addEventListener('did-fail-load', event => { 61 | if (event.errorCode) { 62 | console.log(`Failed loading: ${event.validatedUrl}: ${event.errorDescription}`); 63 | } 64 | }); 65 | 66 | this.webview.addEventListener( 67 | 'close', 68 | () => dispatch(closeTile(this.tile_id)) 69 | ); 70 | } 71 | 72 | updateStatus() { 73 | this.can_go_back = this.webview.canGoBack(); 74 | this.can_go_forward = this.webview.canGoForward(); 75 | this.is_crashed = this.webview.isCrashed(); 76 | this.url = this.webview.getUrl(); 77 | this.title = this.webview.getTitle(); 78 | return this; 79 | } 80 | 81 | open(url) { 82 | this.url = this.getURL(url); 83 | this.webview.src = this.url; 84 | } 85 | 86 | goBack() { 87 | if (this.webview.canGoBack()) { 88 | this.webview.goBack(); 89 | } 90 | } 91 | 92 | goForward() { 93 | if (this.webview.canGoForward()) { 94 | this.webview.goForward(); 95 | } 96 | } 97 | 98 | reload() { 99 | this.webview.reload(); 100 | } 101 | 102 | stop() { 103 | this.webview.stop(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /tests/renderer/container_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {createRenderer} from 'react-addons-test-utils'; 3 | import {assert} from 'chai'; 4 | import Container from '../../renderer/components/container.jsx'; 5 | import Tile from '../../renderer/components/tile.jsx'; 6 | import {TileLeaf, ContainerKnot, SplitType} from '../../renderer/tile-tree'; 7 | 8 | const {Horizontal, Vertical} = SplitType; 9 | 10 | function makeKnot(type) { 11 | const lhs = new TileLeaf(null, 0); 12 | const rhs = new TileLeaf(null, 1); 13 | return new ContainerKnot(null, lhs, rhs, type); 14 | } 15 | 16 | describe(' (shallow renderer)', () => { 17 | it('renders if child is terminal', () => { 18 | const renderer = createRenderer(); 19 | renderer.render( 20 | {}} 22 | style={{}} 23 | current_id={0} 24 | knot={makeKnot(Horizontal)} 25 | /> 26 | ); 27 | const tree = renderer.getRenderOutput(); 28 | tree.props.children.forEach(c => { 29 | assert.strictEqual(c.type, Tile); 30 | }); 31 | }); 32 | 33 | it('renders if child is non-terminal', () => { 34 | const k = makeKnot(Horizontal); 35 | const l = makeKnot(Vertical); 36 | const r = makeKnot(Vertical); 37 | [k.left, k.right, l.parent, r.parent] = [l, r, k, k]; 38 | const renderer = createRenderer(); 39 | renderer.render( 40 | {}} 42 | style={{}} 43 | current_id={0} 44 | knot={k} 45 | /> 46 | ); 47 | const tree = renderer.getRenderOutput(); 48 | tree.props.children.forEach(c => { 49 | assert.strictEqual(c.type, Container); 50 | }); 51 | }); 52 | 53 | it('renders horizontally split container', () => { 54 | const renderer = createRenderer(); 55 | renderer.render( 56 | {}} 58 | style={{}} 59 | current_id={0} 60 | knot={makeKnot(Horizontal)} 61 | /> 62 | ); 63 | const tree = renderer.getRenderOutput(); 64 | assert.strictEqual(tree.props.className, 'knot'); 65 | assert.deepEqual( 66 | tree.props.style, 67 | {flexDirection: 'column'} 68 | ); 69 | tree.props.children.forEach(c => { 70 | assert.deepEqual( 71 | c.props.style, 72 | {width: '100%', height: '50%'} 73 | ); 74 | }); 75 | }); 76 | 77 | it('renders vertically split container', () => { 78 | const renderer = createRenderer(); 79 | renderer.render( 80 | {}} 82 | style={{}} 83 | current_id={0} 84 | knot={makeKnot(Vertical)} 85 | /> 86 | ); 87 | const tree = renderer.getRenderOutput(); 88 | assert.strictEqual(tree.props.className, 'knot'); 89 | assert.deepEqual( 90 | tree.props.style, 91 | {flexDirection: 'row'} 92 | ); 93 | tree.props.children.forEach(c => { 94 | assert.deepEqual( 95 | c.props.style, 96 | {width: '50%', height: '100%'} 97 | ); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/renderer/history_test.js: -------------------------------------------------------------------------------- 1 | import {assert} from 'chai'; 2 | import History from '../../renderer/history'; 3 | 4 | describe('History', () => { 5 | afterEach( 6 | done => 7 | History.histories.clear() 8 | .then(() => done()) 9 | .catch(err => done(err)) 10 | ); 11 | 12 | describe('constructor', () => { 13 | it('opens empty DB', done => { 14 | History.all().then(data => { 15 | assert.strictEqual(data.length, 0); 16 | done(); 17 | }).catch(err => done(err)); 18 | }); 19 | }); 20 | 21 | describe('add()', () => { 22 | it('adds entry and returns promise', done => { 23 | History.add('https://example.com/1', 'Example 1') 24 | .then(() => History.add('https://example.com/2', 'Example 2')) 25 | .then(() => History.add('https://example.com/3', 'Example 3')) 26 | .then(() => History.all()) 27 | .then(data => { 28 | assert.strictEqual(data.length, 3); 29 | assert.deepEqual(data[0].url, 'https://example.com/1'); 30 | assert.deepEqual(data[1].url, 'https://example.com/2'); 31 | assert.deepEqual(data[2].url, 'https://example.com/3'); 32 | done(); 33 | }).catch(err => done(err)); 34 | }); 35 | 36 | it('does not make duplicate in DB entries', done => { 37 | History.add('https://example.com/1', 'Example Foo') 38 | .then(() => History.add('https://example.com/1', 'Example Bar')) 39 | .then(() => History.add('https://example.com/1', 'Example Yo')) 40 | .then(() => History.all()) 41 | .then(data => { 42 | assert.strictEqual(data.length, 1); 43 | assert.deepEqual(data[0].url, 'https://example.com/1'); 44 | assert.deepEqual(data[0].title, 'Example Foo'); 45 | done(); 46 | }).catch(err => done(err)); 47 | }); 48 | }); 49 | 50 | describe('search()', () => { 51 | it('can search DB with specified word', done => { 52 | History.add('https://github.com', 'GitHub') 53 | .then(() => History.add('https://gist.github.com', 'Gist')) 54 | .then(() => History.add('https://google.com', 'Google')) 55 | .then(() => History.search('github')) 56 | .then(result => { 57 | assert.strictEqual(result.length, 2); 58 | assert.strictEqual(result[0].url, 'https://github.com'); 59 | assert.strictEqual(result[1].url, 'https://gist.github.com'); 60 | }).then(() => History.search('gist')) 61 | .then(result => { 62 | assert.strictEqual(result.length, 1); 63 | assert.strictEqual(result[0].url, 'https://gist.github.com'); 64 | }).then(() => History.search('g')) 65 | .then(result => { 66 | assert.strictEqual(result.length, 3); 67 | assert.strictEqual(result[0].url, 'https://github.com'); 68 | assert.strictEqual(result[1].url, 'https://gist.github.com'); 69 | assert.strictEqual(result[2].url, 'https://google.com'); 70 | }).then(() => History.search('foooooOOOO!!!!!')) 71 | .then(result => { 72 | assert.strictEqual(result.length, 0); 73 | done(); 74 | }).catch(err => done(err)); 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /renderer/components/start-page.jsx: -------------------------------------------------------------------------------- 1 | import React, {Component, PropTypes} from 'react'; 2 | import PageState from '../page-state'; 3 | import {openPage, updateSearch} from '../actions'; 4 | import History,{HistoryEntryType} from '../history'; 5 | 6 | export default class StartPage extends Component { 7 | start(input) { 8 | if (!input) { 9 | return; 10 | } 11 | const {dispatch, tileId} = this.props; 12 | dispatch(openPage(new PageState(input, tileId, dispatch), true)); 13 | } 14 | 15 | checkEnter(event) { 16 | if (String.fromCharCode(event.charCode) !== '\r') { 17 | return; 18 | } 19 | event.preventDefault(); 20 | 21 | const {search} = this.props; 22 | const candidates = search || []; 23 | const input = candidates.length === 0 ? event.target.value : candidates[0].url; 24 | this.start(input); 25 | } 26 | 27 | onInputChar(event) { 28 | const {dispatch, tileId, search} = this.props; 29 | const input = event.target.value; 30 | if (!input || search === undefined) { 31 | History.all().then(cs => dispatch(updateSearch(tileId, cs))); 32 | } else { 33 | History.search(input).then(cs => dispatch(updateSearch(tileId, cs))); 34 | } 35 | } 36 | 37 | openLink(event) { 38 | event.preventDefault(); 39 | this.start(event.target.href); 40 | } 41 | 42 | calculateMaxItems() { 43 | if (this.refs.candidates === undefined) { 44 | return 3; // Fallback 45 | } 46 | const items_area_height = this.refs.candidates.clientHeight; 47 | const item_height = 40; 48 | return Math.floor(items_area_height / item_height); 49 | } 50 | 51 | renderCandidates() { 52 | const max_items_by_space = this.calculateMaxItems(); 53 | const items = []; 54 | const {search} = this.props; 55 | const candidates = search || []; 56 | const max_items = Math.min(candidates.length, max_items_by_space); 57 | for (let i = max_items - 1; i >= 0; --i) { 58 | const h = candidates[i]; 59 | items.push( 60 |
61 | {h.title} 62 | {h.url} 63 | ({new Date(h.created_at).toLocaleString()}) 64 |
65 | ); 66 | } 67 | return items; 68 | } 69 | 70 | render() { 71 | const {search, dispatch, tileId} = this.props; 72 | if (!search) { 73 | History.all().then(cs => dispatch(updateSearch(tileId, cs))); 74 | } 75 | return ( 76 |
77 |
78 | 79 |
80 | 88 |
89 | {this.renderCandidates()} 90 |
91 |
92 | ); 93 | } 94 | } 95 | 96 | StartPage.propTypes = { 97 | dispatch: PropTypes.func, 98 | search: PropTypes.arrayOf(HistoryEntryType), 99 | tileId: PropTypes.number 100 | }; 101 | -------------------------------------------------------------------------------- /renderer/reducers.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import * as A from './actions'; 3 | import TileTree, {SplitType} from './tile-tree'; 4 | import History from './history'; 5 | import PageState from './page-state'; 6 | 7 | // When splitting the reducer logically, combine it by combineReducers() 8 | // import {combineReducers} from 'redux' 9 | 10 | // TODO: 11 | // Look addresses in process.argv and open them. 12 | 13 | let init = { 14 | tree: new TileTree(), 15 | current_id: 0, 16 | pages: Immutable.Map(), 17 | searches: Immutable.Map() 18 | }; 19 | 20 | function splitTile(state, id, type) { 21 | const next_state = {...state}; 22 | const created_tile_id = next_state.tree.split(id, type); 23 | if (created_tile_id !== null) { 24 | next_state.current_id = created_tile_id; 25 | } 26 | return next_state; 27 | } 28 | 29 | function splitTileWithCurrentPage(state, id, type) { 30 | const created_id = state.tree.split(id, type); 31 | if (created_id === null) { 32 | return state; 33 | } 34 | 35 | const next_state = {...state}; 36 | next_state.current_id = created_id; 37 | 38 | const p = state.pages.get(id); 39 | if (!p) { 40 | return next_state; 41 | } 42 | 43 | const new_page = new PageState(p.url, created_id, p.dispatch); 44 | next_state.pages = state.pages.set(created_id, new_page); 45 | return next_state; 46 | } 47 | 48 | function closeTile(state, target_id) { 49 | const next_state = {...state}; 50 | const survived_tile_id = next_state.tree.remove(target_id); 51 | if (survived_tile_id === null) { 52 | return next_state; 53 | } 54 | 55 | if (next_state.current_id === target_id) { 56 | next_state.current_id = survived_tile_id; 57 | } 58 | 59 | if (state.pages.has(target_id)) { 60 | next_state.pages = state.pages.delete(target_id); 61 | } 62 | 63 | return next_state; 64 | } 65 | 66 | function openPage(state, page, from_start_page) { 67 | const next_state = {...state}; 68 | next_state.pages = state.pages.set(page.tile_id, page); 69 | if (from_start_page) { 70 | next_state.searches = state.searches.delete(page.tile_id); 71 | } 72 | return next_state; 73 | } 74 | 75 | function changeFocus(state, new_id) { 76 | if (state.current_id === new_id) { 77 | return state; 78 | } 79 | let next_state = {...state}; 80 | next_state.current_id = new_id; 81 | return next_state; 82 | } 83 | 84 | function focusNeighbor(state, next_current_id) { 85 | let next_state = {...state}; 86 | if (next_current_id === null) { 87 | return next_state; 88 | } 89 | 90 | next_state.current_id = next_current_id; 91 | return next_state; 92 | } 93 | 94 | function switchSplit(state, id) { 95 | let next_state = {...state}; 96 | next_state.tree = next_state.tree.switchSplitType(id); 97 | return next_state; 98 | } 99 | 100 | function swapTiles(state, id) { 101 | let next_state = {...state}; 102 | next_state.tree = next_state.tree.swapTiles(id); 103 | return next_state; 104 | } 105 | 106 | function notifyStartLoading(state, id, url) { 107 | const next_state = {...state}; 108 | next_state.pages = state.pages.update(id, p => { 109 | p.loading = true; 110 | p.url = url; 111 | return p; 112 | }); 113 | return next_state; 114 | } 115 | 116 | function notifyEndLoading(state, id) { 117 | const next_state = {...state}; 118 | const p = state.pages.get(id); 119 | p.updateStatus(); 120 | p.loading = false; 121 | History.add(p.url, p.title); 122 | next_state.pages = state.pages.set(id, p.clone()); 123 | return next_state; 124 | } 125 | 126 | function updateSearch(state, new_result, id) { 127 | const next_state = {...state}; 128 | next_state.searches = state.searches.set(id, new_result); 129 | return next_state; 130 | } 131 | 132 | export default function tilectron(state = init, action) { 133 | console.log(action.type); 134 | const id = action.tile_id !== undefined ? action.tile_id : state.current_id; 135 | switch (action.type) { 136 | case A.CHANGE_FOCUS: 137 | return changeFocus(state, id); 138 | case A.NOTIFY_START_LOADING: 139 | return notifyStartLoading(state, id, action.url); 140 | case A.NOTIFY_END_LOADING: 141 | return notifyEndLoading(state, id); 142 | case A.UPDATE_SEARCH: 143 | return updateSearch(state, action.result, id); 144 | case A.SPLIT_VERTICAL: 145 | return splitTile(state, id, SplitType.Vertical); 146 | case A.SPLIT_HORIZONTAL: 147 | return splitTile(state, id, SplitType.Horizontal); 148 | case A.SPLIT_VERTICAL_WITH_CURRENT_PAGE: 149 | return splitTileWithCurrentPage(state, id, SplitType.Vertical); 150 | case A.SPLIT_HORIZONTAL_WITH_CURRENT_PAGE: 151 | return splitTileWithCurrentPage(state, id, SplitType.Horizontal); 152 | case A.OPEN_PAGE: 153 | return openPage(state, action.page, action.from_start_page); 154 | case A.CLOSE_TILE: 155 | return closeTile(state, id); 156 | case A.FOCUS_LEFT: 157 | return focusNeighbor(state, state.tree.getLeftOf(state.current_id)); 158 | case A.FOCUS_RIGHT: 159 | return focusNeighbor(state, state.tree.getRightOf(state.current_id)); 160 | case A.FOCUS_UP: 161 | return focusNeighbor(state, state.tree.getUpOf(state.current_id)); 162 | case A.FOCUS_DOWN: 163 | return focusNeighbor(state, state.tree.getDownOf(state.current_id)); 164 | case A.SWITCH_SPLIT: 165 | return switchSplit(state, id); 166 | case A.SWAP_TILES: 167 | return swapTiles(state, id); 168 | default: 169 | console.log('Unknown action: ' + action.type); 170 | return state; 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /browser/menu.ts: -------------------------------------------------------------------------------- 1 | import {buildFromTemplate, setApplicationMenu} from 'menu'; 2 | import {openExternal} from 'shell'; 3 | import * as app from 'app'; 4 | 5 | export default function setMenu() { 6 | let template = [ 7 | { 8 | label: 'Edit', 9 | submenu: [ 10 | { 11 | label: 'Undo', 12 | accelerator: 'CmdOrCtrl+Z', 13 | role: 'undo' 14 | }, 15 | { 16 | label: 'Redo', 17 | accelerator: 'Shift+CmdOrCtrl+Z', 18 | role: 'redo' 19 | }, 20 | { 21 | type: 'separator' 22 | }, 23 | { 24 | label: 'Cut', 25 | accelerator: 'CmdOrCtrl+X', 26 | role: 'cut' 27 | }, 28 | { 29 | label: 'Copy', 30 | accelerator: 'CmdOrCtrl+C', 31 | role: 'copy' 32 | }, 33 | { 34 | label: 'Paste', 35 | accelerator: 'CmdOrCtrl+V', 36 | role: 'paste' 37 | }, 38 | { 39 | label: 'Select All', 40 | accelerator: 'CmdOrCtrl+A', 41 | role: 'selectall' 42 | }, 43 | ] 44 | }, 45 | { 46 | label: 'View', 47 | submenu: [ 48 | { 49 | label: 'Reload', 50 | accelerator: 'CmdOrCtrl+R', 51 | click: function(_: any, focusedWindow: GitHubElectron.BrowserWindow) { 52 | if (focusedWindow) { 53 | focusedWindow.reload(); 54 | } 55 | } 56 | }, 57 | { 58 | label: 'Toggle Full Screen', 59 | accelerator: (function() { 60 | if (process.platform == 'darwin') 61 | return 'Ctrl+Command+F'; 62 | else 63 | return 'F11'; 64 | })(), 65 | click: function(_: any, focusedWindow: GitHubElectron.BrowserWindow) { 66 | if (focusedWindow) 67 | focusedWindow.setFullScreen(!focusedWindow.isFullScreen()); 68 | } 69 | }, 70 | { 71 | label: 'Toggle Developer Tools', 72 | accelerator: (function() { 73 | if (process.platform == 'darwin') 74 | return 'Alt+Command+I'; 75 | else 76 | return 'Ctrl+Shift+I'; 77 | })(), 78 | click: function(_: any, focusedWindow: GitHubElectron.BrowserWindow) { 79 | if (focusedWindow) 80 | focusedWindow.toggleDevTools(); 81 | } 82 | }, 83 | ] 84 | }, 85 | { 86 | label: 'Window', 87 | role: 'window', 88 | submenu: [ 89 | { 90 | label: 'Minimize', 91 | accelerator: 'CmdOrCtrl+M', 92 | role: 'minimize' 93 | }, 94 | { 95 | label: 'Close', 96 | accelerator: 'CmdOrCtrl+W', 97 | role: 'close' 98 | }, 99 | { 100 | type: 'separator' 101 | }, 102 | { 103 | label: 'Bring All to Front', 104 | role: 'front' 105 | } 106 | ] 107 | }, 108 | { 109 | label: 'Help', 110 | role: 'help', 111 | submenu: [ 112 | { 113 | label: 'Repository', 114 | click: function() { openExternal('http://github.com/rhysd/Tilectron') } 115 | }, 116 | ] 117 | }, 118 | ] as GitHubElectron.MenuItemOptions[]; 119 | 120 | if (process.platform == 'darwin') { 121 | template.unshift({ 122 | label: 'Tilectron', 123 | submenu: [ 124 | { 125 | label: 'About Tilectron', 126 | role: 'about' 127 | }, 128 | { 129 | type: 'separator' 130 | }, 131 | { 132 | label: 'Services', 133 | role: 'services', 134 | submenu: [] 135 | }, 136 | { 137 | type: 'separator' 138 | }, 139 | { 140 | label: 'Hide Tilectron', 141 | accelerator: 'Command+H', 142 | role: 'hide' 143 | }, 144 | { 145 | label: 'Hide Others', 146 | accelerator: 'Command+Shift+H', 147 | role: 'hideothers:' 148 | }, 149 | { 150 | label: 'Show All', 151 | role: 'unhide:' 152 | }, 153 | { 154 | type: 'separator' 155 | }, 156 | { 157 | label: 'Quit', 158 | accelerator: 'Command+Q', 159 | click: function() { app.quit(); } 160 | }, 161 | ] 162 | } as GitHubElectron.MenuItemOptions); 163 | } 164 | 165 | let menu = buildFromTemplate(template); 166 | setApplicationMenu(menu); 167 | } 168 | -------------------------------------------------------------------------------- /renderer/tile-tree.js: -------------------------------------------------------------------------------- 1 | export class TileLeaf { 2 | constructor(parent, id) { 3 | this.parent = parent; 4 | this.id = id; 5 | } 6 | 7 | clone() { 8 | return new TileLeaf(this.parent, this.id); 9 | } 10 | 11 | searchLeaf(id) { 12 | return this.id === id ? this : null; 13 | } 14 | } 15 | 16 | export const SplitType = { 17 | Horizontal: Symbol('horizontal'), 18 | Vertical: Symbol('vertical') 19 | }; 20 | 21 | const Direction = { 22 | Left: Symbol('left'), 23 | Right: Symbol('right') 24 | }; 25 | 26 | export class ContainerKnot { 27 | constructor(parent, left, right, type) { 28 | this.parent = parent; 29 | this.left = left; 30 | this.right = right; 31 | this.left.parent = this; 32 | this.right.parent = this; 33 | this.split_type = type; 34 | } 35 | 36 | clone() { 37 | return new ContainerKnot( 38 | this.parent, 39 | this.left, 40 | this.right, 41 | this.split_type 42 | ); 43 | } 44 | 45 | replaceChild(old_child, new_child) { 46 | if (this.left === old_child) { 47 | this.left = new_child; 48 | new_child.parent = this; 49 | } else if (this.right === old_child) { 50 | this.right = new_child; 51 | new_child.parent = this; 52 | } else { 53 | console.log('Invalid old child', old_child); 54 | } 55 | } 56 | 57 | getChild(direction) { 58 | if (direction === Direction.Left) { 59 | return this.left; 60 | } else { 61 | return this.right; 62 | } 63 | } 64 | 65 | getSiblingOf(child) { 66 | if (this.left === child) { 67 | return this.right; 68 | } else if (this.right === child) { 69 | return this.left; 70 | } else { 71 | console.log('Not a child', child); 72 | return null; 73 | } 74 | } 75 | 76 | searchLeaf(id) { 77 | return this.left.searchLeaf(id) || this.right.searchLeaf(id); 78 | } 79 | } 80 | 81 | export default class TileTree { 82 | constructor(root, next_id) { 83 | this.root = root || new TileLeaf(null, 0); 84 | this.next_id = next_id || 1; 85 | } 86 | 87 | // Note: 88 | // Clone both self and its root because react component detects different properties. 89 | // component takes the root as property, not tree. 90 | clone() { 91 | return new TileTree(this.root.clone(), this.next_id); 92 | } 93 | 94 | split(id, split_type) { 95 | const target_leaf = this.root.searchLeaf(id); 96 | if (target_leaf === null) { 97 | console.log('Invalid id: ' + id); 98 | return null; 99 | } 100 | 101 | const new_leaf = new TileLeaf(null, this.next_id++); 102 | const target_parent = target_leaf.parent; 103 | const new_container = new ContainerKnot(target_parent, target_leaf, new_leaf, split_type); 104 | 105 | if (target_parent === null) { 106 | this.root = new_container; 107 | } else { 108 | target_parent.replaceChild(target_leaf, new_container); 109 | } 110 | 111 | return new_leaf.id; 112 | } 113 | 114 | remove(id) { 115 | const target_leaf = this.root.searchLeaf(id); 116 | if (target_leaf === null) { 117 | console.log('Invalid id: ' + id); 118 | return null; // Error 119 | } 120 | 121 | const target_parent = target_leaf.parent; 122 | if (target_parent === null) { 123 | return null; // Root 124 | } 125 | 126 | const sibling = target_parent.getSiblingOf(target_leaf); 127 | const closer_dir = target_parent.left === sibling ? 128 | Direction.Right : 129 | Direction.Left; 130 | 131 | if (sibling === null) { 132 | return null; // Error 133 | } 134 | 135 | const parent_of_parent = target_parent.parent; 136 | if (parent_of_parent === null) { 137 | this.root = sibling; 138 | sibling.parent = null; 139 | } else { 140 | parent_of_parent.replaceChild(target_parent, sibling); 141 | } 142 | 143 | let c = sibling; 144 | while (c.id === undefined) { 145 | c = c.getChild(closer_dir); 146 | } 147 | 148 | return c.id; 149 | } 150 | 151 | getNeighborImpl(target_node, direction, split_type) { 152 | const parent = target_node.parent; 153 | if (parent === null) { 154 | // Reached root; not found 155 | return null; 156 | } 157 | 158 | if (parent.split_type === split_type) { 159 | const opposite_dir = direction === Direction.Left ? 160 | Direction.Right : 161 | Direction.Left; 162 | const sibling = parent.getChild(opposite_dir); 163 | 164 | if (sibling === target_node) { 165 | // Found! 166 | let c = parent.getChild(direction); 167 | while (c instanceof ContainerKnot) { 168 | if (c.split_type === split_type) { 169 | c = c.getChild(opposite_dir); 170 | } else { 171 | c = c.left; 172 | } 173 | } 174 | return c.id; 175 | } 176 | } 177 | 178 | return this.getNeighborImpl(parent, direction, split_type); 179 | } 180 | 181 | getNeighbor(id, direction, split_type) { 182 | let target_leaf = this.root.searchLeaf(id); 183 | if (target_leaf === null) { 184 | console.log('Invalid id: ' + id); 185 | return null; // Error 186 | } 187 | return this.getNeighborImpl(target_leaf, direction, split_type); 188 | } 189 | 190 | getLeftOf(id) { 191 | return this.getNeighbor(id, Direction.Left, SplitType.Vertical); 192 | } 193 | getRightOf(id) { 194 | return this.getNeighbor(id, Direction.Right, SplitType.Vertical); 195 | } 196 | getUpOf(id) { 197 | return this.getNeighbor(id, Direction.Left, SplitType.Horizontal); 198 | } 199 | getDownOf(id) { 200 | return this.getNeighbor(id, Direction.Right, SplitType.Horizontal); 201 | } 202 | 203 | switchSplitType(id) { 204 | let target_leaf = this.root.searchLeaf(id); 205 | if (target_leaf === null || target_leaf.parent === null) { 206 | return; 207 | } 208 | 209 | target_leaf.parent.split_type = 210 | target_leaf.parent.split_type === SplitType.Vertical ? 211 | SplitType.Horizontal : SplitType.Vertical; 212 | 213 | // Return new tree for updating react component 214 | return this.clone(); 215 | } 216 | 217 | swapTiles(id) { 218 | const t = this.root.searchLeaf(id); 219 | if (t === null || t.parent === null) { 220 | return; 221 | } 222 | 223 | const p = t.parent; 224 | const tmp = p.right; 225 | p.right = p.left; 226 | p.left = tmp; 227 | 228 | // Return new tree for updating react component 229 | return this.clone(); 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /tests/renderer/tile-tree_test.js: -------------------------------------------------------------------------------- 1 | import TileTree, {TileLeaf, ContainerKnot, SplitType} from '../../renderer/tile-tree'; 2 | import {assert} from 'chai'; 3 | 4 | describe('TileLeaf', () => { 5 | let l; 6 | 7 | beforeEach(() => { 8 | l = new TileLeaf(null, 99); 9 | }); 10 | 11 | describe('constructor', () => { 12 | it('generates new leaf', () => { 13 | assert.strictEqual(l.id, 99); 14 | assert.strictEqual(l.parent, null); 15 | }); 16 | }); 17 | 18 | describe('clone()', () => { 19 | it('generates clone of itself', () => { 20 | const c = l.clone(); 21 | assert.strictEqual(l.id, c.id); 22 | assert.strictEqual(l.parent, c.parent); 23 | assert.notStrictEqual(c, l); 24 | }); 25 | }); 26 | 27 | describe('searchLeaf()', () => { 28 | it('returns itself if it is a target', () => { 29 | const r = l.searchLeaf(99); 30 | assert.strictEqual(l, r); 31 | }); 32 | 33 | it('returns null if it is not a target', () => { 34 | const r = l.searchLeaf(999); 35 | assert.strictEqual(r, null); 36 | }); 37 | }); 38 | }); 39 | 40 | describe('ContainerKnot', () => { 41 | let l, r, k; 42 | 43 | beforeEach(() => { 44 | l = new TileLeaf(null, 1); 45 | r = new TileLeaf(null, 2); 46 | k = new ContainerKnot(null, l, r, SplitType.Vertical); 47 | }); 48 | 49 | describe('constructor', () => { 50 | it('generates new knot', () => { 51 | assert.strictEqual(k.left, l); 52 | assert.strictEqual(k.right, r); 53 | assert.strictEqual(k.parent, null); 54 | assert.strictEqual(k.split_type, SplitType.Vertical); 55 | assert.strictEqual(l.parent, k); 56 | assert.strictEqual(r.parent, k); 57 | }); 58 | }); 59 | 60 | describe('clone()', () => { 61 | it('generates clone of itself', () => { 62 | const c = k.clone(); 63 | assert.strictEqual(c.left, k.left); 64 | assert.strictEqual(c.right, k.right); 65 | assert.strictEqual(c.parent, k.parent); 66 | assert.strictEqual(c.split_type, k.split_type); 67 | assert.notStrictEqual(c, k); 68 | }); 69 | }); 70 | 71 | describe('replaceChild()', () => { 72 | it('replaces a child', () => { 73 | const other = new TileLeaf(null, 3); 74 | k.replaceChild(l, other); 75 | assert.strictEqual(k.left, other); 76 | k.replaceChild(r, other); 77 | assert.strictEqual(k.right, other); 78 | }); 79 | 80 | it('does nothing if argument is not a child', () => { 81 | const other = new TileLeaf(null, 3); 82 | const left = k.left; 83 | const right = k.right; 84 | k.replaceChild(other, other); 85 | assert.strictEqual(k.left, left); 86 | assert.strictEqual(k.right, right); 87 | }); 88 | }); 89 | 90 | describe('getSiblingOf()', () => { 91 | it('returns sibling of the child', () => { 92 | const other = new TileLeaf(null, 3); 93 | assert.strictEqual(k.getSiblingOf(l), r); 94 | assert.strictEqual(k.getSiblingOf(r), l); 95 | assert.strictEqual(k.getSiblingOf(other), null); 96 | }); 97 | }); 98 | 99 | describe('searchLeaf()', () => { 100 | it('returns child if it is a target', () => { 101 | const a = k.searchLeaf(1); 102 | assert.strictEqual(a, k.left); 103 | const b = k.searchLeaf(2); 104 | assert.strictEqual(b, k.right); 105 | }); 106 | 107 | it('returns null if children are not target', () => { 108 | const a = k.searchLeaf(3); 109 | assert.strictEqual(a, null); 110 | }); 111 | 112 | it('searches children recursively', () => { 113 | const other_leaf = new TileLeaf(null, 3); 114 | const k2 = new ContainerKnot(null, k, other_leaf, SplitType.Horizontal); 115 | const a = k2.searchLeaf(1); 116 | assert.strictEqual(a, k.left); 117 | const b = k2.searchLeaf(2); 118 | assert.strictEqual(b, k.right); 119 | const c = k2.searchLeaf(4); 120 | assert.strictEqual(c, null); 121 | }); 122 | }); 123 | }); 124 | 125 | describe('TileTree', () => { 126 | 127 | let t, l1, l2, r; 128 | 129 | // t 130 | // | 131 | // k1 132 | // /\ 133 | // l1:0 k2 134 | // /\ 135 | // l2:1 r:2 136 | 137 | // -------------- 138 | // | l1 | 139 | // -------------- 140 | // | l2 | r | 141 | // -------------- 142 | 143 | beforeEach(() => { 144 | t = new TileTree(); 145 | t.split(0, SplitType.Horizontal); 146 | t.split(1, SplitType.Vertical); 147 | l1 = t.root.left; 148 | l2 = t.root.right.left; 149 | r = t.root.right.right; 150 | }); 151 | 152 | describe('constructor', () => { 153 | it('creates new empty tree', () => { 154 | const t = new TileTree(); 155 | assert.instanceOf(t.root, TileLeaf); 156 | assert.equal(t.root.id, 0); 157 | assert.equal(t.parent, null); 158 | }); 159 | 160 | it('creates specified root and id', () => { 161 | const l = new TileLeaf(null, 99); 162 | const t = new TileTree(l, 1234); 163 | assert.instanceOf(t.root, TileLeaf); 164 | assert.equal(t.root.id, 99); 165 | assert.equal(t.root.parent, null); 166 | const new_id = t.split(99, SplitType.Vertical); 167 | assert.equal(new_id, 1234); 168 | }); 169 | }); 170 | 171 | describe('clone()', () => { 172 | it('creates clone of the tree', () => { 173 | const c = t.clone(); 174 | assert.equal(c.root.id, undefined); 175 | assert.instanceOf(c.root, ContainerKnot); 176 | assert.instanceOf(c.root.left, TileLeaf); 177 | assert.equal(c.root.left.id, 0); 178 | assert.instanceOf(c.root.right, ContainerKnot); 179 | assert.notStrictEqual(c, t); 180 | }); 181 | }); 182 | 183 | describe('split()', () => { 184 | it('splits a tile specified by id', () => { 185 | t.split(0, SplitType.Horizontal); 186 | const k = t.root.left; 187 | assert.instanceOf(k, ContainerKnot); 188 | assert.strictEqual(k.left.id, 0); 189 | assert.strictEqual(k.right.id, 3); 190 | 191 | t.split(2, SplitType.Vertical); 192 | const k2 = t.root.right.right; 193 | assert.instanceOf(k2, ContainerKnot); 194 | assert.strictEqual(k2.left.id, 2); 195 | assert.strictEqual(k2.right.id, 4); 196 | }); 197 | 198 | it('returns id of generated tile', () => { 199 | const i = t.split(1, SplitType.Horizontal); 200 | assert.strictEqual(i, 3); 201 | }); 202 | }); 203 | 204 | describe('remove()', () => { 205 | it('returns the id of sibling of removed leaf', () => { 206 | let i = t.remove(2); 207 | assert.notStrictEqual(r, null); 208 | assert.strictEqual(i, 1); 209 | }); 210 | 211 | it('can remove tile of terminal knot', () => { 212 | let i = t.remove(2); 213 | assert.strictEqual(t.root.right.id, 1); 214 | assert.strictEqual(i, 1); 215 | i = t.remove(0); 216 | assert.strictEqual(t.root.id, 1); 217 | assert.strictEqual(i, 1); 218 | }); 219 | 220 | it('can remove tile of non-terminal knot', () => { 221 | const i = t.remove(0); 222 | assert.strictEqual(t.root.left.id, 1); 223 | assert.strictEqual(t.root.right.id, 2); 224 | assert.strictEqual(i, 1); 225 | }); 226 | 227 | it('returns null if id is not found', () => { 228 | const i = t.remove(100); 229 | assert.strictEqual(i, null); 230 | }); 231 | 232 | it('does not remove last tile and returns null', () => { 233 | let t = new TileTree(); 234 | const i = t.remove(0); 235 | assert.strictEqual(i, null); 236 | assert.notStrictEqual(t.root, null); 237 | assert.strictEqual(t.root.id, 0); 238 | }); 239 | }); 240 | 241 | describe('getLeftOf()', () => { 242 | it('returns the left child of specified tile', () => { 243 | let i = t.getLeftOf(r.id); 244 | assert.strictEqual(i, l2.id); 245 | i = t.split(r.id, SplitType.Horizontal); 246 | i = t.getLeftOf(i); 247 | assert.strictEqual(i, l2.id); 248 | }); 249 | 250 | it('returns null if neighbor is not found', () => { 251 | let i = t.getLeftOf(l1.id); 252 | assert.strictEqual(i, null); 253 | i = t.getLeftOf(l2.id); 254 | assert.strictEqual(i, null); 255 | }); 256 | 257 | it('returns null if the id is not found', () => { 258 | let i = t.getLeftOf(99); 259 | assert.strictEqual(i, null); 260 | }); 261 | }); 262 | 263 | describe('getRightOf()', () => { 264 | it('returns the right child of specified tile', () => { 265 | let i = t.getRightOf(l2.id); 266 | assert.strictEqual(i, r.id); 267 | i = t.split(l2.id, SplitType.Horizontal); 268 | i = t.getRightOf(i); 269 | assert.strictEqual(i, r.id); 270 | }); 271 | 272 | it('returns null if neighbor is not found', () => { 273 | let i = t.getRightOf(l1.id); 274 | assert.strictEqual(i, null); 275 | i = t.getRightOf(r.id); 276 | assert.strictEqual(i, null); 277 | }); 278 | 279 | it('returns null if the id is not found', () => { 280 | let i = t.getRightOf(99); 281 | assert.strictEqual(i, null); 282 | }); 283 | }); 284 | 285 | describe('getUpOf()', () => { 286 | it('returns the upper child of specified tile', () => { 287 | let i = t.getUpOf(l2.id); 288 | assert.strictEqual(i, l1.id); 289 | i = t.getUpOf(r.id); 290 | assert.strictEqual(i, l1.id); 291 | i = t.split(l2.id, SplitType.Horizontal); 292 | i = t.getUpOf(i); 293 | assert.strictEqual(i, l2.id); 294 | }); 295 | 296 | it('returns null if neighbor is not found', () => { 297 | let i = t.getUpOf(l1.id); 298 | assert.strictEqual(i, null); 299 | }); 300 | 301 | it('returns null if the id is not found', () => { 302 | let i = t.getUpOf(99); 303 | assert.strictEqual(i, null); 304 | }); 305 | }); 306 | 307 | describe('getDownOf()', () => { 308 | it('returns the upper child of specified tile', () => { 309 | let i = t.getDownOf(l1.id); 310 | assert.strictEqual(i, l2.id); 311 | i = t.split(l2.id, SplitType.Horizontal); 312 | let i2 = t.getDownOf(l2.id); 313 | assert.strictEqual(i, i2); 314 | }); 315 | 316 | it('returns null if neighbor is not found', () => { 317 | let i = t.getDownOf(l2.id); 318 | assert.strictEqual(i, null); 319 | i = t.getDownOf(r.id); 320 | assert.strictEqual(i, null); 321 | }); 322 | 323 | it('returns null if the id is not found', () => { 324 | let i = t.getDownOf(99); 325 | assert.strictEqual(i, null); 326 | }); 327 | }); 328 | 329 | describe('switchSplitType()', () => { 330 | it('returns new tree', () => { 331 | const t2 = t.switchSplitType(l1.id); 332 | assert.notStrictEqual(t2, t); 333 | }); 334 | 335 | it('switches type from horizontal to vertical', () => { 336 | const t2 = t.switchSplitType(l1.id); 337 | assert.strictEqual(t2.root.split_type, SplitType.Vertical); 338 | }); 339 | 340 | it('switches type from vertical to horizontal', () => { 341 | const t2 = t.switchSplitType(r.id); 342 | assert.strictEqual(t2.root.split_type, SplitType.Horizontal); 343 | const t3 = t.switchSplitType(l2.id); 344 | assert.strictEqual(t3.root.split_type, SplitType.Horizontal); 345 | }); 346 | }); 347 | 348 | describe('swapTiles()', () => { 349 | it('returns new tree', () => { 350 | const t2 = t.swapTiles(l1.id); 351 | assert.notStrictEqual(t2, t); 352 | }); 353 | 354 | it('swaps between left and right in horizontal split', () => { 355 | const left = t.root.left; 356 | const right = t.root.right; 357 | const t2 = t.swapTiles(l1.id); 358 | assert.strictEqual(left, t2.root.right); 359 | assert.strictEqual(right, t2.root.left); 360 | }); 361 | 362 | it('swaps between left and right in vertical split', () => { 363 | const left = t.root.right.left; 364 | const right = t.root.right.right; 365 | const t2 = t.swapTiles(l2.id); 366 | assert.strictEqual(left, t2.root.right.right); 367 | assert.strictEqual(right, t2.root.right.left); 368 | const t3 = t2.swapTiles(r.id); 369 | assert.strictEqual(left, t3.root.right.left); 370 | assert.strictEqual(right, t3.root.right.right); 371 | }); 372 | }); 373 | }); 374 | --------------------------------------------------------------------------------