├── 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 | 
2 | ================================
3 | [](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 | 
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 |
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 |
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 |
--------------------------------------------------------------------------------