├── .travis.yml ├── .gitignore ├── src ├── components │ ├── controlled │ │ ├── isControlled.js │ │ └── wrapUpdate.js │ ├── GtkHBox.js │ ├── GtkVBox.js │ ├── GtkButton.js │ ├── GtkLabel.js │ ├── GtkHScale.js │ ├── GtkMisc.js │ ├── GtkVScale.js │ ├── GtkBin.js │ ├── GtkWindow.js │ ├── GtkRange.js │ ├── public.js │ ├── GtkContainer.js │ ├── GtkBox.js │ ├── GtkEntry.js │ ├── GtkScale.js │ ├── GtkToggleButton.js │ ├── GtkSwitch.js │ └── GtkWidget.js ├── lib │ ├── updateProperties.js │ └── withAdjustment.js ├── index.js ├── renderer.js └── reconciler.js ├── nix ├── typelib.nix └── nixpkgs.nix ├── default.nix ├── test ├── functional │ ├── dumps │ │ ├── events │ │ │ ├── signal_removal.dump │ │ │ ├── signal_update.dump │ │ │ └── signal_registration.dump │ │ ├── children │ │ │ ├── removal.dump │ │ │ ├── readding.dump │ │ │ └── reordering.dump │ │ └── inputs │ │ │ ├── scale_fixed.dump │ │ │ ├── scale_moved.dump │ │ │ ├── switch_fixed.dump │ │ │ ├── scale_external_toggle.dump │ │ │ ├── toggle_button_fixed.dump │ │ │ ├── entry_external_toggle.dump │ │ │ ├── entry_fixed.dump │ │ │ ├── switch_clicked.dump │ │ │ ├── switch_external_toggle.dump │ │ │ ├── toggle_button_clicked.dump │ │ │ ├── entry_typed_into.dump │ │ │ └── toggle_button_external_toggle.dump │ ├── apps │ │ ├── runApp.js │ │ ├── events.js │ │ ├── children.js │ │ └── inputs.js │ ├── specs │ │ ├── children_spec.py │ │ ├── events_spec.py │ │ ├── inputs_spec.py │ │ └── common │ │ │ └── __init__.py │ ├── webpack.config.js │ └── default.nix └── unit │ ├── components │ ├── GtkBinSpec.js │ ├── GtkHBoxSpec.js │ ├── GtkMiscSpec.js │ ├── GtkVBoxSpec.js │ ├── GtkLabelSpec.js │ ├── GtkButtonSpec.js │ ├── GtkHScaleSpec.js │ ├── GtkVScaleSpec.js │ ├── GtkWindowSpec.js │ ├── GtkRangeSpec.js │ ├── GtkContainerSpec.js │ ├── GtkBoxSpec.js │ ├── GtkScaleSpec.js │ ├── GtkSwitchSpec.js │ ├── GtkToggleButtonSpec.js │ ├── GtkEntrySpec.js │ └── GtkWidgetSpec.js │ └── reconcilerSpec.js ├── example ├── webpack.config.js └── index.jsx ├── README.md └── package.json /.travis.yml: -------------------------------------------------------------------------------- 1 | language: nix 2 | script: 3 | - nix-shell --run "npm i" 4 | - nix-shell --run "npm test" 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .nyc_output 3 | example/dist 4 | node_modules 5 | package-lock.json 6 | test-output 7 | build 8 | result -------------------------------------------------------------------------------- /src/components/controlled/isControlled.js: -------------------------------------------------------------------------------- 1 | module.exports = function isControlled() { 2 | return typeof this.value !== 'undefined'; 3 | }; 4 | -------------------------------------------------------------------------------- /src/components/GtkHBox.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkBox = require('./GtkBox')(imports); 3 | 4 | return class GtkHBox extends GtkBox { 5 | get InternalType() { 6 | return imports.gi.Gtk.HBox; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkVBox.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkBox = require('./GtkBox')(imports); 3 | 4 | return class GtkVBox extends GtkBox { 5 | get InternalType() { 6 | return imports.gi.Gtk.VBox; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkButton.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkBin = require('./GtkBin')(imports); 3 | 4 | return class GtkButton extends GtkBin { 5 | get InternalType() { 6 | return imports.gi.Gtk.Button; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkLabel.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkMisc = require('./GtkMisc')(imports); 3 | 4 | return class GtkLabel extends GtkMisc { 5 | get InternalType() { 6 | return imports.gi.Gtk.Label; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkHScale.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkScale = require('./GtkScale')(imports); 3 | 4 | return class GtkHScale extends GtkScale { 5 | get InternalType() { 6 | return imports.gi.Gtk.HScale; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkMisc.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkWidget = require('./GtkWidget')(imports); 3 | 4 | return class GtkMisc extends GtkWidget { 5 | get InternalType() { 6 | return imports.gi.Gtk.Misc; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkVScale.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkScale = require('./GtkScale')(imports); 3 | 4 | return class GtkVScale extends GtkScale { 5 | get InternalType() { 6 | return imports.gi.Gtk.VScale; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/GtkBin.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkContainer = require('./GtkContainer')(imports); 3 | 4 | return class GtkBin extends GtkContainer { 5 | get InternalType() { 6 | return imports.gi.Gtk.Bin; 7 | } 8 | }; 9 | }; 10 | -------------------------------------------------------------------------------- /nix/typelib.nix: -------------------------------------------------------------------------------- 1 | pkgs: 2 | let 3 | introspectionLibs = with pkgs.gnome3; [ 4 | gjs 5 | gtk 6 | gsettings_desktop_schemas 7 | pkgs.pango.out 8 | gdk_pixbuf 9 | atk 10 | pkgs.at_spi2_core 11 | ]; 12 | typelibPaths = map (p: "${p}/lib/girepository-1.0") introspectionLibs; 13 | in 14 | pkgs.lib.concatStringsSep ":" typelibPaths -------------------------------------------------------------------------------- /src/lib/updateProperties.js: -------------------------------------------------------------------------------- 1 | module.exports = function updateProperties(instance, set, unset) { 2 | /* eslint-disable no-param-reassign */ 3 | set.forEach(([ property, value ]) => { 4 | instance[property] = value; 5 | }); 6 | unset.forEach((property) => { 7 | instance[property] = null; 8 | }); 9 | /* eslint-enable no-param-reassign */ 10 | }; 11 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | let 2 | pkgs = (import ./nix/nixpkgs.nix).import {}; 3 | typelibPaths = (import ./nix/typelib.nix) pkgs; 4 | in 5 | with pkgs; 6 | stdenv.mkDerivation { 7 | name = "react-gtk"; 8 | version = "0.1.0"; 9 | src = ./.; 10 | 11 | buildInputs = [ nodejs-8_x gnome3.gjs gnome3.gtk gnome3.gsettings_desktop_schemas ]; 12 | 13 | GI_TYPELIB_PATH = typelibPaths; 14 | } 15 | -------------------------------------------------------------------------------- /nix/nixpkgs.nix: -------------------------------------------------------------------------------- 1 | let 2 | host_pkgs = import {}; 3 | src = host_pkgs.fetchFromGitHub { 4 | owner = "NixOS"; 5 | repo = "nixpkgs-channels"; 6 | rev = "45a419ab5a23c93421c18f3d9cde015ded22e712"; 7 | sha256 = "00mpq5p351xsk0p682xjggw17qgd079i45yj0aa6awawpckfx37s"; 8 | }; 9 | in 10 | { 11 | inherit src; 12 | import = import src; 13 | } 14 | -------------------------------------------------------------------------------- /test/functional/dumps/events/signal_removal.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /test/functional/dumps/events/signal_update.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /test/functional/dumps/events/signal_registration.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | -------------------------------------------------------------------------------- /test/functional/apps/runApp.js: -------------------------------------------------------------------------------- 1 | const ReactGtk = require('../../../src'); 2 | const React = require('react'); 3 | const h = React.createElement; 4 | 5 | const Gtk = imports.gi.Gtk; 6 | const Application = Gtk.Application; 7 | 8 | module.exports = function (AppComponent) { 9 | Gtk.init(null); 10 | 11 | const app = new Application(); 12 | 13 | app.connect('activate', () => { 14 | ReactGtk.render(h(AppComponent), app); 15 | }); 16 | app.run([]); 17 | }; 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const createRenderer = require('./renderer'); 2 | const createReconciler = require('./reconciler'); 3 | const publicComponents = require('./components/public')(imports); 4 | 5 | // Monkeypatch console for react 6 | global.console = { log: print, warn: print, error: print }; 7 | 8 | function log(...args) { 9 | if (process.env.DEBUG_REACT_GTK) { 10 | print(...args); 11 | } 12 | } 13 | 14 | module.exports = createRenderer(imports, createReconciler(imports, publicComponents, log)); 15 | -------------------------------------------------------------------------------- /src/components/GtkWindow.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | 3 | module.exports = function (imports) { 4 | const GtkContainer = require('./GtkContainer')(imports); 5 | 6 | return class GtkWindow extends GtkContainer { 7 | get InternalType() { 8 | return imports.gi.Gtk.Window; 9 | } 10 | 11 | constructor(props, rootContainerInstance, ...args) { 12 | const propsWithApplication = R.assoc('application', rootContainerInstance, props); 13 | super(propsWithApplication, rootContainerInstance, ...args); 14 | } 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /src/components/GtkRange.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkWidget = require('./GtkWidget')(imports); 3 | const withAdjustment = require('../lib/withAdjustment')(imports); 4 | 5 | return class GtkRange extends GtkWidget { 6 | get InternalType() { 7 | return imports.gi.Gtk.Range; 8 | } 9 | 10 | constructor(props) { 11 | const { adjustment, remaining } = withAdjustment.construct(props); 12 | super(remaining); 13 | this.instance.adjustment = adjustment; 14 | } 15 | 16 | update(changes) { 17 | const remaining = withAdjustment.update(this.instance.adjustment, changes); 18 | super.update(remaining); 19 | } 20 | }; 21 | }; 22 | -------------------------------------------------------------------------------- /src/components/public.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | return { 3 | 'gtk-bin': require('./GtkBin')(imports), 4 | 'gtk-box': require('./GtkBox')(imports), 5 | 'gtk-button': require('./GtkButton')(imports), 6 | 'gtk-entry': require('./GtkEntry')(imports), 7 | 'gtk-hbox': require('./GtkHBox')(imports), 8 | 'gtk-hscale': require('./GtkHScale')(imports), 9 | 'gtk-label': require('./GtkLabel')(imports), 10 | 'gtk-switch': require('./GtkSwitch')(imports), 11 | 'gtk-togglebutton': require('./GtkToggleButton')(imports), 12 | 'gtk-window': require('./GtkWindow')(imports), 13 | 'gtk-vbox': require('./GtkVBox')(imports), 14 | 'gtk-vscale': require('./GtkVScale')(imports) 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /test/functional/dumps/children/removal.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | -------------------------------------------------------------------------------- /test/unit/components/GtkBinSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkBin = require('../../../src/components/GtkBin'); 5 | 6 | describe('GtkBin', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Bin: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkBin once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkBin = injectGtkBin(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Bin.returns(instance); 23 | 24 | const gotInstance = new GtkBin({}); 25 | 26 | expect(imports.gi.Gtk.Bin.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/components/GtkHBoxSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkHBox = require('../../../src/components/GtkHBox'); 5 | 6 | describe('GtkHBox', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | HBox: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkHBox once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkHBox = injectGtkHBox(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.HBox.returns(instance); 23 | 24 | const gotInstance = new GtkHBox({}); 25 | 26 | expect(imports.gi.Gtk.HBox.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/components/GtkMiscSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkMisc = require('../../../src/components/GtkMisc'); 5 | 6 | describe('GtkMisc', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Misc: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkMisc once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkMisc = injectGtkMisc(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Misc.returns(instance); 23 | 24 | const gotInstance = new GtkMisc({}); 25 | 26 | expect(imports.gi.Gtk.Misc.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/components/GtkVBoxSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkVBox = require('../../../src/components/GtkVBox'); 5 | 6 | describe('GtkVBox', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | VBox: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkVBox once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkVBox = injectGtkVBox(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.VBox.returns(instance); 23 | 24 | const gotInstance = new GtkVBox({}); 25 | 26 | expect(imports.gi.Gtk.VBox.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/components/GtkContainer.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkWidget = require('./GtkWidget')(imports); 3 | 4 | return class GtkContainer extends GtkWidget { 5 | get InternalType() { 6 | return imports.gi.Gtk.Container; 7 | } 8 | 9 | appendChild(child) { 10 | const children = this.instance.get_children(); 11 | 12 | if (!children.includes(child.instance)) { 13 | this.instance.add(child.instance); 14 | } 15 | } 16 | 17 | insertBefore(child) { 18 | const children = this.instance.get_children(); 19 | 20 | if (!children.includes(child.instance)) { 21 | this.instance.add(child.instance); 22 | } 23 | } 24 | 25 | removeChild(child) { 26 | this.instance.remove(child.instance); 27 | } 28 | }; 29 | }; 30 | -------------------------------------------------------------------------------- /test/unit/components/GtkLabelSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkLabel = require('../../../src/components/GtkLabel'); 5 | 6 | describe('GtkLabel', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Label: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkLabel once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkLabel = injectGtkLabel(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Label.returns(instance); 23 | 24 | const gotInstance = new GtkLabel({}); 25 | 26 | expect(imports.gi.Gtk.Label.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /test/unit/components/GtkButtonSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkButton = require('../../../src/components/GtkButton'); 5 | 6 | describe('GtkButton', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Button: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkButton once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkButton = injectGtkButton(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Button.returns(instance); 23 | 24 | const gotInstance = new GtkButton({}); 25 | 26 | expect(imports.gi.Gtk.Button.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /example/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, 'index.jsx'), 6 | module: { 7 | rules: [ 8 | { 9 | test: /\.jsx?$/, 10 | exclude: /(node_modules|bower_components)/, 11 | use: { 12 | loader: 'babel-loader', 13 | options: { 14 | presets: [ 'es2015', 'react' ] 15 | } 16 | } 17 | } 18 | ] 19 | }, 20 | plugins: [ 21 | new webpack.DefinePlugin({ 22 | 'process.env': { 23 | NODE_ENV: process.env.NODE_ENV, 24 | DEBUG_REACT_GTK: '1' 25 | } 26 | }) 27 | ], 28 | output: { 29 | path: path.resolve(__dirname, 'dist'), 30 | filename: 'app.js' 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /test/functional/specs/children_spec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | from common import TestCase 5 | 6 | class TestChildrenApp(TestCase): 7 | name = "children" 8 | 9 | def test_children_reordering(self): 10 | downButton = self.app.childNamed('DOWN') 11 | 12 | for i in range(0, 2): 13 | downButton.click() 14 | self.assertDump('reordering', self.app) 15 | 16 | def test_children_removal(self): 17 | remove1Button = self.app.childNamed('R1') 18 | remove3Button = self.app.childNamed('R3') 19 | 20 | remove1Button.click() 21 | remove3Button.click() 22 | 23 | self.assertDump('removal', self.app) 24 | 25 | def test_children_readding(self): 26 | remove3Button = self.app.childNamed('R3') 27 | 28 | for i in range(0, 2): 29 | remove3Button.click() 30 | 31 | self.assertDump('readding', self.app) 32 | 33 | if __name__ == '__main__': 34 | unittest.main() 35 | -------------------------------------------------------------------------------- /test/functional/dumps/children/readding.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /test/functional/dumps/children/reordering.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 | 18 | -------------------------------------------------------------------------------- /src/components/GtkBox.js: -------------------------------------------------------------------------------- 1 | module.exports = function (imports) { 2 | const GtkContainer = require('./GtkContainer')(imports); 3 | 4 | return class GtkBox extends GtkContainer { 5 | get InternalType() { 6 | return imports.gi.Gtk.Box; 7 | } 8 | 9 | appendChild(child) { 10 | const children = this.instance.get_children(); 11 | 12 | if (children.includes(child.instance)) { 13 | this.instance.reorder_child(child.instance, -1); 14 | } else { 15 | this.instance.add(child.instance); 16 | } 17 | } 18 | 19 | insertBefore(child, beforeChild) { 20 | const children = this.instance.get_children(); 21 | 22 | if (!children.includes(child.instance)) { 23 | this.instance.add(child.instance); 24 | } 25 | 26 | const beforeChildIndex = children.indexOf(beforeChild.instance); 27 | this.instance.reorder_child(child.instance, beforeChildIndex); 28 | } 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /example/index.jsx: -------------------------------------------------------------------------------- 1 | const ReactGtk = require('../src/index'); 2 | const React = require('react'); 3 | const Component = React.Component; 4 | 5 | const Gtk = imports.gi.Gtk; 6 | const Application = Gtk.Application; 7 | 8 | class MyApp extends Component { 9 | constructor() { 10 | super(); 11 | this.state = { clicks: 0 }; 12 | } 13 | 14 | increaseClicks() { 15 | this.setState({ clicks: this.state.clicks + 1 }); 16 | } 17 | 18 | render() { 19 | const label = `${this.state.clicks} Click${this.state.clicks === 1 ? '' : 's'}`; 20 | 21 | return 22 | 23 | 24 | 25 | 26 | ; 27 | } 28 | } 29 | 30 | Gtk.init(null); 31 | 32 | const app = new Application(); 33 | 34 | app.connect('activate', () => { 35 | ReactGtk.render(, app); 36 | }); 37 | 38 | print('Starting app'); 39 | app.run([]); 40 | -------------------------------------------------------------------------------- /test/functional/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | module.exports = { 5 | entry: { 6 | children: path.resolve(__dirname, 'apps/children.js'), 7 | events: path.resolve(__dirname, 'apps/events.js'), 8 | inputs: path.resolve(__dirname, 'apps/inputs.js') 9 | }, 10 | module: { 11 | rules: [ 12 | { 13 | test: /\.js$/, 14 | exclude: /(node_modules|bower_components)/, 15 | use: { 16 | loader: 'babel-loader', 17 | options: { 18 | presets: [ 'es2015' ] 19 | } 20 | } 21 | } 22 | ] 23 | }, 24 | plugins: [ 25 | new webpack.DefinePlugin({ 26 | 'process.env': { 27 | NODE_ENV: process.env.NODE_ENV, 28 | DEBUG_REACT_GTK: '0' 29 | } 30 | }) 31 | ], 32 | output: { 33 | path: path.resolve(__dirname, '../../test-output/functional'), 34 | filename: '[name]Bundle.js' 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /test/functional/specs/events_spec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | from common import TestCase 5 | 6 | class TestEventsApp(TestCase): 7 | name = "events" 8 | 9 | def test_signal_registration(self): 10 | increaseButton = self.app.childNamed('Increase') 11 | 12 | for i in range(0, 2): 13 | increaseButton.click() 14 | self.assertDump('signal_registration', self.app) 15 | 16 | def test_signal_update(self): 17 | increaseButton = self.app.childNamed('Increase') 18 | increaseIncrementButton = self.app.childNamed('Increase Increment') 19 | 20 | increaseIncrementButton.click() 21 | for i in range(0, 2): 22 | increaseButton.click() 23 | self.assertDump('signal_update', self.app) 24 | 25 | def test_signal_removal(self): 26 | increaseButton = self.app.childNamed('Increase') 27 | disableButton = self.app.childNamed('Disable Event') 28 | 29 | disableButton.click() 30 | for i in range(0, 2): 31 | increaseButton.click() 32 | self.assertDump('signal_removal', self.app) 33 | 34 | if __name__ == '__main__': 35 | unittest.main() 36 | -------------------------------------------------------------------------------- /src/renderer.js: -------------------------------------------------------------------------------- 1 | const Reconciler = require('react-reconciler'); 2 | 3 | module.exports = function (imports, reconciler) { 4 | const roots = new Map(); 5 | const GtkReconciler = new Reconciler(reconciler); 6 | const ReactGtk = { 7 | render(element, callback, container) { 8 | const containerKey = typeof container === 'undefined' ? callback : container; 9 | const cb = typeof container !== 'undefined' ? callback : () => {}; 10 | let myRoot = roots.get(containerKey); 11 | if (!myRoot) { 12 | myRoot = GtkReconciler.createContainer(containerKey); 13 | roots.set(container, myRoot); 14 | } 15 | 16 | GtkReconciler.updateContainer(element, myRoot, null, cb); 17 | return GtkReconciler.getPublicRootInstance(myRoot); 18 | }, 19 | 20 | unmountComponentAtNode(container) { 21 | const myRoot = roots.get(container); 22 | if (myRoot) { 23 | GtkReconciler.updateContainer(null, myRoot, null, () => { 24 | roots.delete(container); 25 | }); 26 | } 27 | } 28 | }; 29 | 30 | return ReactGtk; 31 | }; 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-gtk 2 | 3 | [![Build Status](https://travis-ci.org/selaux/react-gtk.svg?branch=master)](https://travis-ci.org/selaux/react-gtk) 4 | 5 | A experimental React renderer written in JavaScript. It can be bundled using webpack 6 | to get an application bundle which can run in `gjs`, the Gnome projects JavaScript 7 | runtime. 8 | 9 | ## Status 10 | 11 | - [x] Rendering a simple application 12 | - [x] Make inputs controlled by react 13 | - [x] Handle dynamically added and removed children 14 | - [ ] Support for all widgets 15 | - [ ] Support for list models 16 | - [ ] Automated mapping of property string values to GTK consts 17 | 18 | ## Dev Dependencies 19 | 20 | We currently depend on gjs>=1.50.2, gtk3 and nodejs. You can choose to 21 | conveniently install them using the Nix package manager: 22 | 23 | ``` 24 | nix-shell 25 | ``` 26 | 27 | This will drop you in a shell with all the dependencies installed. 28 | 29 | __Note__: We use nodejs only for packaging. The application has to be run using 30 | gjs, otherwise it will not work. 31 | 32 | ## Running the Example Application 33 | 34 | Install dependencies: 35 | 36 | ``` 37 | npm i 38 | ``` 39 | 40 | Start the application: 41 | 42 | ``` 43 | npm start 44 | ``` 45 | -------------------------------------------------------------------------------- /src/lib/withAdjustment.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const updateProperties = require('./updateProperties'); 3 | 4 | const PASS_TO_ADJUSTMENT = [ 'lower', 'upper', 'stepIncrement', 'pageIncrement', 'pageSize', 'value' ]; 5 | 6 | module.exports = function withAdjustment(imports) { 7 | const GtkAdjustment = imports.gi.Gtk.Adjustment; 8 | 9 | return { 10 | construct(props) { 11 | const adjustment = new GtkAdjustment(R.pick(PASS_TO_ADJUSTMENT, props)); 12 | const remaining = R.omit(PASS_TO_ADJUSTMENT, props); 13 | 14 | return { adjustment, remaining }; 15 | }, 16 | 17 | update(adjustment, { set, unset }) { 18 | const adjustmentPropsToSet = R.filter(R.pipe(R.head, R.contains(R.__, PASS_TO_ADJUSTMENT)), set); 19 | const adjustmentPropsToUnset = R.filter(R.contains(R.__, PASS_TO_ADJUSTMENT), unset); 20 | const restSet = R.without(adjustmentPropsToSet, set); 21 | const restUnset = R.without(adjustmentPropsToUnset, unset); 22 | 23 | updateProperties(adjustment, adjustmentPropsToSet, adjustmentPropsToUnset); 24 | 25 | return { set: restSet, unset: restUnset }; 26 | } 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /test/unit/components/GtkHScaleSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkHScale = require('../../../src/components/GtkHScale'); 5 | 6 | describe('GtkHScale', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Adjustment: sinon.stub(), 11 | HScale: sinon.stub() 12 | }, 13 | GObject: { 14 | signal_lookup: sinon.stub().returns(0), 15 | signal_handler_block: sinon.stub(), 16 | signal_handler_unblock: sinon.stub(), 17 | signal_handler_is_connected: sinon.stub() 18 | } 19 | } 20 | 21 | }); 22 | const logStub = () => {}; 23 | 24 | it('should instance GtkHScale once', function () { 25 | const imports = getDefaultImports(); 26 | const GtkHScale = injectGtkHScale(imports, logStub); 27 | 28 | const instance = {}; 29 | imports.gi.Gtk.HScale.returns(instance); 30 | 31 | const gotInstance = new GtkHScale({}); 32 | 33 | expect(imports.gi.Gtk.HScale.callCount).to.equal(1); 34 | expect(gotInstance.instance).to.equal(instance); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/unit/components/GtkVScaleSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkVScale = require('../../../src/components/GtkVScale'); 5 | 6 | describe('GtkVScale', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Adjustment: sinon.stub(), 11 | VScale: sinon.stub() 12 | }, 13 | GObject: { 14 | signal_lookup: sinon.stub().returns(0), 15 | signal_handler_block: sinon.stub(), 16 | signal_handler_unblock: sinon.stub(), 17 | signal_handler_is_connected: sinon.stub() 18 | } 19 | } 20 | 21 | }); 22 | const logStub = () => {}; 23 | 24 | it('should instance GtkVScale once', function () { 25 | const imports = getDefaultImports(); 26 | const GtkVScale = injectGtkVScale(imports, logStub); 27 | 28 | const instance = {}; 29 | imports.gi.Gtk.VScale.returns(instance); 30 | 31 | const gotInstance = new GtkVScale({}); 32 | 33 | expect(imports.gi.Gtk.VScale.callCount).to.equal(1); 34 | expect(gotInstance.instance).to.equal(instance); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/scale_fixed.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/scale_moved.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/switch_fixed.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/scale_external_toggle.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/toggle_button_fixed.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/entry_external_toggle.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/entry_fixed.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/switch_clicked.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/switch_external_toggle.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/toggle_button_clicked.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/entry_typed_into.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/functional/dumps/inputs/toggle_button_external_toggle.dump: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9 | 10 | 11 | 14 | 15 | 16 | 19 | 20 | 21 | 24 | 25 | -------------------------------------------------------------------------------- /test/unit/components/GtkWindowSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkWindow = require('../../../src/components/GtkWindow'); 5 | 6 | describe('GtkWindow', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Window: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkWindow once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkWindow = injectGtkWindow(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Window.returns(instance); 23 | 24 | const gotInstance = new GtkWindow({}); 25 | 26 | expect(imports.gi.Gtk.Window.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | 30 | it('should set the application for the window', function () { 31 | const imports = getDefaultImports(); 32 | const GtkWindow = injectGtkWindow(imports, logStub); 33 | const application = 'my root app'; 34 | 35 | const instance = {}; 36 | imports.gi.Gtk.Window.returns(instance); 37 | 38 | new GtkWindow({}, application); 39 | 40 | expect(imports.gi.Gtk.Window.callCount).to.equal(1); 41 | expect(imports.gi.Gtk.Window.firstCall.args[0]).to.have.property('application', application); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/components/GtkEntry.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const isControlled = require('./controlled/isControlled'); 3 | const wrapUpdate = require('./controlled/wrapUpdate'); 4 | 5 | function wrapOnChanged(instance, onChanged) { 6 | return function wrappedonChanged(entry) { 7 | if (onChanged) { 8 | onChanged(entry, entry.get_text()); 9 | } 10 | 11 | if (instance.isControlled()) { 12 | entry.set_text(instance.value); 13 | } 14 | }; 15 | } 16 | 17 | module.exports = function (imports) { 18 | const GtkWidget = require('./GtkWidget')(imports); 19 | 20 | return class GtkEntry extends GtkWidget { 21 | get InternalType() { 22 | return imports.gi.Gtk.Entry; 23 | } 24 | 25 | constructor(props, ...args) { 26 | const appliedProps = R.omit([ 'onChanged', 'text' ], props); 27 | const set = [ 28 | [ 'onChanged', props.onChanged || (() => {}) ], 29 | [ 'text', props.text ] 30 | ].filter(([ , value ]) => typeof value !== 'undefined'); 31 | 32 | super(appliedProps, ...args); 33 | 34 | this.isControlled = isControlled.bind(this); 35 | this.update = wrapUpdate(imports, { 36 | controlledProp: 'text', 37 | handler: 'onChanged', 38 | signal: 'changed', 39 | wrappingFn: wrapOnChanged 40 | }, this.update); 41 | 42 | this.update({ set, unset: [] }); 43 | } 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/GtkScale.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const isControlled = require('./controlled/isControlled'); 3 | const wrapUpdate = require('./controlled/wrapUpdate'); 4 | 5 | function wrapOnValueChanged(instance, onValueChanged) { 6 | return function wrappedOnValueChanged(scale) { 7 | if (onValueChanged) { 8 | onValueChanged(scale, scale.get_value()); 9 | } 10 | 11 | if (instance.isControlled()) { 12 | scale.set_value(instance.value); 13 | } 14 | }; 15 | } 16 | 17 | module.exports = function (imports) { 18 | const GtkRange = require('./GtkRange')(imports); 19 | 20 | return class GtkScale extends GtkRange { 21 | get InternalType() { 22 | return imports.gi.Gtk.Scale; 23 | } 24 | 25 | constructor(props, ...args) { 26 | const appliedProps = R.omit([ 'onValueChanged', 'value' ], props); 27 | const set = [ 28 | [ 'onValueChanged', props.onValueChanged || (() => {}) ], 29 | [ 'value', props.value ] 30 | ].filter(([ , value ]) => typeof value !== 'undefined'); 31 | 32 | super(appliedProps, ...args); 33 | 34 | this.isControlled = isControlled.bind(this); 35 | this.update = wrapUpdate(imports, { 36 | controlledProp: 'value', 37 | handler: 'onValueChanged', 38 | signal: 'value-changed', 39 | wrappingFn: wrapOnValueChanged 40 | }, this.update); 41 | 42 | this.update({ set, unset: [] }); 43 | } 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/components/GtkToggleButton.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const isControlled = require('./controlled/isControlled'); 3 | const wrapUpdate = require('./controlled/wrapUpdate'); 4 | 5 | function wrapOnToggled(instance, onToggled) { 6 | return function wrappedOnToggled(btn) { 7 | const active = btn.get_active(); 8 | 9 | if (onToggled) { 10 | onToggled(btn, active); 11 | } 12 | 13 | if (instance.isControlled()) { 14 | btn.set_active(instance.value); 15 | } 16 | }; 17 | } 18 | 19 | module.exports = function (imports) { 20 | const GtkButton = require('./GtkButton')(imports); 21 | 22 | return class GtkToggleButton extends GtkButton { 23 | get InternalType() { 24 | return imports.gi.Gtk.ToggleButton; 25 | } 26 | 27 | constructor(props, ...args) { 28 | const appliedProps = R.omit([ 'onToggled', 'active' ], props); 29 | const set = [ 30 | [ 'onToggled', props.onToggled || (() => {}) ], 31 | [ 'active', props.active ] 32 | ].filter(([ , value ]) => typeof value !== 'undefined'); 33 | 34 | super(appliedProps, ...args); 35 | 36 | this.isControlled = isControlled.bind(this); 37 | this.update = wrapUpdate(imports, { 38 | controlledProp: 'active', 39 | handler: 'onToggled', 40 | signal: 'toggled', 41 | wrappingFn: wrapOnToggled 42 | }, this.update); 43 | 44 | this.update({ set, unset: [] }); 45 | } 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/components/GtkSwitch.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const isControlled = require('./controlled/isControlled'); 3 | const wrapUpdate = require('./controlled/wrapUpdate'); 4 | 5 | function wrapOnToggled(instance, onToggled) { 6 | return function wrappedOnToggled(btn) { 7 | if (onToggled) { 8 | onToggled(btn, btn.get_active()); 9 | } 10 | 11 | if (instance.isControlled()) { 12 | btn.set_active(instance.value); 13 | } 14 | 15 | return true; 16 | }; 17 | } 18 | 19 | module.exports = function (imports) { 20 | const GtkWidget = require('./GtkWidget')(imports); 21 | 22 | return class GtkSwitch extends GtkWidget { 23 | get InternalType() { 24 | return imports.gi.Gtk.Switch; 25 | } 26 | 27 | constructor(props, ...args) { 28 | const appliedProps = R.omit([ 'onToggled', 'active' ], props); 29 | const set = [ 30 | [ 'onToggled', props.onToggled || (() => {}) ], 31 | [ 'active', props.active ] 32 | ].filter(([ , value ]) => typeof value !== 'undefined'); 33 | 34 | super(appliedProps, ...args); 35 | 36 | this.isControlled = isControlled.bind(this); 37 | this.update = wrapUpdate(imports, { 38 | controlledProp: 'active', 39 | handler: 'onToggled', 40 | mappedHandler: 'onNotify::active', 41 | signal: 'notify::active', 42 | wrappingFn: wrapOnToggled 43 | }, this.update); 44 | 45 | this.update({ set, unset: [] }); 46 | } 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /test/functional/apps/events.js: -------------------------------------------------------------------------------- 1 | const runApp = require('./runApp'); 2 | const React = require('react'); 3 | const h = React.createElement; 4 | const Component = React.Component; 5 | 6 | class EventsApp extends Component { 7 | constructor() { 8 | super(); 9 | this.state = { disableEvent: false, value: 0, increment: 1 }; 10 | } 11 | 12 | toggleEvent() { 13 | this.setState({ disableEvent: !this.state.disableEvent }); 14 | } 15 | 16 | increase(inc) { 17 | this.setState({ value: this.state.value + inc }); 18 | } 19 | 20 | increaseIncrement() { 21 | this.setState({ increment: this.state.increment + 1 }); 22 | } 23 | 24 | render() { 25 | const valueLabel = `Value: ${this.state.value}`; 26 | const buttonLabel = this.state.disableEvent ? 'Enable Event' : 'Disable Event'; 27 | const onClicked = this.state.disableEvent ? null : this.increase.bind(this, this.state.increment); 28 | const buttonBaseProps = { key: 1, label: 'Increase' }; 29 | const buttonProps = onClicked ? Object.assign(buttonBaseProps, { onClicked }) : buttonBaseProps; 30 | 31 | return h('gtk-window', { title: 'react-gtk events test', defaultWidth: 200, defaultHeight: 100 }, 32 | h('gtk-vbox', {}, [ 33 | h('gtk-label', { key: 0, label: valueLabel }), 34 | h('gtk-button', buttonProps), 35 | h('gtk-button', { key: 2, label: 'Increase Increment', onClicked: this.increaseIncrement.bind(this) }), 36 | h('gtk-button', { key: 3, label: buttonLabel, onClicked: this.toggleEvent.bind(this) }) 37 | ])); 38 | } 39 | } 40 | 41 | runApp(EventsApp); 42 | -------------------------------------------------------------------------------- /test/functional/apps/children.js: -------------------------------------------------------------------------------- 1 | const runApp = require('./runApp'); 2 | const React = require('react'); 3 | const R = require('ramda'); 4 | const h = React.createElement; 5 | const Component = React.Component; 6 | 7 | const shiftArrayToRight = (places) => (a) => { 8 | const arr = R.clone(a); 9 | for (let i = 0; i < places; i += 1) { 10 | arr.unshift(arr.pop()); 11 | } 12 | return arr; 13 | }; 14 | 15 | class EventsApp extends Component { 16 | constructor() { 17 | super(); 18 | this.state = { all: [ 1, 2, 3, 4 ], removed: [], rotate: 0 }; 19 | } 20 | 21 | rotate(amount) { 22 | this.setState({ rotate: this.state.rotate + amount }); 23 | } 24 | 25 | toggle(number) { 26 | this.setState({ 27 | removed: R.ifElse(R.contains(number), R.without([ number ]), R.append(number))(this.state.removed) 28 | }); 29 | } 30 | 31 | render() { 32 | const buttons = R.map( 33 | (i) => h('gtk-button', { key: i, label: `R${i}`, onClicked: this.toggle.bind(this, i) }), 34 | this.state.all 35 | ); 36 | const items = R.pipe( 37 | shiftArrayToRight(this.state.rotate), 38 | R.without(this.state.removed), 39 | R.map((i) => h('gtk-label', { key: i, label: `This is ${i}` })) 40 | )(this.state.all); 41 | 42 | return h('gtk-window', { title: 'react-gtk children test', defaultWidth: 200, defaultHeight: 100 }, 43 | h('gtk-vbox', {}, [ 44 | h('gtk-hbox', {}, [ 45 | h('gtk-button', { key: -1, label: 'UP', onClicked: this.rotate.bind(this, -1) }), 46 | h('gtk-button', { key: -2, label: 'DOWN', onClicked: this.rotate.bind(this, +1) }), 47 | ...buttons 48 | ]), 49 | h('gtk-vbox', {}, items) 50 | ])); 51 | } 52 | } 53 | 54 | runApp(EventsApp); 55 | -------------------------------------------------------------------------------- /src/components/controlled/wrapUpdate.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | 3 | function blockHandler(GObject, instance, id) { 4 | if (id) { 5 | GObject.signal_handler_block(instance, id); 6 | } 7 | } 8 | 9 | function unblockHandler(GObject, instance, id) { 10 | if (GObject.signal_handler_is_connected(instance, id)) { 11 | GObject.signal_handler_unblock(instance, id); 12 | } 13 | } 14 | 15 | function rewriteHandler(GObject, input, signal, wrappingFn, handler) { 16 | const outerWrappingFn = (value) => (...args) => { 17 | const handlerToBlock = input.instance._connectedSignals[signal]; 18 | 19 | blockHandler(GObject, input.instance, handlerToBlock); 20 | const r = wrappingFn(input, value)(...args); 21 | unblockHandler(GObject, input.instance, handlerToBlock); 22 | 23 | return r; 24 | }; 25 | return outerWrappingFn(handler); 26 | } 27 | 28 | module.exports = function wrapUpdate(imports, controlScheme, update) { 29 | const GObject = imports.gi.GObject; 30 | const { controlledProp, handler, mappedHandler, signal, wrappingFn } = controlScheme; 31 | 32 | return function controlledUpdate(changes) { 33 | const { set, unset } = changes; 34 | const valueSet = set.find(([ prop ]) => prop === controlledProp); 35 | const handlerSet = set.find(([ prop ]) => prop === handler); 36 | const appliedHandlerSet = [ 37 | mappedHandler || handler, 38 | rewriteHandler(GObject, this, signal, wrappingFn, R.propOr(() => {}, 1, handlerSet)) 39 | ]; 40 | const appliedSet = R.pipe(R.without([ handlerSet ]), R.append(appliedHandlerSet))(set); 41 | const handlerToBlock = this.instance._connectedSignals[signal]; 42 | 43 | blockHandler(GObject, this.instance, handlerToBlock); 44 | 45 | update.call(this, { set: appliedSet, unset }); 46 | if (valueSet) { 47 | this.value = valueSet[1]; 48 | } 49 | 50 | unblockHandler(GObject, this.instance, handlerToBlock); 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-gtk", 3 | "version": "0.1.0", 4 | "description": "React renderer for gtk applications inside gjs", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "webpack --config example/webpack.config.js && gjs example/dist/app.js", 8 | "test": "npm run lint && nyc npm run test:unit && npm run test:functional", 9 | "lint": "eslint .", 10 | "test:unit": "mocha 'test/unit/**/*.js'", 11 | "build:functional": "webpack --config test/functional/webpack.config.js", 12 | "test:functional": "npm run build:functional && nix-build test/functional -A test" 13 | }, 14 | "keywords": [ 15 | "gtk", 16 | "react" 17 | ], 18 | "author": "", 19 | "license": "MIT", 20 | "dependencies": { 21 | "just-camel-case": "^1.0.0", 22 | "just-kebab-case": "^1.0.0", 23 | "ramda": "0.24.1", 24 | "react": "16.2.0", 25 | "react-reconciler": "0.7.0" 26 | }, 27 | "devDependencies": { 28 | "babel-core": "^6.26.0", 29 | "babel-loader": "^7.1.2", 30 | "babel-preset-es2015": "^6.24.1", 31 | "babel-preset-react": "^6.24.1", 32 | "chai": "^4.1.2", 33 | "eslint": "^4.7.0", 34 | "eslint-config-holidaycheck": "^0.12.1", 35 | "mocha": "^3.5.0", 36 | "nyc": "^11.2.1", 37 | "sinon": "^3.2.1", 38 | "webpack": "3.5.5" 39 | }, 40 | "eslintConfig": { 41 | "extends": "holidaycheck/es2015", 42 | "env": { 43 | "node": true 44 | }, 45 | "parserOptions": { 46 | "ecmaVersion": 6, 47 | "ecmaFeatures": { 48 | "jsx": true 49 | } 50 | }, 51 | "rules": { 52 | "max-params": [ 53 | "error", 54 | 6 55 | ], 56 | "max-statements": [ 57 | "error", 58 | 15 59 | ], 60 | "no-process-env": 0 61 | }, 62 | "overrides": [ 63 | { 64 | "files": [ 65 | "test/functional/**/*.js", 66 | "example/**/*.js", 67 | "src/index.js" 68 | ], 69 | "globals": { 70 | "imports": false, 71 | "print": false 72 | } 73 | }, 74 | { 75 | "files": [ 76 | "test/unit/**/*.js" 77 | ], 78 | "env": { 79 | "mocha": true 80 | }, 81 | "rules": { 82 | "camelcase": 0, 83 | "no-new": 0, 84 | "max-statements": [ 85 | "error", 86 | 15 87 | ] 88 | } 89 | } 90 | ] 91 | }, 92 | "eslintIgnore": [ 93 | "test-output/**/*", 94 | "result/**/*", 95 | "node_modules/**/*", 96 | "**/dist/**/*" 97 | ], 98 | "nyc": { 99 | "cache": false, 100 | "all": true, 101 | "include": [ 102 | "src/**/*.js" 103 | ], 104 | "reporter": [ 105 | "lcov", 106 | "text-summary" 107 | ], 108 | "report-dir": "./test-output/nyc" 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /test/unit/reconcilerSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const createReconciler = require('../../src/reconciler'); 5 | 6 | describe('reconciler', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Application: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | describe('create instance', function () { 18 | it('should throw for unknown components', () => { 19 | const imports = getDefaultImports(); 20 | const Reconciler = createReconciler(imports, {}, logStub); 21 | 22 | expect(() => Reconciler.createInstance('foo', {}, null, null, null)) 23 | .to.throw('Unknown component: foo'); 24 | }); 25 | }); 26 | 27 | describe('create text instance', function () { 28 | it('should throw because text instances are unsupported', () => { 29 | const imports = getDefaultImports(); 30 | const Reconciler = createReconciler(imports, {}, logStub); 31 | 32 | expect(() => Reconciler.createTextInstance('foo', null, null, null)) 33 | .to.throw('ReactGTK does not support text instances. Use gtk-label to display text'); 34 | }); 35 | }); 36 | 37 | describe('preparing update', function () { 38 | it('should return null if props are equal', function () { 39 | const imports = getDefaultImports(); 40 | const Reconciler = createReconciler(imports, {}, logStub); 41 | const oldProps = { props: 1 }; 42 | const newProps = { props: 1 }; 43 | 44 | expect(Reconciler.prepareUpdate(null, null, oldProps, newProps)).to.equal(null); 45 | }); 46 | 47 | it('should return null if only children differ', function () { 48 | const imports = getDefaultImports(); 49 | const Reconciler = createReconciler(imports, {}, logStub); 50 | const oldProps = { props: 1, children: [ 2 ] }; 51 | const newProps = { props: 1, children: [ 1 ] }; 52 | 53 | expect(Reconciler.prepareUpdate(null, null, oldProps, newProps)).to.equal(null); 54 | }); 55 | 56 | it('should return set when a prop differs from old prop', function () { 57 | const imports = getDefaultImports(); 58 | const Reconciler = createReconciler(imports, {}, logStub); 59 | const oldProps = { prop: 1, children: [ 2 ] }; 60 | const newProps = { prop: 2, children: [ 1 ] }; 61 | 62 | expect(Reconciler.prepareUpdate(null, null, oldProps, newProps)).to.deep.equal({ 63 | set: [ 64 | [ 'prop', 2 ] 65 | ], 66 | unset: [] 67 | }); 68 | }); 69 | 70 | it('should return unset a prop was removed', function () { 71 | const imports = getDefaultImports(); 72 | const Reconciler = createReconciler(imports, {}, logStub); 73 | const oldProps = { prop1: 1, prop2: 1 }; 74 | const newProps = { prop1: 1 }; 75 | 76 | expect(Reconciler.prepareUpdate(null, null, oldProps, newProps)).to.deep.equal({ 77 | set: [], 78 | unset: [ 'prop2' ] 79 | }); 80 | }); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /test/unit/components/GtkRangeSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkRange = require('../../../src/components/GtkRange'); 5 | 6 | describe('GtkRange', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Adjustment: sinon.stub(), 11 | Range: sinon.stub() 12 | }, 13 | GObject: { 14 | signal_lookup: sinon.stub().returns(0) 15 | } 16 | } 17 | 18 | }); 19 | const logStub = () => {}; 20 | 21 | it('should instance GtkRange once', function () { 22 | const imports = getDefaultImports(); 23 | const GtkRange = injectGtkRange(imports, logStub); 24 | 25 | const instance = {}; 26 | imports.gi.Gtk.Range.returns(instance); 27 | 28 | const gotInstance = new GtkRange({}); 29 | 30 | expect(imports.gi.Gtk.Range.callCount).to.equal(1); 31 | expect(gotInstance.instance).to.equal(instance); 32 | }); 33 | 34 | it('should instance a GtkAdjustment', function () { 35 | const imports = getDefaultImports(); 36 | const GtkRange = injectGtkRange(imports, logStub); 37 | 38 | const instance = {}; 39 | const adjustmentInstance = {}; 40 | imports.gi.Gtk.Range.returns(instance); 41 | imports.gi.Gtk.Adjustment.returns(adjustmentInstance); 42 | 43 | const gotInstance = new GtkRange({}); 44 | 45 | expect(imports.gi.Gtk.Adjustment.callCount).to.equal(1); 46 | expect(gotInstance.instance.adjustment).to.equal(adjustmentInstance); 47 | }); 48 | 49 | it('should pass correct props to GtkAdjustment when initializing', function () { 50 | const imports = getDefaultImports(); 51 | const GtkRange = injectGtkRange(imports, logStub); 52 | 53 | const instance = {}; 54 | const adjustmentInstance = {}; 55 | imports.gi.Gtk.Range.returns(instance); 56 | imports.gi.Gtk.Adjustment.returns(adjustmentInstance); 57 | 58 | new GtkRange({ 59 | value: -1, 60 | lower: 0, 61 | upper: 1, 62 | stepIncrement: 2, 63 | pageIncrement: 3, 64 | pageSize: 4, 65 | other: 5 66 | }); 67 | 68 | expect(imports.gi.Gtk.Adjustment.firstCall.args[0]).to.deep.equal({ 69 | value: -1, 70 | lower: 0, 71 | upper: 1, 72 | stepIncrement: 2, 73 | pageIncrement: 3, 74 | pageSize: 4 75 | }); 76 | expect(imports.gi.Gtk.Range.firstCall.args[0]).to.deep.equal({ 77 | other: 5 78 | }); 79 | }); 80 | 81 | it('should pass correct props to GtkAdjustment when updating', function () { 82 | const imports = getDefaultImports(); 83 | const GtkRange = injectGtkRange(imports, logStub); 84 | 85 | const instance = {}; 86 | const adjustmentInstance = { value: 2 }; 87 | imports.gi.Gtk.Range.returns(instance); 88 | imports.gi.Gtk.Adjustment.returns(adjustmentInstance); 89 | 90 | const rangeInstance = new GtkRange({ value: 2, some: 'prop' }); 91 | 92 | rangeInstance.update({ set: [ [ 'value', 1 ] ], unset: [ 'some' ] }); 93 | expect(rangeInstance.instance.some).to.equal(null); 94 | expect(rangeInstance.instance.adjustment.value).to.equal(1); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/components/GtkWidget.js: -------------------------------------------------------------------------------- 1 | const R = require('ramda'); 2 | const kebabCase = require('just-kebab-case'); 3 | const updateProperties = require('../lib/updateProperties'); 4 | 5 | const withoutChildren = R.omit([ 'children' ]); 6 | 7 | function propNameToSignal(handlerName) { 8 | return handlerName.slice(2).split('::').map(kebabCase).join('::'); 9 | } 10 | 11 | function isSignalHandler(GObject, type, propName) { 12 | return R.startsWith('on', propName) && GObject.signal_lookup(propNameToSignal(propName).split('::')[0], type) !== 0; 13 | } 14 | 15 | function getSignalHandlersFromProps(GObject, type, props) { 16 | return R.pipe( 17 | R.keys, 18 | R.filter(R.partial(isSignalHandler, [ GObject, type ])) 19 | )(props); 20 | } 21 | 22 | function updateSignalHandlers(instance, set, unset) { 23 | /* eslint-disable no-param-reassign */ 24 | const disconnect = (signalName) => { 25 | if (typeof instance._connectedSignals[signalName] !== 'undefined') { 26 | instance.disconnect(instance._connectedSignals[signalName]); 27 | delete instance._connectedSignals[signalName]; 28 | } 29 | }; 30 | const connect = (signalName, fn) => { 31 | instance._connectedSignals[signalName] = instance.connect(signalName, fn); 32 | }; 33 | instance._connectedSignals = instance._connectedSignals || {}; 34 | 35 | R.forEach(R.pipe(propNameToSignal, disconnect), unset); 36 | R.pipe( 37 | R.toPairs, 38 | R.forEach(([ name, fn ]) => { 39 | const signalName = propNameToSignal(name); 40 | disconnect(signalName); 41 | connect(signalName, fn); 42 | }) 43 | )(set); 44 | /* eslint-enable no-param-reassign */ 45 | } 46 | 47 | module.exports = function (imports) { 48 | const Gtk = imports.gi.Gtk; 49 | const GObject = imports.gi.GObject; 50 | 51 | return class GtkWidget { 52 | get InternalType() { 53 | return Gtk.Widget; 54 | } 55 | 56 | constructor(props) { 57 | const signalHandlers = getSignalHandlersFromProps(GObject, this.InternalType, props); 58 | 59 | const appliedProps = R.pipe( 60 | R.omit(signalHandlers), 61 | withoutChildren 62 | )(props); 63 | const instance = new this.InternalType(appliedProps); 64 | 65 | updateSignalHandlers(instance, R.pick(signalHandlers, props), []); 66 | 67 | this.instance = instance; 68 | } 69 | 70 | appendChild() { 71 | throw new Error(`Cannot add children to a ${ this.InternalType}`); 72 | } 73 | 74 | insertBefore() { 75 | throw new Error(`Cannot add children to a ${ this.InternalType}`); 76 | } 77 | 78 | removeChild() { 79 | throw new Error(`Cannot remove children from a ${ this.InternalType}`); 80 | } 81 | 82 | update(changes) { 83 | const isValidHandler = R.partial(isSignalHandler, [ GObject, this.InternalType ]); 84 | const signalHandlersToSet = R.pipe( 85 | R.filter(R.pipe(R.head, isValidHandler)), 86 | R.fromPairs 87 | )(changes.set); 88 | const signalHandlersToUnset = R.filter(isValidHandler, changes.unset); 89 | 90 | // Make properties apply first, so change handlers wont get triggered by updates -- hacky 91 | updateProperties(this.instance, changes.set, changes.unset); 92 | updateSignalHandlers(this.instance, signalHandlersToSet, signalHandlersToUnset); 93 | } 94 | }; 95 | }; 96 | -------------------------------------------------------------------------------- /test/functional/default.nix: -------------------------------------------------------------------------------- 1 | let 2 | nixpkgs = import ../../nix/nixpkgs.nix; 3 | pkgs = nixpkgs.import {}; 4 | typelibPaths = (import ../../nix/typelib.nix) pkgs; 5 | makeTest = import "${nixpkgs.src}/nixos/tests/make-test.nix"; 6 | in 7 | with pkgs; 8 | let 9 | pythonDogtail = python3Packages.buildPythonPackage rec { 10 | pname = "dogtail"; 11 | name = "${pname}-${version}"; 12 | version = "0.9.9"; 13 | src = pythonPackages.fetchPypi { 14 | inherit pname version; 15 | sha256 = "0p5wfssvzr9w0bvhllzbbd8fnp4cca2qxcpcsc33dchrmh5n552x"; 16 | }; 17 | buildInputs = [ gnome3.gtk python3Packages.pygobject3 python3Packages.pyatspi at_spi2_core ]; 18 | propagatedBuildInputs = [ gnome3.gtk python3Packages.pygobject3 python3Packages.pyatspi at_spi2_core ]; 19 | preBuild = '' 20 | export USER="no one" 21 | export CERTIFIED_GNOMIE="yes" 22 | export LD_LIBRARY_PATH="" 23 | export XDG_CONFIG_DIRS="" 24 | export PKG_CONFIG_PATH="" 25 | ''; 26 | doCheck = false; 27 | }; 28 | testCases = stdenv.mkDerivation { 29 | name = "react-gtk-functional-test-cases"; 30 | srcs = [ ../../test-output/functional ./dumps ./specs ]; 31 | buildInputs = [ gtk3 at_spi2_core python3Packages.wrapPython makeWrapper wrapGAppsHook ]; 32 | pythonPath = [ pythonDogtail python3Packages.pygobject3 python3Packages.pyatspi ]; 33 | sourceRoot = "."; 34 | installPhase = '' 35 | mkdir -p $out/bin 36 | cp -r ./specs/* $out/bin 37 | chmod +x $out/bin/* 38 | 39 | mkdir -p $out/bundles 40 | cp -r ./functional/* $out/bundles 41 | 42 | cp -r ./dumps $out 43 | 44 | substituteInPlace $out/bin/common/__init__.py --replace "test-output/functional" "$out/bundles" 45 | substituteInPlace $out/bin/common/__init__.py --replace "test/functional/dumps" "$out/dumps" 46 | substituteInPlace $out/bin/common/__init__.py --replace "/usr/bin/gjs" "${gnome3.gjs}/bin/gjs" 47 | 48 | wrapPythonPrograms 49 | ''; 50 | }; 51 | runTestCase = case: "$machine->succeed(\"su - alice -c 'DISPLAY=:0.0 GI_TYPELIB_PATH=${typelibPaths} GTK_MODULES='gail:atk-bridge' OUT=$out ${testCases}/bin/${case}.py'\");"; 52 | in 53 | { 54 | inherit testCases; 55 | test = makeTest ({ pkgs, ...} : { 56 | name = "react-gtk-functional-tests"; 57 | 58 | machine = 59 | { config, pkgs, ... }: 60 | 61 | { imports = [ "${nixpkgs.src}/nixos/tests/common/user-account.nix" ]; 62 | 63 | services.xserver.enable = true; 64 | 65 | services.xserver.displayManager.auto.enable = true; 66 | services.xserver.displayManager.auto.user = "alice"; 67 | services.xserver.windowManager.default = "icewm"; 68 | services.xserver.windowManager.icewm.enable = true; 69 | services.xserver.desktopManager.default = "none"; 70 | services.gnome3.at-spi2-core.enable = true; 71 | services.packagekit.enable = true; 72 | 73 | virtualisation.memorySize = 1024; 74 | }; 75 | 76 | testScript = 77 | '' 78 | $machine->waitForX; 79 | $machine->sleep(15); 80 | $machine->succeed("su - alice -c 'DISPLAY=:0.0 ${at_spi2_core}/libexec/at-spi-bus-launcher &'"); 81 | $machine->sleep(1); 82 | 83 | my $out = './test-output'; 84 | ${runTestCase "children_spec"} 85 | ${runTestCase "events_spec"} 86 | ${runTestCase "inputs_spec"} 87 | 88 | $machine->screenshot("screen"); 89 | ''; 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /test/functional/specs/inputs_spec.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import unittest 4 | import dogtail.predicate as predicate 5 | import dogtail.rawinput as rawinput 6 | from common import TestCase 7 | 8 | def find_switch(app): 9 | return app.findChildren(predicate.GenericPredicate(roleName='toggle button'))[1] 10 | 11 | def find_text_entry(app): 12 | return app.findChildren(predicate.GenericPredicate(roleName='text'))[0] 13 | 14 | def find_spin_button(app): 15 | return app.findChildren(predicate.GenericPredicate(roleName='spin button'))[0] 16 | 17 | class TestInputsApp(TestCase): 18 | name = "inputs" 19 | 20 | def test_toggle_clicked(self): 21 | toggleButton = self.app.childNamed('Toggle Me') 22 | toggleButton.click() 23 | 24 | self.assertDump('toggle_button_clicked', self.app) 25 | 26 | def test_toggle_external_toggle(self): 27 | activateButton = self.app.childNamed('Activate Toggle') 28 | activateButton.click() 29 | 30 | self.assertDump('toggle_button_external_toggle', self.app) 31 | 32 | def test_toggle_fixed(self): 33 | fixButton = self.app.childNamed('Fix Values') 34 | fixButton.click() 35 | 36 | toggleButton = self.app.childNamed('Toggle Me') 37 | toggleButton.click() 38 | 39 | self.assertDump('toggle_button_fixed', self.app) 40 | 41 | def test_switch_clicked(self): 42 | switch = find_switch(self.app) 43 | switch.click() 44 | 45 | self.assertDump('switch_clicked', self.app) 46 | 47 | def test_switch_external_toggle(self): 48 | activateButton = self.app.childNamed('Activate Switch') 49 | activateButton.click() 50 | 51 | self.assertDump('switch_external_toggle', self.app) 52 | 53 | def test_switch_fixed(self): 54 | fixButton = self.app.childNamed('Fix Values') 55 | fixButton.click() 56 | 57 | switch = find_switch(self.app) 58 | switch.click() 59 | 60 | self.assertDump('switch_fixed', self.app) 61 | 62 | def test_scale_moved(self): 63 | scale = self.app.findChild(predicate.GenericPredicate(roleName='slider')) 64 | scale.grabFocus() 65 | rawinput.pressKey('Right') 66 | rawinput.pressKey('Right') 67 | rawinput.pressKey('Right') 68 | 69 | 70 | self.assertDump('scale_moved', self.app) 71 | 72 | def test_scale_external_toggle(self): 73 | activateButton = self.app.childNamed('Set Scale') 74 | activateButton.click() 75 | 76 | self.assertDump('scale_external_toggle', self.app) 77 | 78 | def test_scale_fixed(self): 79 | fixButton = self.app.childNamed('Fix Values') 80 | fixButton.click() 81 | 82 | scale = self.app.findChild(predicate.GenericPredicate(roleName='slider')) 83 | scale.grabFocus() 84 | rawinput.pressKey('Right') 85 | rawinput.pressKey('Right') 86 | rawinput.pressKey('Right') 87 | 88 | self.assertDump('scale_fixed', self.app) 89 | 90 | def test_entry_typed_into(self): 91 | entry = find_text_entry(self.app) 92 | entry.typeText('Typed Text') 93 | 94 | self.assertDump('entry_typed_into', self.app) 95 | 96 | def test_entry_external_toggle(self): 97 | activateButton = self.app.childNamed('Set Entry') 98 | activateButton.click() 99 | 100 | self.assertDump('entry_external_toggle', self.app) 101 | 102 | def test_entry_fixed(self): 103 | fixButton = self.app.childNamed('Fix Values') 104 | fixButton.click() 105 | 106 | entry = find_text_entry(self.app) 107 | entry.typeText('A') 108 | 109 | self.assertDump('entry_fixed', self.app) 110 | 111 | if __name__ == '__main__': 112 | unittest.main() 113 | -------------------------------------------------------------------------------- /test/unit/components/GtkContainerSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkContainer = require('../../../src/components/GtkContainer'); 5 | 6 | describe('GtkContainer', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Container: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkContainer once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkContainer = injectGtkContainer(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Container.returns(instance); 23 | 24 | const gotInstance = new GtkContainer({}); 25 | 26 | expect(imports.gi.Gtk.Container.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | 30 | it('should append children', function () { 31 | const imports = getDefaultImports(); 32 | const GtkContainer = injectGtkContainer(imports, logStub); 33 | 34 | const instance = { add: sinon.spy(), get_children: sinon.stub().returns([]) }; 35 | imports.gi.Gtk.Container.returns(instance); 36 | 37 | const gotInstance = new GtkContainer({}); 38 | gotInstance.appendChild({ instance: 'mychild' }); 39 | 40 | expect(instance.add.callCount).to.equal(1); 41 | expect(instance.add.firstCall.args).to.deep.equal([ 'mychild' ]); 42 | }); 43 | 44 | it('should not append children if they are already their children', function () { 45 | const imports = getDefaultImports(); 46 | const GtkContainer = injectGtkContainer(imports, logStub); 47 | 48 | const childInstance = { my: 'child' }; 49 | const instance = { add: sinon.spy(), get_children: sinon.stub().returns([ childInstance ]) }; 50 | imports.gi.Gtk.Container.returns(instance); 51 | 52 | const gotInstance = new GtkContainer({}); 53 | gotInstance.appendChild({ instance: childInstance }); 54 | 55 | expect(instance.add.callCount).to.equal(0); 56 | }); 57 | 58 | it('should append children if they use insertBefore', function () { 59 | const imports = getDefaultImports(); 60 | const GtkContainer = injectGtkContainer(imports, logStub); 61 | 62 | const instance = { add: sinon.spy(), get_children: sinon.stub().returns([]) }; 63 | imports.gi.Gtk.Container.returns(instance); 64 | 65 | const gotInstance = new GtkContainer({}); 66 | gotInstance.insertBefore({ instance: 'mychild' }); 67 | 68 | expect(instance.add.callCount).to.equal(1); 69 | expect(instance.add.firstCall.args).to.deep.equal([ 'mychild' ]); 70 | }); 71 | 72 | it('should not append children using insertBefore if they are already their children', function () { 73 | const imports = getDefaultImports(); 74 | const GtkContainer = injectGtkContainer(imports, logStub); 75 | 76 | const childInstance = { my: 'child' }; 77 | const instance = { add: sinon.spy(), get_children: sinon.stub().returns([ childInstance ]) }; 78 | imports.gi.Gtk.Container.returns(instance); 79 | 80 | const gotInstance = new GtkContainer({}); 81 | gotInstance.insertBefore({ instance: childInstance }); 82 | 83 | expect(instance.add.callCount).to.equal(0); 84 | }); 85 | 86 | it('should remove children', function () { 87 | const imports = getDefaultImports(); 88 | const GtkContainer = injectGtkContainer(imports, logStub); 89 | 90 | const instance = { remove: sinon.spy() }; 91 | imports.gi.Gtk.Container.returns(instance); 92 | 93 | const gotInstance = new GtkContainer(); 94 | gotInstance.removeChild({ instance: 'myinstance' }); 95 | 96 | expect(instance.remove.callCount).to.equal(1); 97 | expect(instance.remove.firstCall.args).to.deep.equal([ 'myinstance' ]); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /test/functional/specs/common/__init__.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from os import environ, makedirs 3 | from os.path import dirname, join, exists 4 | import pyatspi 5 | from subprocess import Popen 6 | from time import sleep 7 | from io import StringIO 8 | from dogtail.tree import * 9 | 10 | BUNDLES_LOCATION = 'test-output/functional' 11 | DUMPS_LOCATION = 'test/functional/dumps' 12 | OUT_LOCATION = 'test-output' if 'OUT' not in environ else environ['OUT'] 13 | GJS = '/usr/bin/gjs' 14 | 15 | def has_attribute(attr): 16 | def has_attr(node): 17 | try: 18 | return hasattr(node, attr) and getattr(node, attr) is not None 19 | except NotImplementedError as _: 20 | return False 21 | return has_attr 22 | 23 | def boolean_attribute(attr): 24 | def has_boolean_attr(node): 25 | return has_attribute(attr)(node) and getattr(node, attr) 26 | return has_boolean_attr 27 | 28 | def has_caret_offset(node): 29 | return boolean_attribute("focused")(node) and has_attribute("caretOffset")(node) 30 | 31 | INDENTATION = ' ' 32 | ALL_ATTRIBUTES = [ 33 | { "name": "caretOffset", "applies": has_caret_offset, "value": lambda n: str(n.caretOffset) }, 34 | { "name": "checked", "applies": boolean_attribute("checked"), "value": lambda _: "true" }, 35 | { "name": "focusable", "applies": boolean_attribute("focusable"), "value": lambda _: "true" }, 36 | { "name": "focused", "applies": boolean_attribute("focused"), "value": lambda _: "true" }, 37 | { "name": "pressed", "applies": lambda n: n.getState().contains(pyatspi.STATE_PRESSED), "value": lambda _: "true" }, 38 | { "name": "showing", "applies": boolean_attribute("showing"), "value": lambda _: "true" }, 39 | { "name": "sensitive", "applies": boolean_attribute("sensitive"), "value": lambda _: "true" }, 40 | { "name": "text", "applies": has_attribute("text"), "value": lambda n: n.text }, 41 | { "name": "value", "applies": has_attribute("value"), "value": lambda n: n.value }, 42 | ] 43 | 44 | def do_dump(buffer, node, depth): 45 | role = node.roleName.replace(" ", "") 46 | attrs = [ '{}="{}"'.format(a["name"], a["value"](node)) for a in ALL_ATTRIBUTES if a["applies"](node) ] 47 | has_children = len(node.children) > 0 48 | 49 | opening_str = "<{role} {attrs}{self_closing}>\n".format( 50 | role=role, 51 | attrs=" ".join(attrs), 52 | self_closing="/" if not has_children else "" 53 | ) 54 | buffer.write(INDENTATION * depth + opening_str) 55 | if has_children: 56 | for child in node.children: 57 | do_dump(buffer, child, depth + 1) 58 | buffer.write(INDENTATION * depth + "\n".format(role=role)) 59 | 60 | def crawl(buffer, node): 61 | do_dump(buffer, node, 0) 62 | 63 | class TestCase(unittest.TestCase): 64 | name = "unknown" 65 | 66 | def doDump(self, node): 67 | with StringIO() as tmp_file: 68 | crawl(tmp_file, node) 69 | return tmp_file.getvalue().rstrip() 70 | 71 | def assertDump(self, expected, node): 72 | expected_dump_filename = join(DUMPS_LOCATION, "{}/{}.dump".format(self.name, expected)) 73 | got_dump_filename = join(OUT_LOCATION, "{}/{}.dump".format(self.name, expected)) 74 | 75 | sleep(1) 76 | dump = self.doDump(node) 77 | 78 | directory = dirname(got_dump_filename) 79 | if not exists(directory): 80 | makedirs(directory) 81 | with open(got_dump_filename, 'w+') as f: 82 | f.write(dump) 83 | 84 | expected = None 85 | with open(expected_dump_filename, 'r') as f: 86 | expected = f.read() 87 | 88 | self.assertEqual(dump, expected, "\nGot:\n{}\nExpected:\n{}\n".format(dump, expected)) 89 | 90 | def setUp(self): 91 | testApplication = '{}/{}Bundle.js'.format(BUNDLES_LOCATION, self.name) 92 | self.testApplicationProcess = Popen([GJS, testApplication]) 93 | self.app = root.childNamed('react-gtk {} test'.format(self.name)) 94 | 95 | def tearDown(self): 96 | self.testApplicationProcess.terminate() 97 | self.testApplicationProcess.poll() 98 | -------------------------------------------------------------------------------- /test/functional/apps/inputs.js: -------------------------------------------------------------------------------- 1 | const runApp = require('./runApp'); 2 | const React = require('react'); 3 | const h = React.createElement; 4 | const Component = React.Component; 5 | 6 | class InputsApp extends Component { 7 | constructor() { 8 | super(); 9 | this.state = { 10 | fixedValues: false, 11 | toggleButtonActive: false, 12 | switchActive: false, 13 | scaleValue: 0, 14 | entryText: 'My Text' 15 | }; 16 | } 17 | 18 | toggleFixedValues() { 19 | this.setState({ fixedValues: !this.state.fixedValues }); 20 | } 21 | 22 | onToggleButton(btn, toggled) { 23 | if (!this.state.fixedValues) { 24 | this.setState({ toggleButtonActive: toggled }); 25 | } 26 | } 27 | 28 | setToggleButtonActive() { 29 | this.setState({ toggleButtonActive: true }); 30 | } 31 | 32 | onSwitch(sw, active) { 33 | if (!this.state.fixedValues) { 34 | this.setState({ switchActive: active }); 35 | } 36 | } 37 | 38 | setSwitchActive() { 39 | this.setState({ switchActive: true }); 40 | } 41 | 42 | onScale(scale, value) { 43 | if (!this.state.fixedValues) { 44 | this.setState({ scaleValue: value }); 45 | } 46 | } 47 | 48 | setScaleValue() { 49 | this.setState({ scaleValue: 3 }); 50 | } 51 | 52 | onEntry(entry) { 53 | if (!this.state.fixedValues) { 54 | this.setState({ entryText: entry.get_text() }); 55 | } 56 | } 57 | 58 | setEntryText() { 59 | this.setState({ entryText: 'Set Text' }); 60 | } 61 | 62 | render() { 63 | return h('gtk-window', { title: 'react-gtk inputs test', defaultWidth: 200, defaultHeight: 100 }, 64 | h('gtk-vbox', {}, [ 65 | h('gtk-button', { 66 | label: this.state.fixedValues ? 'Unfix Values' : 'Fix Values', 67 | onClicked: this.toggleFixedValues.bind(this) 68 | }), 69 | h('gtk-hbox', { key: 0 }, [ 70 | h('gtk-togglebutton', { 71 | label: 'Toggle Me', 72 | onToggled: this.onToggleButton.bind(this), 73 | active: this.state.toggleButtonActive 74 | }), 75 | h('gtk-label', { label: this.state.toggleButtonActive.toString() }), 76 | h('gtk-button', { label: 'Activate Toggle', onClicked: this.setToggleButtonActive.bind(this) }) 77 | ]), 78 | h('gtk-hbox', { key: 1 }, [ 79 | h('gtk-switch', { 80 | active: this.state.switchActive, 81 | onToggled: this.onSwitch.bind(this) 82 | }), 83 | h('gtk-label', { label: this.state.switchActive.toString() }), 84 | h('gtk-button', { label: 'Activate Switch', onClicked: this.setSwitchActive.bind(this) }) 85 | ]), 86 | h('gtk-hbox', { key: 2 }, [ 87 | h('gtk-hscale', { 88 | drawValue: true, 89 | lower: -5, 90 | upper: 5, 91 | stepIncrement: 1, 92 | value: this.state.scaleValue, 93 | onValueChanged: this.onScale.bind(this) 94 | }), 95 | h('gtk-label', { label: this.state.scaleValue.toString() }), 96 | h('gtk-button', { label: 'Set Scale', onClicked: this.setScaleValue.bind(this) }) 97 | ]), 98 | h('gtk-hbox', { key: 3 }, [ 99 | h('gtk-entry', { text: this.state.entryText, onChanged: this.onEntry.bind(this) }), 100 | h('gtk-label', { label: this.state.entryText }), 101 | h('gtk-button', { label: 'Set Entry', onClicked: this.setEntryText.bind(this) }) 102 | ]) 103 | ])); 104 | } 105 | } 106 | 107 | runApp(InputsApp); 108 | -------------------------------------------------------------------------------- /test/unit/components/GtkBoxSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkBox = require('../../../src/components/GtkBox'); 5 | 6 | describe('GtkBox', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Box: sinon.stub() 11 | } 12 | } 13 | 14 | }); 15 | const logStub = () => {}; 16 | 17 | it('should instance GtkBox once', function () { 18 | const imports = getDefaultImports(); 19 | const GtkBox = injectGtkBox(imports, logStub); 20 | 21 | const instance = {}; 22 | imports.gi.Gtk.Box.returns(instance); 23 | 24 | const gotInstance = new GtkBox({}); 25 | 26 | expect(imports.gi.Gtk.Box.callCount).to.equal(1); 27 | expect(gotInstance.instance).to.equal(instance); 28 | }); 29 | 30 | it('should append children', function () { 31 | const imports = getDefaultImports(); 32 | const GtkBox = injectGtkBox(imports, logStub); 33 | 34 | const instance = { add: sinon.spy(), get_children: sinon.stub().returns([]) }; 35 | imports.gi.Gtk.Box.returns(instance); 36 | 37 | const gotInstance = new GtkBox({}); 38 | gotInstance.appendChild({ instance: 'mychild' }); 39 | 40 | expect(instance.add.callCount).to.equal(1); 41 | expect(instance.add.firstCall.args).to.deep.equal([ 'mychild' ]); 42 | }); 43 | 44 | it('should move children to the end when they are appended and already in the list', function () { 45 | const imports = getDefaultImports(); 46 | const GtkBox = injectGtkBox(imports, logStub); 47 | 48 | const childInstance = { my: 'child' }; 49 | const instance = { 50 | add: sinon.spy(), 51 | get_children: sinon.stub().returns([ childInstance ]), 52 | reorder_child: sinon.stub() 53 | }; 54 | imports.gi.Gtk.Box.returns(instance); 55 | 56 | const gotInstance = new GtkBox({}); 57 | gotInstance.appendChild({ instance: childInstance }); 58 | 59 | expect(instance.add.callCount).to.equal(0); 60 | expect(instance.reorder_child.callCount).to.equal(1); 61 | expect(instance.reorder_child.firstCall.args).to.deep.equal([ childInstance, -1 ]); 62 | }); 63 | 64 | it('should insert children', function () { 65 | const imports = getDefaultImports(); 66 | const GtkBox = injectGtkBox(imports, logStub); 67 | 68 | const childInstance = { my: 'child' }; 69 | const childAfter = { after: 'child' }; 70 | const otherInstance = { other: 'child' }; 71 | const instance = { 72 | add: sinon.spy(), 73 | get_children: sinon.stub().returns([ otherInstance, otherInstance, childAfter ]), 74 | reorder_child: sinon.stub() 75 | }; 76 | imports.gi.Gtk.Box.returns(instance); 77 | 78 | const gotInstance = new GtkBox({}); 79 | gotInstance.insertBefore({ instance: childInstance }, { instance: childAfter }); 80 | 81 | expect(instance.add.callCount).to.equal(1); 82 | expect(instance.add.firstCall.args).to.deep.equal([ childInstance ]); 83 | expect(instance.reorder_child.callCount).to.equal(1); 84 | expect(instance.reorder_child.firstCall.args).to.deep.equal([ childInstance, 2 ]); 85 | }); 86 | 87 | it('should only reorder when inserting children that already exist', function () { 88 | const imports = getDefaultImports(); 89 | const GtkBox = injectGtkBox(imports, logStub); 90 | 91 | const childInstance = { my: 'child' }; 92 | const childAfter = { after: 'child' }; 93 | const otherInstance = { other: 'child' }; 94 | const instance = { 95 | add: sinon.spy(), 96 | get_children: sinon.stub().returns([ childAfter, otherInstance, otherInstance, childInstance ]), 97 | reorder_child: sinon.stub() 98 | }; 99 | imports.gi.Gtk.Box.returns(instance); 100 | 101 | const gotInstance = new GtkBox({}); 102 | gotInstance.insertBefore({ instance: childInstance }, { instance: childAfter }); 103 | 104 | expect(instance.add.callCount).to.equal(0); 105 | expect(instance.reorder_child.callCount).to.equal(1); 106 | expect(instance.reorder_child.firstCall.args).to.deep.equal([ childInstance, 0 ]); 107 | }); 108 | }); 109 | -------------------------------------------------------------------------------- /test/unit/components/GtkScaleSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkScale = require('../../../src/components/GtkScale'); 5 | 6 | describe('GtkScale', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Adjustment: sinon.stub(), 11 | Scale: sinon.stub() 12 | }, 13 | GObject: { 14 | signal_lookup: sinon.stub().returns(0), 15 | signal_handler_block: sinon.stub(), 16 | signal_handler_unblock: sinon.stub(), 17 | signal_handler_is_connected: sinon.stub() 18 | } 19 | } 20 | 21 | }); 22 | const logStub = () => {}; 23 | 24 | it('should instance GtkScale once', function () { 25 | const imports = getDefaultImports(); 26 | const GtkScale = injectGtkScale(imports, logStub); 27 | 28 | const instance = {}; 29 | imports.gi.Gtk.Scale.returns(instance); 30 | 31 | const gotInstance = new GtkScale({}); 32 | 33 | expect(imports.gi.Gtk.Scale.callCount).to.equal(1); 34 | expect(gotInstance.instance).to.equal(instance); 35 | }); 36 | 37 | describe('uncontrolled', function () { 38 | it('should emit an onValueChanged signal with the new value', function () { 39 | const imports = getDefaultImports(); 40 | const GtkScale = injectGtkScale(imports, logStub); 41 | 42 | const instance = { connect: sinon.stub(), get_value: sinon.stub().returns(12) }; 43 | imports.gi.Gtk.Scale.returns(instance); 44 | imports.gi.GObject.signal_lookup.withArgs('value-changed').returns(1); 45 | 46 | const onValueChanged = sinon.stub(); 47 | 48 | const gotInstance = new GtkScale({ onValueChanged }); 49 | 50 | expect(gotInstance.instance.connect.callCount).to.equal(1); 51 | gotInstance.instance.connect.firstCall.args[1](instance); 52 | 53 | expect(onValueChanged.callCount).to.equal(1); 54 | expect(onValueChanged.firstCall.args[0]).to.equal(instance); 55 | expect(onValueChanged.firstCall.args[1]).to.equal(12); 56 | }); 57 | }); 58 | 59 | describe('controlled', function () { 60 | it('should emit an onValueChanged signal with the new value', function () { 61 | const imports = getDefaultImports(); 62 | const GtkScale = injectGtkScale(imports, logStub); 63 | 64 | const instance = { connect: sinon.stub(), set_value: sinon.stub(), get_value: sinon.stub().returns(12) }; 65 | imports.gi.Gtk.Scale.returns(instance); 66 | imports.gi.GObject.signal_lookup.withArgs('value-changed').returns(1); 67 | 68 | const onValueChanged = sinon.stub(); 69 | 70 | const gotInstance = new GtkScale({ value: 1, onValueChanged }); 71 | 72 | expect(gotInstance.instance.connect.callCount).to.equal(1); 73 | gotInstance.instance.connect.firstCall.args[1](instance); 74 | 75 | expect(onValueChanged.callCount).to.equal(1); 76 | expect(onValueChanged.firstCall.args[0]).to.equal(instance); 77 | expect(onValueChanged.firstCall.args[1]).to.equal(12); 78 | }); 79 | 80 | it('should reset the value to the old value after emitting', function () { 81 | const imports = getDefaultImports(); 82 | const GtkScale = injectGtkScale(imports, logStub); 83 | 84 | const instance = { connect: sinon.stub(), set_value: sinon.stub(), get_value: sinon.stub().returns(12) }; 85 | imports.gi.Gtk.Scale.returns(instance); 86 | imports.gi.GObject.signal_lookup.withArgs('value-changed').returns(1); 87 | 88 | const onValueChanged = sinon.stub(); 89 | 90 | const gotInstance = new GtkScale({ value: 1, onValueChanged }); 91 | 92 | expect(gotInstance.instance.connect.callCount).to.equal(1); 93 | gotInstance.instance.connect.firstCall.args[1](instance); 94 | 95 | expect(instance.set_value.callCount).to.equal(1); 96 | expect(instance.set_value.firstCall.args[0]).to.equal(1); 97 | }); 98 | 99 | it('should disable the toggled event during an update', function () { 100 | const imports = getDefaultImports(); 101 | const GtkScale = injectGtkScale(imports, logStub); 102 | 103 | const instance = { 104 | connect: sinon.stub().returns(123), 105 | disconnect: sinon.stub(), 106 | get_value: sinon.stub().returns(1), 107 | set_value: sinon.stub() 108 | }; 109 | imports.gi.Gtk.Scale.returns(instance); 110 | imports.gi.GObject.signal_lookup.withArgs('value-changed').returns(1); 111 | 112 | const gotInstance = new GtkScale({ value: 1 }); 113 | gotInstance.update({ set: [ [ 'value', 2 ] ], unset: [] }); 114 | 115 | expect(imports.gi.GObject.signal_handler_block.callCount).to.equal(1); 116 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[0]).to.equal(instance); 117 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[1]).to.equal(123); 118 | }); 119 | 120 | it('should unblock if the event has not changed', function () { 121 | const imports = getDefaultImports(); 122 | const GtkScale = injectGtkScale(imports, logStub); 123 | 124 | const instance = { 125 | connect: sinon.stub().returns(123), 126 | disconnect: sinon.stub(), 127 | get_value: sinon.stub().returns(1), 128 | set_value: sinon.stub() 129 | }; 130 | imports.gi.Gtk.Scale.returns(instance); 131 | imports.gi.GObject.signal_lookup.withArgs('value-changed').returns(1); 132 | imports.gi.GObject.signal_handler_is_connected.withArgs(instance, 123).returns(true); 133 | 134 | const gotInstance = new GtkScale({ value: 1 }); 135 | gotInstance.update({ set: [ [ 'value', 2 ] ], unset: [] }); 136 | 137 | expect(imports.gi.GObject.signal_handler_unblock.callCount).to.equal(1); 138 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[0]).to.equal(instance); 139 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[1]).to.equal(123); 140 | }); 141 | }); 142 | }); 143 | -------------------------------------------------------------------------------- /test/unit/components/GtkSwitchSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkSwitch = require('../../../src/components/GtkSwitch'); 5 | 6 | describe('GtkSwitch', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Switch: sinon.stub() 11 | }, 12 | GObject: { 13 | signal_lookup: sinon.stub().returns(0), 14 | signal_handler_block: sinon.stub(), 15 | signal_handler_unblock: sinon.stub(), 16 | signal_handler_is_connected: sinon.stub() 17 | } 18 | } 19 | 20 | }); 21 | const logStub = () => {}; 22 | 23 | it('should instance GtkSwitch once', function () { 24 | const imports = getDefaultImports(); 25 | const GtkSwitch = injectGtkSwitch(imports, logStub); 26 | 27 | const instance = {}; 28 | imports.gi.Gtk.Switch.returns(instance); 29 | 30 | const gotInstance = new GtkSwitch({}); 31 | 32 | expect(imports.gi.Gtk.Switch.callCount).to.equal(1); 33 | expect(gotInstance.instance).to.equal(instance); 34 | }); 35 | 36 | describe('uncontrolled', function () { 37 | it('should emit an onToggled signal with the new value', function () { 38 | const imports = getDefaultImports(); 39 | const GtkSwitch = injectGtkSwitch(imports, logStub); 40 | 41 | const instance = { connect: sinon.stub(), get_active: sinon.stub().returns(true) }; 42 | imports.gi.Gtk.Switch.returns(instance); 43 | imports.gi.GObject.signal_lookup.returns(1); 44 | 45 | const onToggled = sinon.stub(); 46 | 47 | const gotInstance = new GtkSwitch({ onToggled }); 48 | 49 | expect(gotInstance.instance.connect.callCount).to.equal(1); 50 | gotInstance.instance.connect.firstCall.args[1](instance); 51 | 52 | expect(onToggled.callCount).to.equal(1); 53 | expect(onToggled.firstCall.args[0]).to.equal(instance); 54 | expect(onToggled.firstCall.args[1]).to.equal(true); 55 | }); 56 | }); 57 | 58 | describe('controlled', function () { 59 | it('should emit an onToggled signal with the new value', function () { 60 | const imports = getDefaultImports(); 61 | const GtkSwitch = injectGtkSwitch(imports, logStub); 62 | 63 | const instance = { 64 | connect: sinon.stub(), 65 | get_active: sinon.stub().returns(true), 66 | set_active: sinon.stub() 67 | }; 68 | imports.gi.Gtk.Switch.returns(instance); 69 | imports.gi.GObject.signal_lookup.returns(1); 70 | 71 | const onToggled = sinon.stub(); 72 | 73 | const gotInstance = new GtkSwitch({ active: false, onToggled }); 74 | 75 | expect(gotInstance.instance.connect.callCount).to.equal(1); 76 | gotInstance.instance.connect.firstCall.args[1](instance); 77 | 78 | expect(onToggled.callCount).to.equal(1); 79 | expect(onToggled.firstCall.args[0]).to.equal(instance); 80 | expect(onToggled.firstCall.args[1]).to.equal(true); 81 | }); 82 | 83 | it('should reset the value on the input', function () { 84 | const imports = getDefaultImports(); 85 | const GtkSwitch = injectGtkSwitch(imports, logStub); 86 | 87 | const instance = { 88 | connect: sinon.stub(), 89 | get_active: sinon.stub().returns(true), 90 | set_active: sinon.stub() 91 | }; 92 | imports.gi.Gtk.Switch.returns(instance); 93 | imports.gi.GObject.signal_lookup.returns(1); 94 | 95 | const onToggled = sinon.stub(); 96 | 97 | const gotInstance = new GtkSwitch({ active: false, onToggled }); 98 | 99 | expect(gotInstance.instance.connect.callCount).to.equal(1); 100 | gotInstance.instance.connect.firstCall.args[1](instance); 101 | 102 | expect(gotInstance.instance.set_active.callCount).to.equal(1); 103 | expect(gotInstance.instance.set_active.firstCall.args[0]).to.equal(false); 104 | }); 105 | 106 | it('should disable the toggled event during an update', function () { 107 | const imports = getDefaultImports(); 108 | const GtkSwitch = injectGtkSwitch(imports, logStub); 109 | 110 | const instance = { 111 | connect: sinon.stub().returns(123), 112 | disconnect: sinon.stub(), 113 | get_active: sinon.stub().returns(true), 114 | set_active: sinon.stub() 115 | }; 116 | imports.gi.Gtk.Switch.returns(instance); 117 | imports.gi.GObject.signal_lookup.returns(1); 118 | 119 | const gotInstance = new GtkSwitch({ active: false }); 120 | gotInstance.update({ set: [ [ 'active', true ] ], unset: [] }); 121 | 122 | expect(imports.gi.GObject.signal_handler_block.callCount).to.equal(1); 123 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[0]).to.equal(instance); 124 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[1]).to.equal(123); 125 | }); 126 | 127 | it('should unblock if the event has not changed', function () { 128 | const imports = getDefaultImports(); 129 | const GtkSwitch = injectGtkSwitch(imports, logStub); 130 | 131 | const instance = { 132 | connect: sinon.stub().returns(123), 133 | disconnect: sinon.stub(), 134 | get_active: sinon.stub().returns(true), 135 | set_active: sinon.stub() 136 | }; 137 | imports.gi.Gtk.Switch.returns(instance); 138 | imports.gi.GObject.signal_lookup.returns(1); 139 | 140 | imports.gi.GObject.signal_handler_is_connected.withArgs(instance, 123).returns(true); 141 | 142 | const gotInstance = new GtkSwitch({ active: false }); 143 | gotInstance.update({ set: [ [ 'active', true ] ], unset: [] }); 144 | 145 | expect(imports.gi.GObject.signal_handler_unblock.callCount).to.equal(1); 146 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[0]).to.equal(instance); 147 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[1]).to.equal(123); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /test/unit/components/GtkToggleButtonSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkToggleButton = require('../../../src/components/GtkToggleButton'); 5 | 6 | describe('GtkToggleButton', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | ToggleButton: sinon.stub() 11 | }, 12 | GObject: { 13 | signal_lookup: sinon.stub().returns(0), 14 | signal_handler_block: sinon.stub(), 15 | signal_handler_unblock: sinon.stub(), 16 | signal_handler_is_connected: sinon.stub() 17 | } 18 | } 19 | 20 | }); 21 | const logStub = () => {}; 22 | 23 | it('should instance GtkToggleButton once', function () { 24 | const imports = getDefaultImports(); 25 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 26 | 27 | const instance = {}; 28 | imports.gi.Gtk.ToggleButton.returns(instance); 29 | 30 | const gotInstance = new GtkToggleButton({}); 31 | 32 | expect(imports.gi.Gtk.ToggleButton.callCount).to.equal(1); 33 | expect(gotInstance.instance).to.equal(instance); 34 | }); 35 | 36 | describe('uncontrolled', function () { 37 | it('should emit an onToggled signal with the new value', function () { 38 | const imports = getDefaultImports(); 39 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 40 | 41 | const instance = { connect: sinon.stub(), get_active: sinon.stub().returns(true) }; 42 | imports.gi.Gtk.ToggleButton.returns(instance); 43 | imports.gi.GObject.signal_lookup.withArgs('toggled').returns(1); 44 | 45 | const onToggled = sinon.stub(); 46 | 47 | const gotInstance = new GtkToggleButton({ onToggled }); 48 | 49 | expect(gotInstance.instance.connect.callCount).to.equal(1); 50 | gotInstance.instance.connect.firstCall.args[1](instance); 51 | 52 | expect(onToggled.callCount).to.equal(1); 53 | expect(onToggled.firstCall.args[0]).to.equal(instance); 54 | expect(onToggled.firstCall.args[1]).to.equal(true); 55 | }); 56 | }); 57 | 58 | describe('controlled', function () { 59 | it('should emit an onToggled signal with the new value', function () { 60 | const imports = getDefaultImports(); 61 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 62 | 63 | const instance = { 64 | connect: sinon.stub(), 65 | get_active: sinon.stub().returns(true), 66 | set_active: sinon.stub() 67 | }; 68 | imports.gi.Gtk.ToggleButton.returns(instance); 69 | imports.gi.GObject.signal_lookup.returns(1); 70 | 71 | const onToggled = sinon.stub(); 72 | 73 | const gotInstance = new GtkToggleButton({ active: false, onToggled }); 74 | 75 | expect(gotInstance.instance.connect.callCount).to.equal(1); 76 | gotInstance.instance.connect.firstCall.args[1](instance); 77 | 78 | expect(onToggled.callCount).to.equal(1); 79 | expect(onToggled.firstCall.args[0]).to.equal(instance); 80 | expect(onToggled.firstCall.args[1]).to.equal(true); 81 | }); 82 | 83 | it('should reset the value on the input', function () { 84 | const imports = getDefaultImports(); 85 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 86 | 87 | const instance = { 88 | connect: sinon.stub(), 89 | get_active: sinon.stub().returns(true), 90 | set_active: sinon.stub() 91 | }; 92 | imports.gi.Gtk.ToggleButton.returns(instance); 93 | imports.gi.GObject.signal_lookup.returns(1); 94 | 95 | const onToggled = sinon.stub(); 96 | 97 | const gotInstance = new GtkToggleButton({ active: false, onToggled }); 98 | 99 | expect(gotInstance.instance.connect.callCount).to.equal(1); 100 | gotInstance.instance.connect.firstCall.args[1](instance); 101 | 102 | expect(gotInstance.instance.set_active.callCount).to.equal(1); 103 | expect(gotInstance.instance.set_active.firstCall.args[0]).to.equal(false); 104 | }); 105 | 106 | it('should disable the toggled event during an update', function () { 107 | const imports = getDefaultImports(); 108 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 109 | 110 | const instance = { 111 | connect: sinon.stub().returns(123), 112 | disconnect: sinon.stub(), 113 | get_active: sinon.stub().returns(true), 114 | set_active: sinon.stub() 115 | }; 116 | imports.gi.Gtk.ToggleButton.returns(instance); 117 | imports.gi.GObject.signal_lookup.returns(1); 118 | 119 | const gotInstance = new GtkToggleButton({ active: false, onToggled: sinon.stub() }); 120 | gotInstance.update({ set: [ [ 'active', true ] ], unset: [] }); 121 | 122 | expect(imports.gi.GObject.signal_handler_block.callCount).to.equal(1); 123 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[0]).to.equal(instance); 124 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[1]).to.equal(123); 125 | }); 126 | 127 | it('should unblock if the event has not changed', function () { 128 | const imports = getDefaultImports(); 129 | const GtkToggleButton = injectGtkToggleButton(imports, logStub); 130 | 131 | const instance = { 132 | connect: sinon.stub().returns(123), 133 | disconnect: sinon.stub(), 134 | get_active: sinon.stub().returns(true), 135 | set_active: sinon.stub() 136 | }; 137 | imports.gi.Gtk.ToggleButton.returns(instance); 138 | imports.gi.GObject.signal_lookup.returns(1); 139 | 140 | imports.gi.GObject.signal_handler_is_connected.withArgs(instance, 123).returns(true); 141 | 142 | const gotInstance = new GtkToggleButton({ active: false, onToggled: sinon.stub() }); 143 | gotInstance.update({ set: [ [ 'active', true ] ], unset: [] }); 144 | 145 | expect(imports.gi.GObject.signal_handler_unblock.callCount).to.equal(1); 146 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[0]).to.equal(instance); 147 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[1]).to.equal(123); 148 | }); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /src/reconciler.js: -------------------------------------------------------------------------------- 1 | /* eslint no-unused-vars: 0 */ 2 | 3 | const R = require('ramda'); 4 | 5 | const withoutChildren = R.omit([ 'children' ]); 6 | 7 | const stringify = JSON.stringify; 8 | 9 | module.exports = function (imports, publicComponents, log) { 10 | const Gtk = imports.gi.Gtk; 11 | 12 | const GtkReconciler = { 13 | now: Date.now, 14 | useSyncScheduling: true, 15 | 16 | createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) { 17 | log('createInstance', type, props); 18 | const Type = publicComponents[type]; 19 | 20 | if (!Type) { 21 | throw new Error(`Unknown component: ${type}`); 22 | } 23 | 24 | return new Type(props, rootContainerInstance, hostContext, internalInstanceHandle); 25 | }, 26 | 27 | createTextInstance( 28 | text, 29 | rootContainerInstance, 30 | hostContext, 31 | internalInstanceHandle 32 | ) { 33 | log('createTextInstance'); 34 | throw new Error('ReactGTK does not support text instances. Use gtk-label to display text'); 35 | }, 36 | 37 | appendInitialChild(parentInstance, child) { 38 | log('appendInitialChild', parentInstance, child); 39 | child.instance.show(); 40 | 41 | if (!R.is(Gtk.Application, parentInstance)) { 42 | parentInstance.appendChild(child); 43 | } 44 | }, 45 | 46 | finalizeInitialChildren(instance, type, props, rootContainerInstance) { 47 | log('finalizeInitialChildren'); 48 | return false; 49 | }, 50 | 51 | getPublicInstance(instance) { 52 | log('getPublicInstance'); 53 | return instance; 54 | }, 55 | 56 | prepareForCommit() { 57 | log('prepareForCommit'); 58 | }, 59 | 60 | prepareUpdate( 61 | instance, 62 | type, 63 | oldProps, 64 | newProps, 65 | rootContainerInstance, 66 | hostContext 67 | ) { 68 | const oldNoChildren = withoutChildren(oldProps); 69 | const newNoChildren = withoutChildren(newProps); 70 | const propsAreEqual = R.equals(oldNoChildren, newNoChildren); 71 | const unset = R.without(R.keys(newNoChildren), R.keys(oldNoChildren)); 72 | const set = R.reject(R.contains(R.__, R.toPairs(oldNoChildren)), R.toPairs(newNoChildren)); 73 | 74 | log('prepareUpdate', stringify(oldNoChildren), stringify(newNoChildren), !propsAreEqual); 75 | return propsAreEqual ? null : { unset, set }; 76 | }, 77 | 78 | resetAfterCommit() { 79 | log('resetAfterCommit'); 80 | }, 81 | 82 | resetTextContent(instance) { 83 | log('resetTextContent'); 84 | }, 85 | 86 | shouldDeprioritizeSubtree(type, props) { 87 | return false; 88 | }, 89 | 90 | getRootHostContext(rootContainerInstance) { 91 | return {}; 92 | }, 93 | 94 | getChildHostContext(parentHostContext, type) { 95 | return parentHostContext; 96 | }, 97 | 98 | shouldSetTextContent(props) { 99 | return false; 100 | }, 101 | 102 | scheduleAnimationCallback() { 103 | log('scheduleAnimationCallback'); 104 | }, 105 | 106 | scheduleDeferredCallback() { 107 | log('scheduleDeferredCallback'); 108 | }, 109 | 110 | mutation: { 111 | appendChild(parentInstance, child) { 112 | log('appendChild', parentInstance, child); 113 | child.instance.show(); 114 | if (!R.is(Gtk.Application, parentInstance)) { 115 | parentInstance.appendChild(child); 116 | } 117 | }, 118 | 119 | appendChildToContainer(parentInstance, child) { 120 | log('appendChildToContainer', parentInstance, child); 121 | child.instance.show(); 122 | if (!R.is(Gtk.Application, parentInstance)) { 123 | parentInstance.appendChild(child); 124 | } 125 | }, 126 | 127 | insertBefore(parentInstance, child, beforeChild) { 128 | log('insertInContainerBefore', parentInstance, child, beforeChild); 129 | child.instance.show(); 130 | if (!R.is(Gtk.Application, parentInstance)) { 131 | parentInstance.insertBefore(child, beforeChild); 132 | } 133 | }, 134 | 135 | insertInContainerBefore(parentInstance, child, beforeChild) { 136 | log('insertInContainerBefore', parentInstance, child, beforeChild); 137 | child.instance.show(); 138 | if (!R.is(Gtk.Application, parentInstance)) { 139 | parentInstance.insertBefore(child, beforeChild); 140 | } 141 | }, 142 | 143 | removeChild(parentInstance, child) { 144 | log('removeChild', parentInstance, child); 145 | if (!R.is(Gtk.Application, parentInstance)) { 146 | parentInstance.removeChild(child); 147 | } 148 | }, 149 | 150 | removeChildFromContainer(parentInstance, child) { 151 | log('removeChildFromContainer', parentInstance, child); 152 | if (!R.is(Gtk.Application, parentInstance)) { 153 | parentInstance.removeChild(child); 154 | } 155 | }, 156 | 157 | commitTextUpdate( 158 | textInstance, 159 | oldText, 160 | newText 161 | ) { 162 | log('commitTextUpdate'); 163 | throw new Error('commitTextUpdate should not be called'); 164 | }, 165 | 166 | // commitMount is called after initializeFinalChildren *if* 167 | // `initializeFinalChildren` returns true. 168 | 169 | commitMount( 170 | instance, 171 | type, 172 | newProps, 173 | internalInstanceHandle 174 | ) { 175 | log('commitMount'); 176 | }, 177 | 178 | commitUpdate( 179 | instance, 180 | changes, 181 | type, 182 | oldProps, 183 | newProps, 184 | internalInstanceHandle 185 | ) { 186 | log('commitUpdate', stringify(changes)); 187 | instance.update(changes); 188 | } 189 | } 190 | }; 191 | 192 | return GtkReconciler; 193 | }; 194 | -------------------------------------------------------------------------------- /test/unit/components/GtkEntrySpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkEntry = require('../../../src/components/GtkEntry'); 5 | 6 | describe('GtkEntry', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Entry: sinon.stub() 11 | }, 12 | GObject: { 13 | signal_lookup: sinon.stub().returns(0), 14 | signal_handler_block: sinon.stub(), 15 | signal_handler_unblock: sinon.stub(), 16 | signal_handler_is_connected: sinon.stub() 17 | } 18 | } 19 | 20 | }); 21 | const logStub = () => {}; 22 | 23 | it('should instance GtkEntry once', function () { 24 | const imports = getDefaultImports(); 25 | const GtkEntry = injectGtkEntry(imports, logStub); 26 | 27 | const instance = {}; 28 | imports.gi.Gtk.Entry.returns(instance); 29 | 30 | const gotInstance = new GtkEntry({}); 31 | 32 | expect(imports.gi.Gtk.Entry.callCount).to.equal(1); 33 | expect(gotInstance.instance).to.equal(instance); 34 | }); 35 | 36 | it('should not set a text value to undefined', function () { 37 | const imports = getDefaultImports(); 38 | const GtkEntry = injectGtkEntry(imports, logStub); 39 | 40 | const instance = {}; 41 | imports.gi.Gtk.Entry.returns(instance); 42 | 43 | const gotInstance = new GtkEntry({}); 44 | 45 | expect(imports.gi.Gtk.Entry.callCount).to.equal(1); 46 | expect(imports.gi.Gtk.Entry.firstCall.args[0]).to.deep.equal({}); 47 | expect(gotInstance.instance).to.not.have.property('text'); 48 | }); 49 | 50 | it('should not set a onChanged handler to undefined', function () {}); 51 | 52 | describe('uncontrolled', function () { 53 | it('should emit an onChanged signal with the new value', function () { 54 | const imports = getDefaultImports(); 55 | const GtkEntry = injectGtkEntry(imports, logStub); 56 | 57 | const instance = { connect: sinon.stub(), get_text: sinon.stub().returns('new text') }; 58 | imports.gi.Gtk.Entry.returns(instance); 59 | imports.gi.GObject.signal_lookup.withArgs('changed').returns(1); 60 | 61 | const onChanged = sinon.stub(); 62 | 63 | const gotInstance = new GtkEntry({ onChanged }); 64 | 65 | expect(gotInstance.instance.connect.callCount).to.equal(1); 66 | gotInstance.instance.connect.firstCall.args[1](instance); 67 | 68 | expect(onChanged.callCount).to.equal(1); 69 | expect(onChanged.firstCall.args[0]).to.equal(instance); 70 | expect(onChanged.firstCall.args[1]).to.equal('new text'); 71 | }); 72 | }); 73 | 74 | describe('controlled', function () { 75 | it('should emit an onChanged signal with the new value', function () { 76 | const imports = getDefaultImports(); 77 | const GtkEntry = injectGtkEntry(imports, logStub); 78 | 79 | const instance = { connect: sinon.stub(), get_text: sinon.stub().returns('new text') }; 80 | imports.gi.Gtk.Entry.returns(instance); 81 | imports.gi.GObject.signal_lookup.withArgs('changed').returns(1); 82 | 83 | const onChanged = sinon.stub(); 84 | 85 | const gotInstance = new GtkEntry({ onChanged }); 86 | 87 | expect(gotInstance.instance.connect.callCount).to.equal(1); 88 | gotInstance.instance.connect.firstCall.args[1](instance); 89 | 90 | expect(onChanged.callCount).to.equal(1); 91 | expect(onChanged.firstCall.args[0]).to.equal(instance); 92 | expect(onChanged.firstCall.args[1]).to.equal('new text'); 93 | }); 94 | 95 | it('should reset the value to the old value after emitting', function () { 96 | const imports = getDefaultImports(); 97 | const GtkEntry = injectGtkEntry(imports, logStub); 98 | 99 | const instance = { connect: sinon.stub(), set_text: sinon.stub(), get_text: sinon.stub().returns('text') }; 100 | imports.gi.Gtk.Entry.returns(instance); 101 | imports.gi.GObject.signal_lookup.withArgs('changed').returns(1); 102 | 103 | const onChanged = sinon.stub(); 104 | 105 | const gotInstance = new GtkEntry({ text: 'aloha', onChanged }); 106 | 107 | gotInstance.instance.connect.firstCall.args[1](instance); 108 | 109 | expect(instance.set_text.callCount).to.equal(1); 110 | expect(instance.set_text.firstCall.args[0]).to.equal('aloha'); 111 | }); 112 | 113 | it('should disable the toggled event during an update', function () { 114 | const imports = getDefaultImports(); 115 | const GtkEntry = injectGtkEntry(imports, logStub); 116 | 117 | const instance = { 118 | connect: sinon.stub().withArgs('changed').returns(123), 119 | disconnect: sinon.stub(), 120 | set_text: sinon.stub(), 121 | get_text: sinon.stub().returns('text') 122 | }; 123 | imports.gi.Gtk.Entry.returns(instance); 124 | imports.gi.GObject.signal_lookup.withArgs('changed').returns(1); 125 | 126 | const gotInstance = new GtkEntry({ text: 'first text' }); 127 | gotInstance.update({ set: [ [ 'text', 'second text' ] ], unset: [] }); 128 | 129 | expect(imports.gi.GObject.signal_handler_block.callCount).to.equal(1); 130 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[0]).to.equal(instance); 131 | expect(imports.gi.GObject.signal_handler_block.firstCall.args[1]).to.equal(123); 132 | }); 133 | 134 | it('should unblock if the event has not changed', function () { 135 | const imports = getDefaultImports(); 136 | const GtkEntry = injectGtkEntry(imports, logStub); 137 | 138 | const instance = { 139 | connect: sinon.stub().withArgs('changed').returns(123), 140 | disconnect: sinon.stub(), 141 | set_text: sinon.stub(), 142 | get_text: sinon.stub().returns('text') 143 | }; 144 | imports.gi.Gtk.Entry.returns(instance); 145 | imports.gi.GObject.signal_lookup.withArgs('changed').returns(1); 146 | imports.gi.GObject.signal_handler_is_connected.withArgs(instance, 123).returns(true); 147 | 148 | const gotInstance = new GtkEntry({ text: 'test1' }); 149 | gotInstance.update({ set: [ [ 'text', 'test2' ] ], unset: [] }); 150 | 151 | expect(imports.gi.GObject.signal_handler_unblock.callCount).to.equal(1); 152 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[0]).to.equal(instance); 153 | expect(imports.gi.GObject.signal_handler_unblock.firstCall.args[1]).to.equal(123); 154 | }); 155 | }); 156 | }); 157 | -------------------------------------------------------------------------------- /test/unit/components/GtkWidgetSpec.js: -------------------------------------------------------------------------------- 1 | const sinon = require('sinon'); 2 | const { expect } = require('chai'); 3 | 4 | const injectGtkWidget = require('../../../src/components/GtkWidget'); 5 | 6 | describe('GtkWidget', function () { 7 | const getDefaultImports = () => ({ 8 | gi: { 9 | Gtk: { 10 | Widget: sinon.stub() 11 | }, 12 | GObject: { 13 | signal_lookup: sinon.stub() 14 | } 15 | } 16 | 17 | }); 18 | const logStub = () => {}; 19 | 20 | describe('constructor', function () { 21 | it('should instance GtkWidget once', function () { 22 | const imports = getDefaultImports(); 23 | const GtkWidget = injectGtkWidget(imports, logStub); 24 | 25 | const instance = {}; 26 | imports.gi.Gtk.Widget.returns(instance); 27 | 28 | const gotInstance = new GtkWidget({}); 29 | 30 | expect(imports.gi.Gtk.Widget.callCount).to.equal(1); 31 | expect(gotInstance.instance).to.equal(instance); 32 | }); 33 | 34 | it('should instance with properties', function () { 35 | const imports = getDefaultImports(); 36 | const GtkWidget = injectGtkWidget(imports, logStub); 37 | 38 | const instance = {}; 39 | imports.gi.Gtk.Widget.returns(instance); 40 | 41 | new GtkWidget({ some: 'prop' }); 42 | 43 | expect(imports.gi.Gtk.Widget.firstCall.args).to.deep.equal([ { some: 'prop' } ]); 44 | }); 45 | 46 | it('should not set the children property', function () { 47 | const imports = getDefaultImports(); 48 | const GtkWidget = injectGtkWidget(imports, logStub); 49 | 50 | const instance = {}; 51 | imports.gi.Gtk.Widget.returns(instance); 52 | 53 | new GtkWidget({ some: 'prop', children: [] }); 54 | 55 | expect(imports.gi.Gtk.Widget.firstCall.args).to.deep.equal([ { some: 'prop' } ]); 56 | }); 57 | 58 | it('should set signal handlers', function () { 59 | const imports = getDefaultImports(); 60 | const GtkWidget = injectGtkWidget(imports, logStub); 61 | 62 | const handleId = 111; 63 | const instance = { connect: sinon.stub().returns(handleId) }; 64 | imports.gi.Gtk.Widget.returns(instance); 65 | imports.gi.GObject.signal_lookup.withArgs('clicked', imports.gi.Gtk.Widget).returns(124); 66 | 67 | const handler = () => ({}); 68 | new GtkWidget({ onClicked: handler, children: [] }); 69 | 70 | expect(instance.connect.firstCall.args).to.deep.equal([ 'clicked', handler ]); 71 | expect(instance._connectedSignals).to.deep.equal({ clicked: handleId }); 72 | }); 73 | 74 | it('should not set unknown signal handlers', function () { 75 | const imports = getDefaultImports(); 76 | const GtkWidget = injectGtkWidget(imports, logStub); 77 | 78 | const instance = { connect: sinon.stub() }; 79 | imports.gi.Gtk.Widget.returns(instance); 80 | imports.gi.GObject.signal_lookup.returns(0); 81 | 82 | new GtkWidget({ onSomething: () => {}, children: [] }); 83 | 84 | expect(instance.connect.callCount).to.equal(0); 85 | }); 86 | }); 87 | 88 | describe('adding child', function () { 89 | it('should throw', function () { 90 | const imports = getDefaultImports(); 91 | const GtkWidget = injectGtkWidget(imports, logStub); 92 | 93 | const widget = new GtkWidget(); 94 | 95 | expect(() => widget.appendChild({})).to.throw(); 96 | }); 97 | }); 98 | 99 | describe('removing child', function () { 100 | it('should throw', function () { 101 | const imports = getDefaultImports(); 102 | const GtkWidget = injectGtkWidget(imports, logStub); 103 | 104 | const widget = new GtkWidget(); 105 | 106 | expect(() => widget.removeChild({})).to.throw(); 107 | }); 108 | }); 109 | 110 | describe('committing update', function () { 111 | it('should set properties', function () { 112 | const imports = getDefaultImports(); 113 | const GtkWidget = injectGtkWidget(imports, logStub); 114 | const instance = { prop1: 1 }; 115 | const changes = { set: [ [ 'prop1', 2 ] ], unset: [] }; 116 | 117 | imports.gi.GObject.signal_lookup.returns(0); 118 | imports.gi.Gtk.Widget.returns(instance); 119 | 120 | const widget = new GtkWidget(); 121 | widget.update(changes); 122 | 123 | expect(instance.prop1).to.equal(2); 124 | }); 125 | 126 | it('should unset properties', function () { 127 | const imports = getDefaultImports(); 128 | const GtkWidget = injectGtkWidget(imports, logStub); 129 | const instance = { prop1: 1 }; 130 | const changes = { set: [], unset: [ 'prop1' ] }; 131 | 132 | imports.gi.GObject.signal_lookup.returns(0); 133 | imports.gi.Gtk.Widget.returns(instance); 134 | 135 | const widget = new GtkWidget(); 136 | widget.update(changes); 137 | 138 | expect(instance.prop1).to.equal(null); 139 | }); 140 | 141 | it('should set signal handlers', function () { 142 | const imports = getDefaultImports(); 143 | const GtkWidget = injectGtkWidget(imports, logStub); 144 | const instance = { connect: sinon.stub().returns(124) }; 145 | const onClicked = () => 'on clicked'; 146 | const changes = { set: [ [ 'onClicked', onClicked ] ], unset: [] }; 147 | 148 | imports.gi.GObject.signal_lookup.withArgs('clicked', instance).returns(1); 149 | imports.gi.Gtk.Widget.returns(instance); 150 | 151 | const widget = new GtkWidget(); 152 | widget.update(changes); 153 | 154 | expect(instance.connect.callCount).to.equal(1); 155 | expect(instance.connect.firstCall.args).to.deep.equal([ 'clicked', onClicked ]); 156 | expect(instance._connectedSignals).to.deep.equal({ clicked: 124 }); 157 | }); 158 | 159 | it('should update signal handlers', function () { 160 | const imports = getDefaultImports(); 161 | const GtkWidget = injectGtkWidget(imports, logStub); 162 | const instance = { 163 | connect: sinon.stub().returns(124), 164 | disconnect: sinon.spy(), 165 | _connectedSignals: { clicked: 125 } 166 | }; 167 | const onClicked = () => 'on clicked'; 168 | const changes = { set: [ [ 'onClicked', onClicked ] ], unset: [] }; 169 | 170 | imports.gi.GObject.signal_lookup.withArgs('clicked', instance).returns(124); 171 | imports.gi.Gtk.Widget.returns(instance); 172 | 173 | const widget = new GtkWidget(); 174 | widget.update(changes); 175 | 176 | expect(instance.connect.callCount).to.equal(1); 177 | expect(instance.connect.firstCall.args).to.deep.equal([ 'clicked', onClicked ]); 178 | expect(instance.disconnect.callCount).to.equal(1); 179 | expect(instance.disconnect.firstCall.args).to.deep.equal([ 125 ]); 180 | expect(instance._connectedSignals).to.deep.equal({ clicked: 124 }); 181 | }); 182 | 183 | it('should remove signal handlers', function () { 184 | const imports = getDefaultImports(); 185 | const GtkWidget = injectGtkWidget(imports, logStub); 186 | const instance = { 187 | disconnect: sinon.spy(), 188 | _connectedSignals: { clicked: 125 } 189 | }; 190 | const changes = { set: [], unset: [ 'onClicked' ] }; 191 | 192 | imports.gi.GObject.signal_lookup.withArgs('clicked', instance).returns(124); 193 | imports.gi.Gtk.Widget.returns(instance); 194 | 195 | const widget = new GtkWidget(); 196 | widget.update(changes); 197 | 198 | expect(instance.disconnect.callCount).to.equal(1); 199 | expect(instance.disconnect.firstCall.args).to.deep.equal([ 125 ]); 200 | expect(instance._connectedSignals).to.deep.equal({}); 201 | }); 202 | }); 203 | }); 204 | --------------------------------------------------------------------------------