├── .gitattributes ├── .gitignore ├── CONFIG.md ├── README.md ├── run-client.py ├── run-server.py └── src ├── OSC.py ├── OSC3.py ├── __init__.py ├── client.py ├── conf └── boot.json ├── config.py ├── hub ├── __init__.py ├── client.py └── parser.py ├── interface ├── __init__.py ├── bracket.py ├── colour_merge.py ├── colour_picker.py ├── conn_info.py ├── console.py ├── constraints.py ├── drag.py ├── img │ ├── icon.gif │ └── icon.ico ├── interface.py ├── line_numbers.py ├── menu_bar.py ├── mouse.py ├── peer.py └── textbox.py ├── interpreter.py ├── logfile.py ├── message.py ├── ot ├── __init__.py ├── client.py ├── server.py └── text_operation.py ├── receiver.py ├── sender.py ├── server.py ├── threadserv.py └── utils.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | 4 | # Custom for Visual Studio 5 | *.cs diff=csharp 6 | 7 | # Standard to msysgit 8 | *.doc diff=astextplain 9 | *.DOC diff=astextplain 10 | *.docx diff=astextplain 11 | *.DOCX diff=astextplain 12 | *.dot diff=astextplain 13 | *.DOT diff=astextplain 14 | *.pdf diff=astextplain 15 | *.PDF diff=astextplain 16 | *.rtf diff=astextplain 17 | *.RTF diff=astextplain 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Python file 2 | *.pyc 3 | __pycache__/* 4 | setup.py 5 | dist/* 6 | build/* 7 | env/* 8 | *.spec 9 | 10 | # Create logs 11 | logs/* 12 | 13 | # Windows image file caches 14 | Thumbs.db 15 | ehthumbs.db 16 | 17 | # Folder config file 18 | Desktop.ini 19 | 20 | # Recycle Bin used on file shares 21 | $RECYCLE.BIN/ 22 | 23 | # Windows Installer files 24 | *.cab 25 | *.msi 26 | *.msm 27 | *.msp 28 | *.iss 29 | 30 | # Windows shortcuts 31 | *.lnk 32 | 33 | # ========================= 34 | # Operating System Files 35 | # ========================= 36 | 37 | # OSX 38 | # ========================= 39 | 40 | .DS_Store 41 | .AppleDouble 42 | .LSOverride 43 | 44 | # Thumbnails 45 | ._* 46 | 47 | # Files that might appear on external disk 48 | .Spotlight-V100 49 | .Trashes 50 | 51 | # Directories potentially created on remote AFP share 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /CONFIG.md: -------------------------------------------------------------------------------- 1 | # Troop Interpreter Configuration 2 | 3 | Troop has seen some updates to its configuration to communicate with your live coding language, such as FoxDot and Tidal Cycles. Instead of the `boot.txt` file to point an interpreter to the location of a boot-file, Troop now uses `boot.json` to configure a language's executable path *and* bootfile. Let's look at how this affects Tidal users as an example: 4 | 5 | ## Editing paths 6 | 7 | TidalCycles, and it's host language Haskell, can be installed in different ways and use different executables to startup using the Glasgow Haskell Compiler but is most commonly run using the `ghci` executable. By default, TidalCycles will try and start using this file by default but not all instances of Tidal are run using it. For example, some users will have used 'stack' to install Tidal and will have to run it using `stach ghci`. Previously Troop included an extra drop-down option for the client for Tidal users using stack but this *no longer exists*. Instead, users now need edit the `src/conf/boot.json` file to point Tidal in the direction of `stack ghci`. This is what the file looks like by default: 8 | 9 | ```json 10 | { 11 | "foxdot": { 12 | "path": "", 13 | "bootfile": "" 14 | }, 15 | "tidalcycles": { 16 | "path": "", 17 | "bootfile": "" 18 | } 19 | } 20 | ``` 21 | 22 | If an empty path/bootfile is set, Troop will use the default (`ghci` for Tidal in this instance). To use `stack ghci` simply change the value for "path" under "tidalcycles" like so: 23 | 24 | 25 | ```json 26 | { 27 | "foxdot": { 28 | "path": "", 29 | "bootfile": "" 30 | }, 31 | "tidalcycles": { 32 | "path": "stack ghci", 33 | "bootfile": "" 34 | } 35 | } 36 | ``` 37 | 38 | After you save the file, Troop will use the new executable when starting up TidalCycles. User who use the `ghc` executable for Haskell can change the value as needed too. 39 | 40 | ## Editing bootfile 41 | 42 | The bootfile parameter mainly relates to TidalCycles but can also be used with FoxDot, but we will discuss Tidal config mainly here. Tidal requires that a `BootTidal.hs` file is run by the Haskell compile to load the necessary libraries and set any required values - without it, Tidal will not run. Troop uses the `ghc-pkg` application to find where this is stored - usually in the Tidal installation folder. You may wish to override this for a number of reasons: 43 | 44 | - The `ghc-pkg` file cannot find the boot file 45 | - The `ghc-pkg` is not installed/not on your path 46 | - You have a customer boot file you wish to run (also the case for FoxDot) 47 | 48 | You can manually set the path to your boot file in `src/conf/boot.json` by setting the "bootfile" parameter under your desired language. So if we had a custom Tidal bootfile in a directory called `/home/ryan/dev/` called `custom.hs` I would change my `boot.json` file to the following: 49 | 50 | ```json 51 | { 52 | "foxdot": { 53 | "path": "", 54 | "bootfile": "" 55 | }, 56 | "tidalcycles": { 57 | "path": "", 58 | "bootfile": "/home/ryan/dev/custom.hs" 59 | } 60 | } 61 | ``` 62 | 63 | Save the file and start Troop in Tidal mode to boot with your custom file! 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Troop v0.10.3 2 | 3 | ## Real-time collaborative live coding 4 | 5 | Troop is a real-time collaborative tool that enables group live coding within the same document across multiple computers. Hypothetically Troop can talk to any interpreter that can take input as a string from the command line but it is already configured to work with live coding languages [FoxDot](https://github.com/Qirky/FoxDot), [TidalCycles](https://tidalcycles.org/), and [SuperCollider](http://supercollider.github.io/). 6 | 7 | Troop is not a language for live coding but a tool for connecting multiple live coders over a network - so you'll need to install your language of choice before you get started. By default Troop runs with the Python based language, [FoxDot](https://github.com/Qirky/FoxDot), but it can also be used with [TidalCycles](https://tidalcycles.org/) and [SuperCollider](http://supercollider.github.io/). Click the links to find out more about installing. Both TidalCycles and FoxDot require [SuperCollider](http://supercollider.github.io/) to work, so it's a good bet you'll need it. 8 | 9 | Troop runs using Python 3, which can be downloaded from [here](https://www.python.org/) (see **troubleshooting** below for more help on installing Python) but make sure that you use the same version of Python that use to run [FoxDot](https://github.com/Qirky/FoxDot) when doing so. 10 | 11 | Linux users may need to install `python-tk` if you have not done so already: 12 | 13 | `apt-get install python-tk` 14 | 15 | As of 01/01/20, Troop will no longer support Python 2. Users may find that their experience using Troop with Python 2 is less than desirable so please do consider upgrading your version of Python as it is no longer officially supported by the Python foundation. 16 | 17 | ## Please note 18 | 19 | This is free software developed by one person in their free time so please understand that there may be bugs and it might not work perfectly for every OS and every live coding language - both of these change over time and it is often difficult to keep up. Please don't be pedantic and please don't expect the same quality of software from a company that employs hundreds of professional programmers. I made this as a fun tool as part of a University project to play with friends and have been maintaining it but the expectations and attitudes of many people have put me off continuing to do so. Thank you. 20 | 21 | ## v0.10 Update 23/05/20 22 | 23 | - v0.10.1 - There are some new ways of configuring Troop to run with different installations of your live coding languages, particularly Tidal Cycles. Please read the [CONFIG.md](/CONFIG.md) file for more information. 24 | - v0.10.2 - The "keep-alive" functionality has been moved into a command line argument. If you have connection issues and users aren't being kicked from session when the client crashes then try starting the server with the `--keepalive` flag. This will send a 'ping' message to clients who then return it. Any clients that don't return the ping in 30 seconds are forcefully disconnected. 25 | 26 | ## Getting started 27 | 28 | There are two ways of using Troop; one is to download the latest release and run it as you would any other program on your computer, and the other is two run the files using Python. The first option does not require Python to be installed on your machine, but you do need to have correctly configured your live coding language of choice first e.g. FoxDot, which uses Python to run. 29 | 30 | #### Using the downloadable executable 31 | 32 | 1. Download the latest version for your appropriate operating system [from this page](https://github.com/Qirky/Troop/releases). 33 | 2. Double-click the program to get started. Enter server connection details then press OK to open the interface. 34 | 3. You can still run Troop from the command line with extra arguments as you would the Python files. Run the following command to find out more (changing the executable name for the version you have downloaded): 35 | 36 | Troop-Windows-0.9.1-client.exe -h 37 | 38 | See "Running the Troop client" below for more details. 39 | 40 | #### Running the Python files 41 | 42 | Download the files from this repository as a .zip file and extract the contents to a suitable folder. Alternatively, if you are familiar with Git you can clone the repository yourself using: 43 | 44 | git clone https://github.com/Qirky/Troop.git 45 | 46 | and keep up to date with the project by using `git pull -a`, which automatically update your files with any changes. 47 | 48 | ## Running the Troop server 49 | 50 | Troop is a client-server application, which means that you need to run a Troop server on a machine that other people on the network using the client (interface) can connect to. Only one person needs to run the server, so decide who will do the "hosting" before getting started. 51 | 52 | Start the Troop Server by running the `run-server.py` Python file. Depending on your O/S and Python installation you can either double click the file or run it from the command prompt. To run from the command prompt you'll need to make sure you're in correct directory: use the 'cd' command followed by the path to where you've extracted Troop. For example if Troop is saved in `C:\Users\Guest\Troop` then type the following into the command prompt: 53 | 54 | cd C:\Users\Guest\Troop 55 | 56 | Then to run the server application, type in the following and press return: 57 | 58 | python run-server.py 59 | 60 | If you don't have Python installed and you have downloaded the executable, simply type the name of the executable and press return (or double clicking on it): 61 | 62 | Troop-Windows-0.9.1-server.exe 63 | 64 | You will be asked to enter a password. You can leave this blank if you wish - but make sure you are on a secure network if you do. Connecting clients will be required to enter the same password when connecting to the server. By default the server will run on port 57890 but this isn't always the case. Make a note of the address and port number so that Troop clients can connect to the server and you're up and running! To stop the server, either close the terminal window it's running in or use the keyboard shorcut `Ctrl+C` to kill the process. 65 | 66 | **Warning:** Code executed by one client is executed on every client, so be careful when using public networks as you will then be susceptible to having malicious code run on your machine. Avoid using public networks and only give your server password to people you trust. 67 | 68 | ### Running the Troop Client 69 | 70 | Once you've opened the Troop client you'll be able to enter the IP address and port number of the Troop server instance running on your network. Enter the name you want to display and the password for the server and select the interpreter you want to use (requires installation and setup - see below). Press OK to open the editor. You can also change the interpreter to use with Troop after you've opened the editor by going to `Code -> Choose Language` and selecting the language of choice. 71 | 72 | Alternatively you can start Troop in a different "mode" so that it is interpreting another language at startup. To do this, run the following from the command line depending on your desired startup language: 73 | 74 | **[TidalCycles](https://tidalcycles.org/)** 75 | 76 | python run-client.py --mode TidalCycles 77 | 78 | **[SuperCollider](https://supercollider.github.io/)** 79 | 80 | python run-client.py --mode SuperCollider 81 | 82 | To use the SuperCollider language from Troop you will need to install the Troop Quark but opening SuperCollider and running the following line of code. This will create a class that listens for messages from Troop containing SuperCollider code. 83 | 84 | Quarks.install("http://github.com/Qirky/TroopQuark.git") 85 | 86 | Once this is done you'll need to make SuperCollider listen for Troop messages by evaluating the following line of code in SuperCollider: 87 | 88 | Troop.start 89 | 90 | **[Sonic Pi](https://sonic-pi.net/)** 91 | 92 | python run-client.py --mode SonicPi 93 | 94 | Requires Sonic-Pi to be open on your computer. 95 | 96 | **Other** 97 | 98 | python run-client.py --mode path/to/interpreter 99 | 100 | If you've connected successfully then you'll greeted with an interface with three boxes. The largest of the boxes is used to input code and the others to display console responses and some stats about character usages. To evaluate a line of code make sure your text cursor is placed in the line you want and press `Ctrl+Return`. If there are any other users connected you should see coloured markers in the text displaying their names. You can even execute code they've written and vice versa. 101 | 102 | #### Running multiple instances in the same location 103 | 104 | If you are and your fellow live coders are in the same room using Troop, it's often most convenient for only one laptop to produce sound (the master). When one user logs in using an interpreter, such as TidalCycles, all others can log in using the "No Interpreter" option or `--mode none` flag. When the "master" laptop receives text in the console, it is sent to all of the other users so you can see exactly what your code is doing. Futhermore, you can select the language for syntax highlighting / keyboard short-cuts at the log in window or use the `--syntax` flag to choose the language you wish to emulate. 105 | 106 | #### Other flags 107 | 108 | Other flags can be added to the `run-client.py` command too. Below is an in-depth look at how to use them: 109 | 110 | `python run-client.py -h` / `python run-client.py --help` - Shows the help dialog and exits 111 | 112 | `python run-client.py -i` / `python run-client.py --cli` - Starts Troop with a command line interface 113 | 114 | `python run-client.py -H HOST` / `python run-client.py --host HOST` - Start Troop with the host value set to HOST 115 | 116 | `python run-client.py -P port` / `python run-client.py --port PORT` - Start Troop with the port value set to PORT 117 | 118 | `python run-client.py -m MODE` / `python run-client.py --mode MODE` - Start Troop with the specified mode (see above) 119 | 120 | `python run-client.py -c` / `python run-client.py --config` - Load host/port info from `client.cfg`. You can create a `client.cfg` file in the root directory if you have a host / port that you want to connect to regularly. It's contents should contain two lines: 121 | 122 | host= 123 | port= 124 | 125 | `python run-client.py -a ARG1, ARG2, ...` / `python run-client.py --args ARG1, ARG2, ...` - Supply remaining command line arguments to the interpreter e.g. 126 | 127 | python run-client.py --args --startup path/to/startup_file.py 128 | 129 | ## Troubleshooting 130 | 131 | ### Installing Python 132 | 133 | If you are using Windows you might get an error along the lines of "python is not recognized command". This means you need to add Python to your system path so that your computer knows where to find Python's libraries. To do this open file explorer and right click on My Computer / This PC and click properties. From here you should open Advanced System Properties and click the button labelled Environment Variables. There should be a list of variables and their value. Of these variables there should be one named PATH. Edit it and add the location where Python was installed, most likely C:\Python27. If the PATH variable does not exist, create it and set its value to the Python installation. 134 | 135 | ### Server says it is running on 127.0.0.1 136 | 137 | For some versions of Linux Python retrieves the localhost IP address instead of the public facing IP address, which means users trying to connect to 127.0.0.1 when running the Troop client will attempt to connect to *their own machine*. To find your IP address, open a terminal/command prompt and type `ipconfig` (windows) or `ifconfig` (Linux/Mac) and press enter. This will display information about your network adapters and your IP address will probably look along the lines of 192.168.0.xx if you are using a standard home network. 138 | 139 | ### Errors or bugs while Troop is running 140 | 141 | If you do find any problems when using Troop, please raise an issue on the GitHub page quoting the error message and describing what you were doing at the time (on Troop not in life). 142 | 143 | 144 | ## Thanks 145 | 146 | Huge thank you to Alex McLean for his inspiration for this project and to Lucy and Laurie, among other users from the live coding community, for testing it during its development. 147 | 148 | ### Feedback 149 | 150 | Your feedback for this project would be greatly appreciated. If you have used the Troop software yourself, please take a few minutes to fill out my (mostly) multiple-choice [online questionnaire](http://tinyurl.com/troop-feedback). 151 | -------------------------------------------------------------------------------- /run-client.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | Troop-Client 4 | ------------ 5 | Real-time collaborative Live Coding. 6 | 7 | - Troop is a real-time collaborative tool that enables group live 8 | coding within the same document. To run the client application it 9 | must be able to connect to a running Troop Server instance on 10 | your network. Running `python run-client.py` will start the process 11 | of connecting to the server by asking for a host and port (defaults 12 | are localhost and port 57890). 13 | 14 | - Using other Live Coding Languages: 15 | 16 | Troop is designed to be used with FoxDot (http://foxdot.org) but 17 | is also configured to work with Tidal Cycles (http://tidalcycles.org). 18 | You can run this file with the `--mode` flag followed by "tidalcycles" 19 | to use the Tidal Cycles language. You can also use any other application 20 | that can accept code commands as strings via the stdin by specifying 21 | the path of the interpreter application, such as ghci in the case of 22 | Tidal Cycles, in place of the "tidalcycles" string when using the 23 | `--mode` flag. 24 | """ 25 | 26 | import argparse 27 | from src.config import langnames 28 | 29 | parser = argparse.ArgumentParser( 30 | prog="Troop Client", 31 | description="Collaborative interface for Live Coding") 32 | 33 | parser.add_argument('-i', '--cli', action='store_true', help="Use the command line to enter connection info") 34 | parser.add_argument('-p', '--public', action='store_true', help="Connect to public Troop server") 35 | parser.add_argument('-H', '--host', action='store', help="IP Address of the machine running the Troop server") 36 | parser.add_argument('-P', '--port', action='store', help="Port for Troop server (default 57890)") 37 | parser.add_argument('-n', '--name', action='store', help="Display name to use") 38 | parser.add_argument('-m', '--mode', action='store', default='foxdot', 39 | help='Name of live coding language ({}) or a valid executable'.format(', '.join(langnames.keys()))) 40 | parser.add_argument('-s', '--syntax', action='store', 41 | help='Name of live coding language syntax to use when selecting "No Interpreter" option.') 42 | parser.add_argument('-a', '--args', action='store', help="Add extra arguments to supply to the interpreter", nargs=argparse.REMAINDER, type=str) 43 | parser.add_argument('-c', '--config', action='store_true', help="Load connection info from 'client.cfg'") 44 | parser.add_argument('--hub', help="Connect to a named Troop server running on the Troop Hub Service") 45 | 46 | args = parser.parse_args() 47 | 48 | # Set up client 49 | 50 | from src.client import Client 51 | from src.config import readin 52 | from getpass import getpass 53 | 54 | # Language and syntax 55 | 56 | options = { 'lang': args.mode } 57 | 58 | if args.syntax: 59 | 60 | options['syntax'] = args.syntax 61 | 62 | # Server address 63 | 64 | if args.hub: 65 | 66 | from src.hub import HubClient, HubParser 67 | 68 | print("Troop Hub Service | Collecting details for '{}'".format(args.hub)) 69 | 70 | hub = HubParser(args.hub) 71 | address = HubClient(**hub).query(hub.get('name')) 72 | 73 | print("Troop Hub Service | Success.") 74 | 75 | options['host'], options['port'] = address 76 | 77 | elif args.public: 78 | 79 | from src.config import PUBLIC_SERVER_ADDRESS 80 | options['host'], options['port'] = PUBLIC_SERVER_ADDRESS 81 | 82 | else: 83 | 84 | if args.host: 85 | 86 | options['host'] = args.host 87 | 88 | if args.port: 89 | 90 | options['port'] = args.port 91 | 92 | # User name 93 | 94 | if args.name: 95 | 96 | options['name'] = args.name 97 | 98 | # Non-gui startup 99 | 100 | if args.cli: 101 | 102 | if 'host' not in options: 103 | 104 | options['host'] = readin("Troop Server Address", default="localhost") 105 | 106 | if 'port' not in options: 107 | 108 | options['port'] = readin("Port Number", default="57890") 109 | 110 | if 'name' not in options: 111 | 112 | options['name'] = readin("Enter a name") 113 | 114 | options['password'] = getpass() 115 | options['get_info'] = False # Flag to say we don't need the GUI 116 | 117 | elif args.config: 118 | 119 | import os.path 120 | 121 | if os.path.isfile('client.cfg'): 122 | 123 | """ 124 | You can set a configuration file if you are connecting to the same 125 | server on repeated occasions. A password should not be stored. The 126 | file (client.cfg) should look like: 127 | 128 | host= 129 | port= 130 | 131 | """ 132 | 133 | options.update(Client.read_configuration_file('client.cfg')) 134 | 135 | else: 136 | 137 | print("Unable to load configuration from 'client.cfg'") 138 | 139 | # Store any extra arguments to supply to the interpreter 140 | 141 | if args.args: 142 | 143 | options['args'] = args.args 144 | 145 | myClient = Client(**options) 146 | -------------------------------------------------------------------------------- /run-server.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Troop-Server 5 | ------------ 6 | 7 | The Troop Server runs on the local machine by default on port 57890. 8 | This needs to be running before connecting using the client application. 9 | See "run-client.py" for more information on how to connect to the 10 | server. 11 | 12 | """ 13 | 14 | import argparse 15 | from src.server import TroopServer 16 | from getpass import getpass 17 | 18 | if __name__ == '__main__': 19 | parser = argparse.ArgumentParser() 20 | parser.add_argument("-p", "--port", help="Specify a port to use (default is 57890, auto-increments when multiple instances are running.)", default=57890, type=int) 21 | parser.add_argument("-P", "--password", help="Set a password. If not specified, you will be prompted for it.") 22 | parser.add_argument("-d", "--debug", help="Run the server in debug mode.", default=False, action='store_true') 23 | parser.add_argument("-k", "--keepalive", help="Turn on server keep-alive to force kick 'dead' clients.", default=False, action='store_true') 24 | parser.add_argument("-l", "--log", help="Turn the logging on. The logs will be saved to the 'logs' directory.", default=False, action='store_true') 25 | parser.add_argument("--hub", help="Create a public Troop server via the Troop Hub Service.") 26 | args = parser.parse_args() 27 | 28 | if args.password is None: 29 | password = getpass("Password (leave blank for no password): ") 30 | else: 31 | password = args.password 32 | 33 | try: 34 | if args.hub: 35 | from src.hub import HubClient, HubParser 36 | myServer = HubClient(password=password, **HubParser(args.hub)) 37 | else: 38 | myServer = TroopServer(password=password, port=args.port, debug=args.debug, log=args.log, keepalive=args.keepalive) 39 | myServer.start() 40 | except KeyboardInterrupt: 41 | # Exit cleanly on Ctrl + c 42 | pass 43 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qirky/Troop/529c5eb14e456f683e6d23fd4adcddc8446aa115/src/__init__.py -------------------------------------------------------------------------------- /src/client.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, print_function 2 | 3 | from .interface import * 4 | from .sender import * 5 | from .receiver import * 6 | from .message import * 7 | from .config import * 8 | from .interpreter import * 9 | 10 | from time import sleep, time 11 | from hashlib import md5 12 | 13 | try: 14 | import queue 15 | except ImportError: 16 | import Queue as queue 17 | 18 | import sys 19 | 20 | class Client: 21 | 22 | version = VERSION 23 | ui = None 24 | send = None 25 | recv = None 26 | mainloop_started = False 27 | keepalive = None 28 | timeout = 3 29 | 30 | def __init__(self, **kwargs): 31 | 32 | self.is_alive = True 33 | 34 | # Start the UI 35 | 36 | self.input = ConnectionInput(self, **kwargs) 37 | self.input.start() 38 | 39 | def setup(self, host="", port="", name="", password="", lang=FOXDOT, syntax=FOXDOT, args="", ipv6=False): 40 | 41 | # ConnectionInput(host, port) 42 | 43 | self.hostname = str(host) 44 | self.port = int(port) 45 | self.name = str(name if name is not None else hostname).replace(" ", "_") 46 | self.args = args 47 | self.id = None 48 | 49 | # Try and connect to server 50 | 51 | try: 52 | 53 | self.send = Sender(self).connect(self.hostname, self.port, self.name, ipv6, password) 54 | 55 | if not self.send.connected: 56 | 57 | raise ConnectionError(self.send.error_message()) 58 | 59 | else: 60 | 61 | self.id = self.send.conn_id 62 | 63 | assert self.id is not None, "No ID number assigned by server" 64 | 65 | self.input.print_message("Password accepted") 66 | 67 | self.send_queue = queue.Queue() 68 | 69 | # Quit with error output if we cannot connect 70 | 71 | except Exception as e: 72 | 73 | self.input.print_message(e) 74 | 75 | return 76 | 77 | # Clean up the user interface 78 | 79 | self.input.cleanup() 80 | 81 | # Continue with set up 82 | # Set up a receiver on the connected socket 83 | 84 | self.recv = Receiver(self, self.send.conn) 85 | self.recv.start() 86 | 87 | self.address = (self.send.hostname, self.send.port) 88 | 89 | # Choose the language to use 90 | 91 | try: 92 | 93 | lang = getInterpreter(lang) 94 | 95 | if lang in langtypes: 96 | 97 | if lang >= 0: 98 | 99 | # Use a known interpreter 100 | 101 | self.lang = langtypes[lang](self, self.args) 102 | 103 | else: 104 | 105 | # Use dummy interpreter with specific syntax highlighting 106 | 107 | self.lang = langtypes[lang](self, self.args, syntax=getInterpreter(syntax)) 108 | 109 | else: 110 | 111 | # Execute a program not known to Troop 112 | 113 | self.lang = Interpreter(self, lang, self.args) 114 | 115 | except ExecutableNotFoundError as e: 116 | 117 | print(e) 118 | 119 | self.lang = DummyInterpreter() 120 | 121 | # Create address book 122 | 123 | self.peers = {} 124 | 125 | # Set up a user interface 126 | 127 | title = "Troop - {}@{}:{}. v{}".format(self.name, self.send.hostname, self.send.port, self.version) 128 | self.ui = Interface(self, title, self.lang) 129 | self.ui.init_local_user(self.id, self.name) 130 | 131 | # Send information about this client to the server 132 | 133 | self.send( MSG_CONNECT(self.id, self.name, self.send.hostname, self.send.port, self.lang.id == -1) ) 134 | 135 | # Give the recv / send a reference to the user-interface 136 | self.recv.ui = self.ui 137 | self.send.ui = self.ui 138 | 139 | self.ui.run() 140 | 141 | 142 | @staticmethod 143 | def read_configuration_file(filename): 144 | conf = {} 145 | with open(filename) as f: 146 | for line in f.readlines(): 147 | try: 148 | line = line.strip().split("=") 149 | conf[line[0].strip()] = line[1].strip() 150 | except: 151 | pass 152 | return conf 153 | 154 | def update_send(self): 155 | """ Continually polls the queue and sends any messages to the server """ 156 | try: 157 | while self.send.connected: 158 | 159 | try: 160 | 161 | msg = self.send_queue.get_nowait() 162 | 163 | self.send( msg ) 164 | 165 | except ConnectionError as e: 166 | 167 | return print(e) 168 | 169 | self.ui.root.update_idletasks() 170 | 171 | # Break when the queue is empty 172 | except queue.Empty: 173 | pass 174 | 175 | # Recursive call 176 | self.ui.root.after(30, self.update_send) 177 | return 178 | 179 | def is_master(self): 180 | """ Returns True if this client has the lowest ID number and is not 181 | using a dummy interpreter """ 182 | for peer_id, peer in self.peers.items(): 183 | if not peer.is_dummy: 184 | if peer_id < self.id: 185 | return False 186 | return True 187 | 188 | def kill(self): 189 | """ Kills the connection sockets and UI correctly """ 190 | 191 | self.is_alive = False 192 | 193 | for attr in (self.recv, self.send, self.ui): 194 | 195 | if attr is not None: 196 | 197 | attr.kill() 198 | 199 | return 200 | 201 | def check_for_timeout(self): 202 | if self.keepalive and (time() > self.keepalive + self.timeout): 203 | self.ui.freeze_kill('Warning: connection lost.') 204 | else: 205 | self.ui.root.after(1000, self.check_for_timeout) 206 | -------------------------------------------------------------------------------- /src/conf/boot.json: -------------------------------------------------------------------------------- 1 | { 2 | "foxdot": { 3 | "path": "", 4 | "bootfile": "" 5 | }, 6 | "tidalcycles": { 7 | "path": "", 8 | "bootfile": "" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/config.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import json 3 | import os, os.path 4 | 5 | VERSION = "0.10.3" 6 | 7 | # Check for location of Python 8 | 9 | if sys.argv[0] == sys.executable: # If this is compiled file, just use python 10 | 11 | PYTHON_EXECUTABLE = "python" 12 | 13 | else: 14 | 15 | PYTHON_EXECUTABLE = os.path.basename(sys.executable) 16 | 17 | 18 | PY_VERSION = sys.version_info[0] 19 | 20 | # Any Py2to3 21 | 22 | if PY_VERSION == 2: 23 | 24 | input = raw_input 25 | FileNotFoundError = IOError 26 | 27 | # This removed blurry fonts on Windows 28 | try: 29 | from ctypes import windll 30 | try: 31 | windll.shcore.SetProcessDpiAwareness(1) 32 | except: 33 | pass 34 | except ImportError: 35 | pass 36 | 37 | # Apparently this fixes some issues 38 | 39 | try: 40 | import matplotlib 41 | matplotlib.use('TkAgg') 42 | except ImportError: 43 | pass 44 | 45 | 46 | def stdout(*args): 47 | """ Forces prints to server-side """ 48 | sys.__stdout__.write(" ".join([str(s) for s in args]) + "\n") 49 | 50 | def readin(prompt="", default=None): 51 | other = " ({})".format(default) if default is not None else "" 52 | while True: 53 | try: 54 | val = input("{}{}: ".format(prompt, other)) 55 | if val != "": 56 | return val 57 | elif val == "" and default is not None: 58 | return default 59 | except (EOFError, SystemExit, KeyboardInterrupt): 60 | sys.exit() 61 | return 62 | 63 | # Absolute path of the root e.g. where run-client.py is found 64 | 65 | ROOT_DIR = os.path.join(os.path.dirname(__file__), "..") 66 | SRC_DIR = os.path.join(os.path.dirname(__file__)) 67 | CONF_DIR = os.path.join(SRC_DIR, "conf") 68 | BOOT_CONFIG_FILE = os.path.join(CONF_DIR, "boot.json") 69 | 70 | BOOT_DATA = {} 71 | if os.path.exists(BOOT_CONFIG_FILE): 72 | with open(BOOT_CONFIG_FILE) as f: 73 | BOOT_DATA = json.loads(f.read()) 74 | 75 | 76 | # Check for OS -> mac, linux, win 77 | 78 | SYSTEM = 0 79 | WINDOWS = 0 80 | LINUX = 1 81 | MAC_OS = 2 82 | 83 | if sys.platform.startswith('darwin'): 84 | 85 | SYSTEM = MAC_OS 86 | 87 | elif sys.platform.startswith('win'): 88 | 89 | SYSTEM = WINDOWS 90 | 91 | elif sys.platform.startswith('linux'): 92 | 93 | SYSTEM = LINUX 94 | 95 | # RegEx and tags 96 | 97 | import re 98 | 99 | string_regex = re.compile(r"\".*?\"|'.*?'|\".*?$|'.*?$") 100 | 101 | tag_descriptions = { 102 | "code" : {"background": "Red", "foreground": "White"}, 103 | "tag_bold" : {"font": "BoldFont"}, 104 | "tag_italic" : {"font": "ItalicFont"} 105 | } 106 | 107 | 108 | # Public server 109 | 110 | PUBLIC_SERVER_ADDRESS = ("206.189.25.52", 57890) 111 | 112 | # Choose a language 113 | 114 | DUMMY = -1 115 | FOXDOT = 0 116 | TIDAL = 1 117 | SUPERCOLLIDER = 2 118 | SONICPI = 3 119 | 120 | langnames = { "foxdot" : FOXDOT, 121 | "tidalcycles" : TIDAL, 122 | "supercollider" : SUPERCOLLIDER, 123 | "sonic-pi" : SONICPI, 124 | "none" : DUMMY } 125 | 126 | langtitles = { "foxdot" : "FoxDot", 127 | "tidalcycles" : "TidalCycles", 128 | "supercollider" : "SuperCollider", 129 | "sonic-pi" : "Sonic-Pi", 130 | "none" : "No Interpreter" } 131 | 132 | def getInterpreter(path): 133 | """ Returns the integer representing the specified interpreter unless 134 | a custom path is used, which is returned """ 135 | if isinstance(path, str): 136 | path = path.lower() 137 | return langnames.get(path, path) 138 | 139 | # Sorting colours 140 | 141 | global COLOUR_INFO_FILE 142 | global COLOURS 143 | 144 | COLOUR_INFO_FILE = os.path.join(CONF_DIR, "colours.txt") 145 | 146 | COLOURS = { "Background" : "#272822", 147 | "Console" : "#151613", 148 | "Stats" : "#151613", 149 | "Alpha" : 0.8, 150 | "Peers" : [ "#66D9EF", 151 | "#F92672", 152 | "#ffd549", 153 | "#A6E22E", 154 | "#ff108f", 155 | "#fffd56", 156 | "#0589e7", 157 | "#c345f5", 158 | "#ff411f", 159 | "#05cc50" ] } 160 | 161 | def LoadColours(): 162 | """ Reads colour information from COLOUR_INFO and updates 163 | the IDE accordingly. """ 164 | # Read from file 165 | read = {} 166 | try: 167 | with open(COLOUR_INFO_FILE) as f: 168 | for line in f.readlines(): 169 | attr, colour = [item.strip() for item in line.split("=")] 170 | read[attr] = colour 171 | except IOError: 172 | pass 173 | # Load into memory 174 | for key, colour in read.items(): 175 | if key.startswith("Peer"): 176 | _, i = key.split() 177 | COLOURS["Peers"][int(i)-1] = colour 178 | else: 179 | COLOURS[key] = colour 180 | return 181 | 182 | LoadColours() 183 | 184 | def exe_exists(exe): 185 | if SYSTEM == WINDOWS: 186 | exe = "{}.exe".format(exe) 187 | return any( 188 | os.access(os.path.join(path, exe), os.X_OK) 189 | for path in os.environ["PATH"].split(os.pathsep) 190 | ) 191 | 192 | class ExecutableNotFoundError(Exception): 193 | def __init__(self, executable): 194 | Exception.__init__(self, "{}: '{}' is not a valid executable".format(self.__class__.__name__, executable)) 195 | -------------------------------------------------------------------------------- /src/hub/__init__.py: -------------------------------------------------------------------------------- 1 | from .client import * 2 | from .parser import * 3 | -------------------------------------------------------------------------------- /src/hub/client.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | import time 4 | import sys 5 | import threading 6 | 7 | from ..config import PUBLIC_SERVER_ADDRESS 8 | 9 | class JSONMessage: 10 | """ Wrapper for JSON messages sent to the server """ 11 | def __init__(self, data): 12 | self.data = data 13 | 14 | def __str__(self): 15 | return self.string 16 | 17 | @property 18 | def string(self): 19 | """ 20 | Prepares the json message to be sent with first 4 digits 21 | denoting the length of the message 22 | """ 23 | if not hasattr(self, "_string"): 24 | packet = str(json.dumps(self.data, separators=(',',':'))) 25 | length = "{:04d}".format( len(packet) ) 26 | self._string = length + packet 27 | return self._string 28 | 29 | def __len__(self): 30 | return len(str(self)) 31 | 32 | class HubClient: 33 | def __init__(self, *args, **kwargs): 34 | self.name = kwargs.get('name') 35 | self.hostname = kwargs.get('host', PUBLIC_SERVER_ADDRESS[0]) 36 | self.port = int(kwargs.get('port', 57990)) 37 | self.address = (self.hostname, self.port) 38 | self.password = kwargs.get('password', '') 39 | 40 | try: 41 | self.socket = socket.socket() 42 | self.socket.connect(self.address) 43 | except socket.error: 44 | sys.exit("Troop Hub Service | Error: could not connect to service") 45 | 46 | self.polling_thread = threading.Thread(target=self.listen) 47 | self.polling_thread.daemon = True 48 | 49 | self.running = False 50 | 51 | def start(self): 52 | ''' Connect to Hub and instantiate server ''' 53 | self.running = self.connect() 54 | self.polling_thread.start() 55 | while self.running: 56 | try: 57 | time.sleep(10) 58 | except KeyboardInterrupt: 59 | self.kill('KeyboardInterrupt', error=False) 60 | self.running=False 61 | return 62 | 63 | def listen(self): 64 | ''' Continually polls - currently has no handle except errors ''' 65 | while self.running: 66 | try: 67 | self.poll() 68 | except Exception as e: 69 | self.kill(e) 70 | self.running = False 71 | return 72 | 73 | def connect(self): 74 | if not self.name: 75 | raise ValueError("Server name cannot be 'None'") 76 | self.send({ 77 | 'type': 'server', 78 | 'name': self.name, 79 | 'password': self.password 80 | }) 81 | data = self.poll() 82 | if 'address' in data: 83 | print("Server running @ {} on port {}.".format(*data['address'])) 84 | return True 85 | return False 86 | 87 | def query(self, name): 88 | ''' 89 | Get the hostname and port for a named Troop server withing the Hub Service 90 | ''' 91 | self.send({ 92 | 'type': 'query', 93 | 'name': name 94 | }) 95 | data = self.poll() 96 | result = data.get('result') 97 | if result is None: 98 | sys.exit("Troop Hub Service | Error: '{}' not found".format(name)) 99 | return result 100 | 101 | def poll(self): 102 | ''' Used when polling socket, handles errors ''' 103 | data = self.recv() 104 | if not data: 105 | return self.handle_error("Broken pipe") 106 | elif 'error' in data: 107 | return self.handle_error(data['error']) 108 | return data 109 | 110 | def handle_error(self, message): 111 | print("Error: {}".format(message)) 112 | self.running = False 113 | return {} 114 | 115 | def kill(self, message="", error=True): 116 | if error: 117 | self.handle_error(message) 118 | self.send({'kill': str(message)}) 119 | 120 | def recv(self): 121 | """ Reads data from the socket """ 122 | # Get number single int that tells us how many digits to read 123 | try: 124 | bits = int(self.socket.recv(4).decode()) 125 | except Exception as e: 126 | return None 127 | if bits > 0: 128 | # Read the remaining data (JSON) 129 | data = self.socket.recv(bits).decode() 130 | # Convert back to Python data structure 131 | return json.loads(data) 132 | 133 | def send(self, data): 134 | """ Converts Python data structure to JSON message and 135 | sends to a connected socket """ 136 | msg = JSONMessage(data) 137 | # Get length and store as string 138 | msg_len, msg_str = len(msg), str(msg).encode() 139 | # Continually send until we know all of the data has been sent 140 | sent = 0 141 | while sent < msg_len: 142 | bits = self.socket.send(msg_str[sent:]) 143 | sent += bits 144 | return 145 | -------------------------------------------------------------------------------- /src/hub/parser.py: -------------------------------------------------------------------------------- 1 | class HubParser(dict): 2 | def __init__(self, input): 3 | super().__init__() 4 | if '@' in input: 5 | self['name'], address = input.split('@', 1) 6 | if ':' in address: 7 | self['host'], self['port'] = address.split(':', 1) 8 | else: 9 | self['host'] = address 10 | else: 11 | self['name'] = input 12 | -------------------------------------------------------------------------------- /src/interface/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from .interface import * 3 | from .conn_info import * 4 | -------------------------------------------------------------------------------- /src/interface/bracket.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | try: 4 | from Tkinter import * 5 | except ImportError: 6 | from tkinter import * 7 | 8 | from ..message import * 9 | from ..config import * 10 | 11 | whitespace = [" ","\t","\n","\r","\f","\v"] 12 | 13 | class BracketHandler: 14 | def __init__(self, master): 15 | 16 | self.root = master 17 | 18 | self.text = master.text 19 | 20 | self.inbrackets = False 21 | 22 | self.style = {'borderwidth': 2, 'relief' : 'groove'} 23 | self.text.tag_config("tag_open_brackets", **self.style) 24 | 25 | left_b = list("([{") 26 | right_b = list(")]}") 27 | 28 | self.left_brackets = dict(zip(left_b, right_b)) 29 | self.right_brackets = dict(zip(right_b, left_b)) 30 | 31 | self.left_brackets_all = dict(list(zip(left_b, right_b)) + [("'","'"), ('"','"')]) 32 | self.right_brackets_all = dict(list(zip(right_b, left_b)) + [("'","'"), ('"','"')]) 33 | 34 | 35 | def is_inserting_bracket(self, text, row, col, char): 36 | 37 | # Assume we are adding a new bracket 38 | 39 | adding_bracket = True 40 | 41 | coords = self.find_starting_bracket(text, row, col - 1, char) 42 | 43 | # If there isn't a starting bracket 44 | 45 | if coords is not None: 46 | 47 | # Get index of the end of the buffer 48 | 49 | col1 = new_col = (col + 1) if (col < len(text[row])-1) else 0 50 | row1 = new_row = (row + 1) if new_col == 0 else row 51 | 52 | end_row, end_col = len(text), 0 53 | 54 | while (new_row, new_col) != (end_row, end_col) and len(text[new_row]) > 0: 55 | 56 | # If we find a closing bracket, find it's pair 57 | 58 | next_char = text[new_row][new_col] 59 | 60 | if next_char == char: 61 | 62 | coords_ = self.find_starting_bracket(text, new_row, new_col - 1, char, offset=0) 63 | 64 | # If there is not a closing brackets 65 | 66 | if coords_ is None: 67 | 68 | adding_bracket = False 69 | 70 | break 71 | 72 | else: 73 | 74 | adding_bracket = True 75 | 76 | else: 77 | 78 | break 79 | 80 | # row1, col1 = new_row, new_col 81 | 82 | if new_col == (len(text[new_row])-1): 83 | 84 | new_row += 1 85 | new_col = 0 86 | 87 | else: 88 | 89 | new_col += 1 90 | 91 | return adding_bracket 92 | 93 | 94 | def find_starting_bracket(self, text, line, column, bracket_style, offset = 0): 95 | """ Finds the opening bracket to the closing bracket at line, column co-ords. 96 | Returns None if not found. """ 97 | 98 | line_length = column + 1 99 | used_br = offset 100 | 101 | for row in range(line, 0, -1): 102 | 103 | if line_length > 1: 104 | 105 | for col in range(line_length-1, -1, -1): 106 | 107 | # If the char is a left bracket and not used, break 108 | 109 | try: 110 | 111 | if text[row][col] == self.right_brackets[bracket_style]: 112 | 113 | if used_br == 0: 114 | 115 | return row, col 116 | 117 | else: 118 | 119 | used_br -= 1 120 | 121 | elif text[row][col] == bracket_style: 122 | 123 | used_br += 1 124 | 125 | except IndexError: # TODO <- tidy this up 126 | 127 | stdout(text[row], col, len(text[row]), line_length) 128 | 129 | # line_length = int(self.text.index("{}.end".format(row-1)).split(".")[1]) 130 | line_length = len(text[row-1]) 131 | 132 | else: 133 | 134 | return None 135 | -------------------------------------------------------------------------------- /src/interface/colour_merge.py: -------------------------------------------------------------------------------- 1 | try: 2 | from Tkinter import * 3 | from tkColorChooser import askcolor 4 | except ImportError: 5 | from tkinter import * 6 | from tkinter.colorchooser import askcolor 7 | 8 | class ColourMerge(object): 9 | """docstring for ColourMerge""" 10 | def __init__(self, parent, *args, **kwargs): 11 | self.parent = parent # text widget 12 | self.colour = None 13 | self.duration = 0 14 | self.time_elapsed = 0 15 | self.recur_time = 0 16 | self.weight = 0 17 | 18 | def start(self, event=None): 19 | """ Opens a basic text-entry window and starts the process of "merging fonts". 20 | This is the slow process of converging all the font colours to the same 21 | colour. 22 | """ 23 | 24 | # TODO get values from a window 25 | 26 | _, self.colour = askcolor() 27 | self.duration = self.ask_duration() 28 | 29 | self.recur_time = int( (60000 * self.duration) / 100) 30 | 31 | self.update_font_colours(recur_time = self.recur_time ) 32 | 33 | return 34 | 35 | def ask_duration(self): 36 | """ Opens a small window that asks the user to enter a duration """ 37 | 38 | self.root = self.parent.root.root # Tk instance 39 | 40 | popup = popup_window(self.root, title="Set duration") 41 | popup.text.focus_set() 42 | 43 | # Put the popup on top 44 | 45 | self.root.wait_window(popup.top) 46 | 47 | return float(popup.value) 48 | 49 | 50 | def update_font_colours(self, recur_time=0): 51 | """ Updates the font colours of all the peers. Set a recur time 52 | to update reguarly. 53 | """ 54 | 55 | for peer in self.parent.peers.values(): 56 | 57 | peer.update_colours() 58 | 59 | peer.configure_tags() 60 | 61 | self.parent.root.graphs.itemconfig(peer.graph, fill=peer.bg) 62 | 63 | if recur_time > 0: 64 | 65 | self.time_elapsed += recur_time 66 | 67 | self.weight = min(self.weight + 0.01, 1) 68 | 69 | if self.weight < 1: 70 | 71 | self.parent.after(recur_time, lambda: self.update_font_colours(recur_time = self.recur_time)) 72 | 73 | return 74 | 75 | def get_weight(self): 76 | return self.weight 77 | 78 | 79 | 80 | class popup_window: 81 | def __init__(self, master, title=""): 82 | self.top=Toplevel(master) 83 | self.top.title(title) 84 | # Text entry 85 | lbl = Label(self.top, text="Duration (mins): ") 86 | lbl.grid(row=0, column=0, sticky=W) 87 | self.text=Entry(self.top) 88 | self.text.grid(row=0, column=1, sticky=NSEW) 89 | self.value = None 90 | # Ok button 91 | self.button=Button(self.top,text='Ok',command=self.cleanup) 92 | self.button.grid(row=1, column=0, columnspan=2, sticky=NSEW) 93 | # Enter shortcut 94 | self.top.bind("", self.cleanup) 95 | # Start 96 | self.center() 97 | 98 | def cleanup(self, event=None): 99 | """ Stores the data in the entry fields then closes the window """ 100 | self.value = float(self.text.get()) 101 | self.top.destroy() 102 | 103 | def center(self): 104 | """ Centers the popup in the middle of the screen """ 105 | self.top.update_idletasks() 106 | w = self.top.winfo_screenwidth() 107 | h = self.top.winfo_screenheight() 108 | size = tuple(int(_) for _ in self.top.geometry().split('+')[0].split('x')) 109 | x = w/2 - size[0]/2 110 | y = h/2 - size[1]/2 111 | self.top.geometry("%dx%d+%d+%d" % (size + (x, y))) 112 | return -------------------------------------------------------------------------------- /src/interface/colour_picker.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | try: 4 | from Tkinter import * 5 | from tkColorChooser import askcolor 6 | except ImportError: 7 | from tkinter import * 8 | from tkinter.colorchooser import askcolor 9 | 10 | from ..config import * 11 | 12 | """ 13 | Widget that assigns background and Peer IDs a specific colour 14 | """ 15 | 16 | class ColourPicker(Frame): 17 | def __init__(self, master): 18 | self.master = master 19 | self.root=Toplevel(master.root) 20 | self.root.title("Edit Colours") 21 | self.root.attributes('-topmost', 'true') 22 | 23 | self.filename=COLOUR_INFO_FILE 24 | 25 | self.attributes = ["Background", "Console"] + ["Peer {}".format(n) for n in range(1,11)] 26 | 27 | # load in colours 28 | self.colours = self.read() 29 | self.labels = {} 30 | self.selected = IntVar(value=0) 31 | 32 | for i, name in enumerate(self.attributes): 33 | # Make a button to trigger colour picker 34 | lbl = Radiobutton(self.root, text=name, width=10, 35 | indicatoron=0, 36 | variable=self.selected, value=i, 37 | command=lambda: self.get_colour()) 38 | lbl.grid(row=i, column=0) 39 | 40 | # Make a label for showing the colour 41 | lbl = Label(self.root, bg=self.colours[name], width=15) 42 | lbl.grid(row=i, column=1) 43 | self.labels[name] = lbl 44 | 45 | # Make a save changes button 46 | b = Button(self.root, text="Save Changes", command=self.write) 47 | b.grid(row=i+1, column=0, columnspan=2, stick="nsew") 48 | 49 | def get_colour(self): 50 | """ Opens a colour palette dialog """ 51 | attr=self.attributes[self.selected.get()] 52 | rgb, html = askcolor(color=self.colours[attr]) 53 | if rgb != None: 54 | self.colours[attr] = html 55 | self.labels[attr].config(bg=html) 56 | return 57 | 58 | def write(self): 59 | """ Writes to file """ 60 | with open(self.filename, "w") as f: 61 | for attr in self.attributes: 62 | f.write("{}={}\n".format(attr, self.colours[attr])) 63 | self.master.ApplyColours() 64 | return 65 | 66 | def read(self): 67 | """ Reads from file """ 68 | data = {} 69 | data["Background"] = COLOURS["Background"] 70 | data["Console"] = COLOURS["Console"] 71 | for n in range(10): 72 | data["Peer {}".format(n + 1)] = COLOURS["Peers"][n] 73 | return data 74 | -------------------------------------------------------------------------------- /src/interface/conn_info.py: -------------------------------------------------------------------------------- 1 | try: 2 | import Tkinter as Tk 3 | import tkMessageBox 4 | import tkFileDialog 5 | except ImportError: 6 | import tkinter as Tk 7 | from tkinter import messagebox as tkMessageBox 8 | from tkinter import filedialog as tkFileDialog 9 | 10 | from .interface import Window 11 | from ..config import langtitles 12 | 13 | class ConnectionInput: 14 | """ Interface for getting connection info from the user """ 15 | def __init__(self, client, get_info=True, **kwargs): 16 | 17 | self.client = client 18 | self.using_gui_input = get_info 19 | self.options = kwargs 20 | 21 | # If there is all the info, go straight to main interface 22 | 23 | if self.using_gui_input: 24 | 25 | self.root=Window.root 26 | 27 | self.root.title("Troop v{}".format(client.version)) 28 | self.root.protocol("WM_DELETE_WINDOW", self.quit ) 29 | self.root.resizable(False, False) 30 | 31 | # Host 32 | lbl = Tk.Label(self.root, text="Host:") 33 | lbl.grid(row=0, column=0, stick=Tk.W) 34 | self.host=Tk.Entry(self.root) 35 | self.host.insert(0, kwargs.get("host", "localhost")) 36 | self.host.grid(row=0, column=1, sticky=Tk.NSEW) 37 | 38 | # Port 39 | lbl = Tk.Label(self.root, text="Port:") 40 | lbl.grid(row=1, column=0, stick=Tk.W) 41 | self.port=Tk.Entry(self.root) 42 | self.port.insert(0, kwargs.get("port", "57890")) 43 | self.port.grid(row=1, column=1, sticky=Tk.NSEW) 44 | 45 | # Name 46 | lbl = Tk.Label(self.root, text="Name:") 47 | lbl.grid(row=2, column=0, sticky=Tk.W) 48 | self.name=Tk.Entry(self.root) 49 | self.name.grid(row=2, column=1, sticky=Tk.NSEW) 50 | 51 | # Password 52 | lbl = Tk.Label(self.root, text="Password: ") 53 | lbl.grid(row=3, column=0, sticky=Tk.W) 54 | self.password=Tk.Entry(self.root, show="*") 55 | self.password.grid(row=3, column=1, sticky=Tk.NSEW) 56 | 57 | # Interpreter 58 | lbl = Tk.Label(self.root, text="Language: ") 59 | lbl.grid(row=4, column=0, sticky=Tk.W) 60 | self.select_path_option = "Select another program..." 61 | options = list(langtitles.values()) + [self.select_path_option] 62 | self.lang = Tk.StringVar(self.root) 63 | self.lang.set(langtitles.get(kwargs.get('lang', 'foxdot').lower(), 'FoxDot')) 64 | self.drop = Tk.OptionMenu(self.root, self.lang, *list(options), command=self.select_path) 65 | self.drop.config(width=5) 66 | self.drop.grid(row=4, column=1, sticky=Tk.NSEW) 67 | 68 | # Invisible syntax highlighting option 69 | self.syntax_label = Tk.Label(self.root, text="Syntax: ") 70 | options = list(langtitles.values()) 71 | self.syntax = Tk.StringVar(self.root) 72 | self.syntax.set(langtitles.get(kwargs.get('syntax', 'foxdot').lower(), 'FoxDot')) 73 | self.syntax_drop = Tk.OptionMenu(self.root, self.syntax, *options) 74 | self.syntax_drop.config(width=5) 75 | 76 | if "syntax" in self.options: 77 | self.show_syntax_options() 78 | else: 79 | self.hide_syntax_options() 80 | 81 | # Ok button 82 | self.button=Tk.Button(self.root, text='Ok',command=self.store_data) 83 | self.button.grid(row=6, column=0, columnspan=2, sticky=Tk.NSEW) 84 | 85 | self.response = Tk.StringVar() 86 | self.lbl_response=Tk.Label(self.root, textvariable=self.response, fg="Red") 87 | self.lbl_response.grid(row=7, column=0, columnspan=2) 88 | self.lbl_response.grid_remove() 89 | 90 | # Value 91 | self.data = {} 92 | 93 | # Enter shortcut 94 | self.root.bind("", self.store_data) 95 | 96 | def start(self): 97 | if self.using_gui_input: 98 | self.center() 99 | self.mainloop() # calls finish from the OK button 100 | else: 101 | self.finish() 102 | 103 | def mainloop(self): 104 | if self.client.mainloop_started is False: 105 | self.client.mainloop_started = True 106 | try: 107 | self.root.mainloop() 108 | except KeyboardInterrupt: 109 | self.client.kill() 110 | return 111 | 112 | def quit(self): 113 | self.data = {} 114 | return self.root.quit() 115 | 116 | def finish(self): 117 | """ Starts the client connection""" 118 | self.client.setup(**self.options) 119 | return 120 | 121 | def cleanup(self): 122 | """ Removes all the widgets from the root """ 123 | if self.using_gui_input: 124 | for widget in self.root.winfo_children(): 125 | widget.grid_forget() 126 | return 127 | 128 | def select_path(self, lang): 129 | """ If lang is select_path_option, open file dialog and set self.lang to the path """ 130 | if lang == self.select_path_option: 131 | path = tkFileDialog.askopenfilename(initialdir = "/",title = "Select file") 132 | self.lang.set(path) 133 | elif lang == langtitles["none"]: 134 | self.show_syntax_options() 135 | else: 136 | self.hide_syntax_options() 137 | return 138 | 139 | def show_syntax_options(self): 140 | """ Use 'grid' to show options for selecting syntax highlighting """ 141 | self.syntax_drop.grid(row=5, column=1, sticky=Tk.NSEW) 142 | self.syntax_label.grid(row=5, column=0, sticky=Tk.W) 143 | return 144 | 145 | def hide_syntax_options(self): 146 | """ Use 'grid_forget' to hide syntax options """ 147 | self.syntax_drop.grid_forget() 148 | self.syntax_label.grid_forget() 149 | return 150 | 151 | def select_syntax(self, lang): 152 | """ Store the name of the interpreter syntax highlighting to use """ 153 | return 154 | 155 | def store_data(self, event=None): 156 | """ Stores the data in the entry fields then closes the window """ 157 | host = self.host.get() 158 | port = self.port.get() 159 | name = self.name.get() 160 | password = self.password.get() 161 | 162 | # Use correct formatting of lang_name and syntax_name 163 | 164 | lang_name = self.lang.get() 165 | syntax_name = self.syntax.get() 166 | 167 | for short_name, long_name in langtitles.items(): 168 | 169 | if long_name == lang_name: 170 | 171 | lang_name = short_name 172 | 173 | if long_name == syntax_name: 174 | 175 | syntax_name = short_name 176 | 177 | # If we have values for name, host, and port then go to "finish" 178 | 179 | if name.strip() != "" and host.strip() != "" and port.strip() != "": 180 | 181 | self.options.update( 182 | host = host, 183 | port = port, 184 | name = name, 185 | password = password, 186 | lang = lang_name, 187 | syntax = syntax_name 188 | ) 189 | 190 | self.finish() 191 | 192 | return 193 | 194 | def center(self): 195 | """ Centers the popup in the middle of the screen """ 196 | self.root.update_idletasks() 197 | w = self.root.winfo_screenwidth() 198 | h = self.root.winfo_screenheight() 199 | size = tuple(int(_) for _ in self.root.geometry().split('+')[0].split('x')) 200 | x = int(w/2 - size[0]/2) 201 | y = int(h/2 - size[1]/2) 202 | self.root.geometry("+{}+{}".format(x, y)) 203 | self.lbl_response.config(wraplength=size[0]) 204 | self.name.focus() 205 | return 206 | 207 | def print_message(self, message): 208 | """ Displays the response message to the user """ 209 | if self.using_gui_input: 210 | self.response.set(message) 211 | self.lbl_response.grid() 212 | else: 213 | print(message) 214 | return 215 | -------------------------------------------------------------------------------- /src/interface/console.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | from ..config import * 3 | 4 | try: 5 | from Tkinter import * 6 | import tkFont 7 | except ImportError: 8 | from tkinter import * 9 | from tkinter import font as tkFont 10 | 11 | try: 12 | import queue 13 | except ImportError: 14 | import Queue as queue 15 | 16 | from .menu_bar import ConsolePopupMenu 17 | 18 | import re 19 | 20 | re_colour = re.compile(r".*?)\">(?P.*?)(?P.*?)$", re.DOTALL) 21 | 22 | def find_colour(string): 23 | return re_colour.search(string) 24 | 25 | class Console(Text): 26 | def __init__(self, root, **kwargs): 27 | # Inherit 28 | Text.__init__(self, root, **kwargs) 29 | 30 | self.root = root # 31 | 32 | # Set font 33 | 34 | if SYSTEM == MAC_OS: 35 | 36 | fontfamily = "Monaco" 37 | 38 | elif SYSTEM == WINDOWS: 39 | 40 | fontfamily = "Consolas" 41 | 42 | else: 43 | 44 | fontfamily = "Courier New" 45 | 46 | self.font = tkFont.Font(family=fontfamily, size=12, name="ConsoleFont") 47 | self.font.configure(**tkFont.nametofont("ConsoleFont").configure()) 48 | 49 | self.configure(font="ConsoleFont") 50 | 51 | # Queue waits for messages to be added to the console 52 | self.queue = queue.Queue() 53 | 54 | # By default, don't allow keypresses 55 | self.bind("", self.null) 56 | 57 | self.bind("" if SYSTEM==MAC_OS else "", self.mouse_press_right) 58 | 59 | CtrlKey = "Command" if SYSTEM == MAC_OS else "Control" 60 | 61 | self.bind("<{}-c>".format(CtrlKey), self.copy) 62 | self.bind("<{}-a>".format(CtrlKey), self.select_all) 63 | 64 | self.popup = ConsolePopupMenu(self) 65 | 66 | self.colours = {} 67 | 68 | self.update_me() 69 | 70 | def null(self, event): 71 | return "break" 72 | 73 | def update_me(self): 74 | try: 75 | while True: 76 | 77 | string = self.queue.get_nowait().rstrip() # Remove trailing whitespace 78 | 79 | match = find_colour(string) 80 | 81 | if match: 82 | 83 | self.mark_set(INSERT, END) 84 | 85 | colour = match.group("colour") 86 | c_text = match.group("c_text") 87 | string = match.group("string") 88 | 89 | start = self.index(INSERT) 90 | self.insert(INSERT, c_text) 91 | end = self.index(INSERT) 92 | 93 | # Add tag 94 | 95 | if colour not in self.colours: 96 | 97 | self.colours[colour] = "tag_%s" % colour 98 | 99 | self.tag_config(self.colours[colour], foreground=colour) 100 | 101 | self.tag_add(self.colours[colour], start, end) 102 | 103 | self.insert(END, string + "\n") 104 | 105 | self.see(END) 106 | 107 | self.update_idletasks() 108 | 109 | except queue.Empty: 110 | 111 | pass 112 | 113 | self.after(100, self.update_me) 114 | 115 | def write(self, string): 116 | """ Adds a string to the console queue """ 117 | if string != "\n": 118 | self.queue.put(string) 119 | return 120 | 121 | def flush(self, *args, **kwargs): 122 | """ Override """ 123 | return 124 | 125 | def has_selection(self): 126 | """ Returns True if the SEL tag is found in the Console widget """ 127 | return bool(self.tag_ranges(SEL)) 128 | 129 | def get_selection(self): 130 | return self.get(SEL_FIRST, SEL_LAST) 131 | 132 | def mouse_press_right(self, event): 133 | """ Displays popup menu""" 134 | self.popup.show(event) 135 | return "break" 136 | 137 | def copy(self, event=None): 138 | if self.has_selection(): 139 | self.root.clipboard_clear() 140 | self.root.clipboard_append(self.get_selection()) 141 | return "break" 142 | 143 | def select_all(self, event=None): 144 | self.tag_add(SEL,"1.0", END) 145 | return "break" 146 | -------------------------------------------------------------------------------- /src/interface/constraints.py: -------------------------------------------------------------------------------- 1 | from functools import partial 2 | 3 | try: 4 | from Tkinter import BooleanVar 5 | except ImportError: 6 | from tkinter import BooleanVar 7 | 8 | def get_count(text, func=sum): 9 | """ Performs `func` (default is `sum`) on the list of all peer char totals """ 10 | return float(func([peer.count for peer in text.peers.values()])) 11 | 12 | class TextConstraint(object): 13 | def __init__(self, text): 14 | self.text = text 15 | 16 | self.constraints = { 17 | 0 : anarchy(), 18 | 1 : communism(), 19 | 2 : democracy(), 20 | 3 : dictatorship() 21 | } 22 | 23 | self.leader = None 24 | self.constraint_id = None 25 | self.rule = lambda *args: True 26 | 27 | self.using = { n: BooleanVar() for n in self.constraints } 28 | 29 | self.set_constraint(0) 30 | 31 | def __call__(self): 32 | """ If there are multuple users connected, start to apply rules""" 33 | return self.rule(self.text) 34 | 35 | def __eq__(self, constraint_id): 36 | return self.constraint_id == constraint_id 37 | 38 | def __ne__(self, constraint_id): 39 | return self.constraint_id != constraint_id 40 | 41 | def names(self): 42 | return [str(c) for c in self.constraints.keys()] 43 | 44 | def items(self): 45 | return self.constraints.items() 46 | 47 | def get_name(self, n): 48 | return self.constraints[n] 49 | 50 | def get_id(self, name): 51 | for n, constraint in self.constraints.items(): 52 | if name == str(constraint): 53 | return n 54 | else: 55 | raise KeyError("Key {!r} not found".format(n)) 56 | 57 | def set_constraint(self, constraint_id, peer_id=None): 58 | """ """ 59 | self.constraint_id = constraint_id 60 | 61 | self.rule = self.constraints[constraint_id] 62 | 63 | if peer_id is not None and peer_id >= 0: 64 | self.leader = self.text.peers[peer_id] 65 | else: 66 | self.leader = None 67 | 68 | for n in self.constraints: 69 | if n == constraint_id: 70 | self.using[n].set(True) 71 | else: 72 | self.using[n].set(False) 73 | return 74 | 75 | # Constraints 76 | 77 | class __constraint(object): 78 | def __init__(self): 79 | pass 80 | 81 | def __repr__(self): 82 | return self.__class__.__name__ 83 | 84 | def __call__(self, text, *args): 85 | if len(text.peers) > 1: 86 | return self.rule(text, *args) 87 | return True 88 | 89 | class anarchy(__constraint): 90 | """ No rule (anarchy) """ 91 | def rule(self, *args, **kwargs): 92 | return True 93 | 94 | class democracy(__constraint): 95 | """ Users can not enter more than 1/n-1 of the text i.e. if 3 users are connected, 96 | a user cannot enter over 1/2 of the total number of characters """ 97 | def rule(self, text, *args): 98 | if text.marker.count > 10: 99 | max_chars = get_count(text) / len(text.peers) 100 | if text.marker.count > max_chars: 101 | return False 102 | return True 103 | 104 | class communism(__constraint): 105 | """ Users can only add a maximum of 1 character more than anyone else. 106 | i.e. everyone has to be the same number of characters """ 107 | def rule(self, text, *args): 108 | return text.marker.count <= get_count(text, min) + 1 109 | 110 | class dictatorship(__constraint): 111 | """ One user (master) can use any number of the characters but other users 112 | can only use 25/(n-1) % """ 113 | def rule(self, text, peer, leader, *args): 114 | if peer != text.peers[leader]: 115 | return peer.count < (get_count(text) * 0.25) / (len(text.peers) - 1) 116 | -------------------------------------------------------------------------------- /src/interface/drag.py: -------------------------------------------------------------------------------- 1 | try: 2 | from Tkinter import Frame 3 | except ImportError: 4 | from tkinter import Frame 5 | 6 | class Dragbar(Frame): 7 | cursor_style="sb_v_double_arrow" 8 | def __init__(self, master, *args, **kwargs): 9 | 10 | self.app = master 11 | self.root = master.root 12 | 13 | kwargs["cursor"]=self.cursor_style 14 | 15 | Frame.__init__( self, self.root, **kwargs ) 16 | 17 | self.mouse_down = False 18 | 19 | self.bind("", self.drag_mouseclick) 20 | self.bind("", self.drag_mouserelease) 21 | self.bind("", self.drag_mousedrag) 22 | 23 | def drag_mouseclick(self, event): 24 | """ Allows the user to resize the console height """ 25 | self.mouse_down = True 26 | self.root.grid_propagate(False) 27 | return 28 | 29 | def drag_mouserelease(self, event): 30 | self.mouse_down = False 31 | self.app.text.focus_set() 32 | return 33 | 34 | def drag_mousedrag(self, event): 35 | if self.mouse_down: 36 | 37 | line_height = self.app.text.char_h 38 | 39 | text_height = ( self.app.text.winfo_height() / line_height ) # In lines 40 | 41 | widget_y = self.app.console.winfo_rooty() # Location of the console 42 | 43 | new_height = ( self.app.console.winfo_height() + (widget_y - event.y_root) ) 44 | 45 | # Update heights of console / graphs 46 | 47 | self.app.graphs.config(height = int(new_height)) 48 | 49 | self.app.console.config(height = int(max(2, new_height / line_height))) 50 | 51 | return "break" 52 | 53 | class ConsoleDragbar(Dragbar): 54 | cursor_style="sb_h_double_arrow" 55 | def drag_mousedrag(self, event): 56 | """ Resize the canvas """ 57 | if self.mouse_down: 58 | 59 | widget_x = self.app.graphs.winfo_rootx() # Location of the graphs 60 | 61 | new_width = self.app.graphs.winfo_width() + (widget_x - event.x_root) 62 | 63 | self.app.graphs.config(width = int(new_width)) 64 | 65 | console_width = (self.app.root.winfo_width() - new_width) / self.app.text.char_w 66 | 67 | self.app.console.config(width = int(console_width)) 68 | 69 | return "break" 70 | -------------------------------------------------------------------------------- /src/interface/img/icon.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qirky/Troop/529c5eb14e456f683e6d23fd4adcddc8446aa115/src/interface/img/icon.gif -------------------------------------------------------------------------------- /src/interface/img/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qirky/Troop/529c5eb14e456f683e6d23fd4adcddc8446aa115/src/interface/img/icon.ico -------------------------------------------------------------------------------- /src/interface/line_numbers.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | try: 3 | import Tkinter as Tk 4 | except: 5 | import tkinter as Tk 6 | 7 | from ..config import * 8 | 9 | class LineNumbers(Tk.Canvas): 10 | def __init__(self, master, *args, **kwargs): 11 | Tk.Canvas.__init__(self, *args, **kwargs) 12 | self.textwidget = master 13 | self.redraw() 14 | 15 | def redraw(self, *args): 16 | '''Redraws the line numbers at 30 fps''' 17 | self.delete("all") 18 | 19 | i = self.textwidget.index("@0,0") 20 | 21 | self.config(width=self.textwidget.font.measure(str(max(self.textwidget.get_num_lines(), 10))) + 20) 22 | 23 | w = self.winfo_width() - 5 # Width 24 | 25 | while True: 26 | 27 | dline=self.textwidget.dlineinfo(i) 28 | 29 | if dline is None: 30 | break 31 | 32 | y = dline[1] 33 | h = dline[3] 34 | 35 | linenum = int(str(i).split(".")[0]) 36 | 37 | # If the linenum is the currently edited linenumber, highlight 38 | 39 | if self.textwidget.marker is not None: 40 | 41 | if linenum == self.textwidget.marker.row: 42 | 43 | x1, y1 = 0, y 44 | x2, y2 = w, y + h 45 | 46 | self.create_rectangle(x1, y1, x2, y2, fill="gray30", outline="gray30") 47 | 48 | self.create_text(w - 4, y, anchor="ne", 49 | justify=Tk.RIGHT, 50 | text=linenum, 51 | font="Font", 52 | fill="#d3d3d3") 53 | 54 | 55 | i = self.textwidget.index("{}+1line".format(i)) 56 | 57 | # Draw a line 58 | 59 | self.create_line(w, 0, w, self.winfo_height(), fill="gray50") 60 | 61 | # Draw peer_lables 62 | 63 | if self.textwidget.is_refreshing is False: 64 | 65 | self.textwidget.refresh_peer_labels() 66 | 67 | self.after(30, self.redraw) 68 | -------------------------------------------------------------------------------- /src/interface/menu_bar.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | try: 4 | from Tkinter import Menu, DISABLED, NORMAL, StringVar 5 | import tkFileDialog 6 | import tkMessageBox 7 | except ImportError: 8 | from tkinter import Menu, DISABLED, NORMAL, StringVar 9 | from tkinter import filedialog as tkFileDialog 10 | from tkinter import messagebox as tkMessageBox 11 | 12 | from functools import partial 13 | 14 | from ..config import * 15 | from ..message import * 16 | 17 | class MenuBar(Menu): 18 | def __init__(self, master, visible=True): 19 | 20 | self.root = master 21 | 22 | Menu.__init__(self, master.root) 23 | 24 | # File menu 25 | 26 | filemenu = Menu(self, tearoff=0) 27 | filemenu.add_command(label="New Document", command=self.new_file, accelerator="Ctrl+N") 28 | filemenu.add_command(label="Save", command=self.save_file, accelerator="Ctrl+S") 29 | filemenu.add_command(label="Open", command=self.open_file, accelerator="Ctrl+O") 30 | filemenu.add_separator() 31 | filemenu.add_command(label="Exit", command=self.root.client.kill) 32 | self.add_cascade(label="File", menu=filemenu) 33 | 34 | # Edit menu 35 | 36 | editmenu = Menu(self, tearoff=0) 37 | editmenu.add_command(label="Cut", command=self.root.cut, accelerator="Ctrl+X") 38 | editmenu.add_command(label="Copy", command=self.root.copy, accelerator="Ctrl+C") 39 | editmenu.add_command(label="Paste", command=self.root.paste, accelerator="Ctrl+V") 40 | editmenu.add_command(label="Select All", command=self.root.select_all, accelerator="Ctrl+/") 41 | editmenu.add_separator() 42 | editmenu.add_command(label="Increase Font Size", command=self.root.increase_font_size, accelerator="Ctrl+=") 43 | editmenu.add_command(label="Decrease Font Size", command=self.root.decrease_font_size, accelerator="Ctrl+-") 44 | editmenu.add_separator() 45 | editmenu.add_command(label="Toggle Menu", command=self.toggle, accelerator="Ctrl+M") 46 | editmenu.add_separator() 47 | editmenu.add_command(label="Edit Colours", command=self.root.edit_colours) 48 | editmenu.add_checkbutton(label="Toggle Window Transparency", command=self.root.toggle_transparency, variable=self.root.transparent) 49 | self.add_cascade(label="Edit", menu=editmenu) 50 | 51 | # Code menu 52 | 53 | codemenu = Menu(self, tearoff=0) 54 | codemenu.add_command(label="Evaluate Code", command=self.root.evaluate, accelerator="Ctrl+Return") 55 | codemenu.add_command(label="Evaluate Single Line", command=self.root.single_line_evaluate, accelerator="Alt+Return") 56 | codemenu.add_command(label="Stop All Sound", command=self.root.stop_sound, accelerator="Ctrl+.") 57 | codemenu.add_command(label="Font colour merge", command=self.root.text.merge.start) 58 | 59 | # Constraints 60 | 61 | constmenu = Menu(self, tearoff=0) 62 | 63 | for i, name in self.root.text.constraint.items(): 64 | 65 | constmenu.add_checkbutton(label=str(name).title(), 66 | command = partial(self.root.set_constraint, i), 67 | variable = self.root.text.constraint.using[i]) 68 | 69 | codemenu.add_cascade(label="Set Constraint", menu=constmenu) 70 | 71 | codemenu.add_separator() 72 | 73 | # Allow choice of interpreter 74 | 75 | langmenu = Menu(self, tearoff=0) 76 | 77 | for name, interpreter in langnames.items(): 78 | 79 | langmenu.add_checkbutton(label=langtitles[name], 80 | command = partial(self.root.set_interpreter, interpreter), 81 | variable = self.root.interpreters[name]) 82 | 83 | codemenu.add_cascade(label="Choose Mode", menu=langmenu) 84 | 85 | self.add_cascade(label="Code", menu=codemenu) 86 | 87 | # Help 88 | 89 | helpmenu = Menu(self, tearoff=0) 90 | helpmenu.add_command(label="Documentation", command=self.root.OpenGitHub) 91 | self.add_cascade(label="Help", menu=helpmenu) 92 | 93 | # Add to root 94 | 95 | self.visible = visible 96 | 97 | if self.visible: 98 | 99 | master.root.config(menu=self) 100 | 101 | def toggle(self, *args, **kwargs): 102 | self.root.root.config(menu=self if not self.visible else 0) 103 | self.visible = not self.visible 104 | return "break" 105 | 106 | def save_file(self, event=None): 107 | """ Opens a save file dialog """ 108 | lang_files = ("{} file".format(repr(self.root.lang)), self.root.lang.filetype ) 109 | all_files = ("All files", "*.*") 110 | fn = tkFileDialog.asksaveasfilename(title="Save as...", filetypes=(lang_files, all_files), defaultextension=lang_files[1]) 111 | if len(fn): 112 | with open(fn, "w") as f: 113 | f.write(self.root.text.read()) 114 | print("Saved: {}".format(fn)) 115 | return 116 | 117 | def new_file(self, event=None): 118 | """ Asks if the user wants to clear the screen and does so if yes """ 119 | return 120 | 121 | def open_file(self, event=None): 122 | """ Opens a fileopen dialog then sets the text box contents to the contents of the file """ 123 | lang_files = ("{} files".format(repr(self.root.lang)), self.root.lang.filetype ) 124 | all_files = ("All files", "*.*") 125 | fn = tkFileDialog.askopenfilename(title="Open file", filetypes=(lang_files, all_files)) 126 | 127 | if len(fn): 128 | 129 | with open(fn) as f: 130 | contents = f.read() 131 | 132 | self.root.apply_operation( self.root.get_set_all_operation(contents) ) 133 | 134 | return 135 | 136 | 137 | class PopupMenu(Menu): 138 | def __init__(self, master): 139 | self.root = master 140 | Menu.__init__(self, master.root, tearoff=0, postcommand=self.update) 141 | self.add_command(label="Undo", command=self.root.undo, accelerator="Ctrl+Z") 142 | self.add_command(label="Redo", command=self.root.redo, accelerator="Ctrl+Y") 143 | self.add_separator() 144 | self.add_command(label="Copy", command=self.root.copy, accelerator="Ctrl+C") 145 | self.add_command(label="Cut", command=self.root.cut, accelerator="Ctrl+X") 146 | self.add_command(label="Paste", command=self.root.paste, accelerator="Ctrl+V") 147 | self.add_separator() 148 | self.add_command(label="Select All", command=self.root.select_all, accelerator="Ctrl+A") 149 | 150 | self.bind("", self.hide) # hide when clicked off 151 | 152 | def is_active(self): 153 | return self.active 154 | 155 | def show(self, event): 156 | """ Displays the popup menu """ 157 | self.focus_set() 158 | return self.post(event.x_root, event.y_root) 159 | 160 | def hide(self, event=None): 161 | """ Removes the display of the popup """ 162 | return self.unpost() 163 | 164 | def update(self): 165 | """ Sets the state for variables""" 166 | 167 | self.entryconfig("Undo", state=NORMAL if len(self.root.text.undo_stack) > 0 else DISABLED) 168 | self.entryconfig("Redo", state=NORMAL if len(self.root.text.redo_stack) > 0 else DISABLED) 169 | 170 | select = self.root.text.marker.has_selection() 171 | self.entryconfig("Copy", state=NORMAL if select else DISABLED) 172 | self.entryconfig("Cut", state=NORMAL if select else DISABLED) 173 | 174 | return 175 | 176 | class ConsolePopupMenu(PopupMenu): 177 | def __init__(self, master): 178 | self.root = master # console widget 179 | disable = lambda *e: None 180 | Menu.__init__(self, master.root, tearoff=0, postcommand=self.update) 181 | self.add_command(label="Undo", command=disable, accelerator="Ctrl+Z", state=DISABLED) 182 | self.add_command(label="Redo", command=disable, accelerator="Ctrl+Y", state=DISABLED) 183 | self.add_separator() 184 | self.add_command(label="Copy", command=self.root.copy, accelerator="Ctrl+C") 185 | self.add_command(label="Cut", command=disable, accelerator="Ctrl+X", state=DISABLED) 186 | self.add_command(label="Paste", command=disable, accelerator="Ctrl+V", state=DISABLED) 187 | self.add_separator() 188 | self.add_command(label="Select All", command=self.root.select_all, accelerator="Ctrl+A") 189 | 190 | self.bind("", self.hide) # hide when clicked off 191 | 192 | def update(self): 193 | self.entryconfig("Copy", state=NORMAL if self.root.has_selection() else DISABLED) 194 | return 195 | -------------------------------------------------------------------------------- /src/interface/mouse.py: -------------------------------------------------------------------------------- 1 | class Mouse: 2 | def __init__(self, widget): 3 | self.root = widget 4 | self.is_pressed = False 5 | self.index = 0 6 | self.anchor = None 7 | self.tcl_index = "1.0" 8 | 9 | def update(self, event): 10 | self.tcl_index = self.root.text.index("@{},{}".format( event.x, event.y )) 11 | self.index = self.root.text.tcl_index_to_number(self.tcl_index) 12 | return self.index 13 | 14 | def click(self, event): 15 | """ Monitors location and press info about last mouse click based on tcl event""" 16 | self.is_pressed = True 17 | self.anchor = self.update(event) 18 | return self.index 19 | 20 | def release(self, event): 21 | self.is_pressed = False 22 | self.anchor = None 23 | self.update(event) 24 | return self.index 25 | 26 | def get_index(self): 27 | """ Returns the index (single number) """ 28 | return self.index -------------------------------------------------------------------------------- /src/interface/peer.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | try: 4 | import Tkinter as Tk 5 | except ImportError: 6 | import tkinter as Tk 7 | 8 | from ..config import * 9 | from ..utils import get_peer_char 10 | import colorsys 11 | 12 | def rgb2hex(*rgb): 13 | r = int(max(0, min(rgb[0], 255))) 14 | g = int(max(0, min(rgb[1], 255))) 15 | b = int(max(0, min(rgb[2], 255))) 16 | return "#{0:02x}{1:02x}{2:02x}".format(r, g, b) 17 | 18 | def hex2rgb(value): 19 | value = value.lstrip('#') 20 | return tuple(int(value[i:i+2], 16) for i in range(0,6,2) ) 21 | 22 | def avg_colour(col1, col2, weight=0.5): 23 | rgb1 = hex2rgb(col1) 24 | rgb2 = hex2rgb(col2) 25 | avg_rgb = tuple(rgb1[i] * (1-weight) + rgb2[i] * weight for i in range(3)) 26 | return rgb2hex(*avg_rgb) 27 | 28 | def int2rgb(i): 29 | h = (((i + 2) * 70) % 255) / 255.0 30 | return [int(n * 255) for n in colorsys.hsv_to_rgb(h, 1, 1)] 31 | 32 | def PeerFormatting(index): 33 | i = index % len(COLOURS["Peers"]) 34 | c = COLOURS["Peers"][i] 35 | return c, "Black" 36 | 37 | class PeerColourTest: 38 | def __init__(self): 39 | self.root=Tk.Tk() 40 | num = 20 41 | h = 30 42 | w = 100 43 | self.canvas =Tk.Canvas(self.root, width=300, height=num*h) 44 | self.canvas.pack() 45 | m = 0 46 | for n in range(num): 47 | rgb = int2rgb(n) 48 | m = 0 49 | self.canvas.create_rectangle(m * w, n * h, (m + 1) * w, (n + 1) * h, fill=rgb2hex(*rgb)) 50 | m = 1 51 | rgb = tuple(n - 30 for n in rgb) 52 | self.canvas.create_rectangle(m * w, n * h, (m + 1) * w, (n + 1) * h, fill=rgb2hex(*rgb)) 53 | m = 2 54 | self.canvas.create_rectangle(m * w, n * h, (m + 1) * w, (n + 1) * h, fill="Black") 55 | self.root.mainloop() 56 | 57 | class Highlight: 58 | def __init__(self, text, tag): 59 | self.text = text 60 | self.tag = tag 61 | self.deactivate() 62 | 63 | def __repr__(self): 64 | return "".format(self.start, self.end) 65 | 66 | def __len__(self): 67 | return (self.end - self.start) 68 | 69 | def set(self, start, end): 70 | """ Set a relative index """ 71 | self.anchor = start # point of origin (could be end or start in terms of length) 72 | self.start = min(start, end) 73 | self.end = max(start, end) 74 | self.active = self.start != self.end 75 | 76 | def add(self, start, end): 77 | """ Add a Tk index """ 78 | self.multiple.append((start, end)) 79 | return 80 | 81 | def update(self, old, new): 82 | if new > self.anchor: 83 | self.start = self.anchor 84 | self.end = new 85 | elif new < self.anchor: 86 | self.start = new 87 | self.end = self.anchor 88 | else: 89 | self.hide() 90 | return 91 | 92 | def shift(self, loc, amount): 93 | if self.active: 94 | if loc < self.start: 95 | self.anchor += amount 96 | self.start += amount 97 | self.end += amount 98 | elif loc < self.end: 99 | self.end += amount 100 | return 101 | 102 | def is_active(self): 103 | return self.active 104 | 105 | def deactivate(self): 106 | self.start = 0 107 | self.end = 0 108 | self.anchor = 0 109 | self.active = False 110 | self.multiple = [] 111 | 112 | def show(self): 113 | """ Adds the highlight tag to the text """ 114 | if len(self.multiple) > 0: 115 | for start, end in self.multiple: 116 | self.text.tag_add(self.tag, start, end) 117 | else: 118 | self.text.tag_add(self.tag, self.text.number_index_to_tcl(self.start), self.text.number_index_to_tcl(self.end)) 119 | self.active = True 120 | return 121 | 122 | def hide(self): 123 | """ Removes the highlight tag from the text """ 124 | self.clear() 125 | self.deactivate() 126 | return 127 | 128 | def remove(self, start, end): 129 | """ Removes a portion of the highlight """ 130 | if self.active: 131 | start, end = sorted((start, end)) 132 | size = end - start 133 | # If the area falls outside of this highlight, do nothing 134 | if start > self.end or end < self.start: 135 | return 136 | elif start < self.start and end < self.end: 137 | self.end = self.end + (end - self.start) - size 138 | self.start = start 139 | elif start > self.start and end > self.end: 140 | self.end = start 141 | elif start > self.start and end < self.end: 142 | self.end = self.end - size 143 | return 144 | 145 | def clear(self): 146 | """ Removes the Tk text tag from the document """ 147 | self.text.tag_remove(self.tag, "1.0", Tk.END) 148 | return 149 | 150 | class Peer: 151 | """ Class representing the connected performers within the Tk Widget 152 | """ 153 | def __init__(self, id_num, name, is_dummy, widget, row=1, col=0): 154 | self.id = id_num 155 | self.char = get_peer_char(self.id) 156 | self.root = widget # Text 157 | self.root_parent = widget.root 158 | 159 | self.is_dummy = is_dummy # using a language or not 160 | 161 | self.name = Tk.StringVar() 162 | self.name.set(name) 163 | 164 | self.update_colours() 165 | 166 | self.label = Tk.Label(self.root, 167 | textvariable=self.name, 168 | bg=self.bg, 169 | fg=self.fg, 170 | font="Font") 171 | 172 | self.raised = False 173 | 174 | self.insert = Tk.Label(self.root, 175 | bg=self.bg, 176 | fg=self.fg, 177 | bd=0, 178 | height=2, 179 | text="", font="Font" ) 180 | 181 | self.text_tag = self.get_text_tag(self.id) 182 | self.code_tag = self.get_code_tag(self.id) 183 | self.sel_tag = self.get_select_tag(self.id) 184 | self.str_tag = self.get_string_tag(self.id) 185 | self.mark = self.get_mark_tag(self.id) 186 | self.bbox = None 187 | 188 | # For refreshing the text 189 | self.hl_eval = Highlight(self.root, self.code_tag) 190 | self.hl_select = Highlight(self.root, self.sel_tag) 191 | 192 | self.root.peer_tags.append(self.text_tag) 193 | 194 | # Stat graph 195 | 196 | self.count = 0 197 | self.graph = None 198 | 199 | self.configure_tags() 200 | 201 | # Tracks a peer's selection amount and location 202 | self.row = row 203 | self.col = col 204 | self.index_num = 0 205 | self.sel_start = "0.0" 206 | self.sel_end = "0.0" 207 | 208 | self.visible = True 209 | self.connected = True 210 | 211 | # self.move(1,0) # create the peer 212 | 213 | def __str__(self): 214 | return str(self.name.get()) 215 | 216 | @staticmethod 217 | def get_text_tag(p_id): 218 | return "text_{}".format(p_id) 219 | 220 | @staticmethod 221 | def get_code_tag(p_id): 222 | return "code_{}".format(p_id) 223 | 224 | @staticmethod 225 | def get_select_tag(p_id): 226 | return "sel_{}".format(p_id) 227 | 228 | @staticmethod 229 | def get_string_tag(p_id): 230 | return "str_{}".format(p_id) 231 | 232 | @staticmethod 233 | def get_mark_tag(p_id): 234 | return "mark_{}".format(p_id) 235 | 236 | def get_peer_formatting(self, index): 237 | 238 | fg, bg = PeerFormatting(index) 239 | 240 | if self.root.merge.colour is not None: 241 | 242 | w = self.root.merge.get_weight() 243 | 244 | fg = avg_colour(fg, self.root.merge.colour, w) 245 | 246 | return fg, bg 247 | 248 | def update_colours(self): 249 | """ Sets the foreground / background colours based on ID """ 250 | self.bg, self.fg = self.get_peer_formatting(self.id) 251 | return self.bg, self.fg 252 | 253 | def configure_tags(self): 254 | doing = True 255 | while doing: 256 | try: 257 | # Text tags 258 | self.root.tag_config(self.text_tag, foreground=self.bg) 259 | self.root.tag_config(self.str_tag, foreground=self.fg) 260 | self.root.tag_config(self.code_tag, background=self.bg, foreground=self.fg) 261 | self.root.tag_config(self.sel_tag, background=self.bg, foreground=self.fg) 262 | # Label 263 | self.label.config(bg=self.bg, fg=self.fg) 264 | self.insert.config(bg=self.bg, fg=self.fg) 265 | doing = False 266 | except TclError: 267 | pass 268 | return 269 | 270 | def shift(self, amount, *args, **kwargs): 271 | """ Updates the peer's location relative to its current location by calling `move` """ 272 | return self.move(self.index_num + amount, *args, **kwargs) 273 | 274 | def select_shift(self, loc, amount): 275 | return self.hl_select.shift(loc, amount) 276 | 277 | def select_remove(self, start, end): 278 | """ Removes an area from the select highlight """ 279 | return self.hl_select.remove(start, end) 280 | 281 | def find_overlapping_peers(self): 282 | """ Returns True if this peer overlaps another peer's label """ 283 | 284 | for peer in self.root.peers.values(): 285 | 286 | # If the indices are in overlapping position, on the same row, and the other peer is not already raised 287 | 288 | if peer != self and peer.visible: 289 | 290 | peer_index = peer.get_index_num() 291 | this_index = self.get_index_num() 292 | 293 | if (peer_index >= this_index) and (peer_index - this_index < len(str(self))): 294 | 295 | if not peer.raised and self.is_on_same_row(peer): 296 | 297 | self.raised = True 298 | 299 | break 300 | 301 | else: 302 | 303 | self.raised = False 304 | 305 | return self.raised 306 | 307 | def is_on_same_row(self, other): 308 | """ Returns true if this peer and other peer have the first same value for their tcl index """ 309 | return self.get_row() == other.get_row() 310 | 311 | def move(self, loc, raised = False, local_operation = False): 312 | """ Updates the location of the Peer's label """ 313 | 314 | if self.visible is False: 315 | 316 | return 317 | 318 | try: 319 | 320 | document_length = len(self.root.read()) 321 | 322 | # Make sure the location is valid 323 | 324 | if loc < 0: 325 | 326 | self.index_num = 0 327 | 328 | elif loc > document_length: 329 | 330 | self.index_num = document_length 331 | 332 | else: 333 | 334 | self.index_num = loc 335 | 336 | # Work with tcl indexing e.g. "1.0" 337 | 338 | index = self.root.number_index_to_tcl(loc) 339 | 340 | row, col = [int(val) for val in index.split(".")] 341 | 342 | self.row = row 343 | self.col = col 344 | 345 | index = "{}.{}".format(self.row, self.col) 346 | 347 | # Update the Tk text tag -- currently not used 348 | 349 | self.root.mark_set(self.mark, index) 350 | 351 | except Tk.TclError as e: 352 | 353 | print(e) 354 | 355 | return self.index_num 356 | 357 | def redraw(self): 358 | """ Redraws the peer label """ 359 | 360 | if self.visible is False: 361 | 362 | return 363 | 364 | self.bbox = self.root.bbox(self.get_tcl_index()) 365 | 366 | if self.bbox is not None: 367 | 368 | x, y, width, height = self.bbox 369 | 370 | self.x_val = x - 2 371 | 372 | # Label can go on top of the cursor 373 | 374 | raised = self.find_overlapping_peers() 375 | 376 | if raised: 377 | 378 | self.y_val = (y - height, y - height) 379 | 380 | else: 381 | 382 | self.y_val = (y + height, y) 383 | 384 | else: 385 | 386 | # Move out of view if not needed 387 | 388 | self.x_val = -100 389 | self.y_val = (-100, -100) 390 | 391 | self.label.place(x=self.x_val, y=self.y_val[0], anchor="nw") 392 | self.insert.place(x=self.x_val, y=self.y_val[1], anchor="nw") 393 | 394 | return 395 | 396 | def see(self): 397 | """ Use text.see to see this peer then redraw -- unused?""" 398 | self.root.see(self.mark) 399 | self.redraw() 400 | return 401 | 402 | def select(self, start, end): 403 | """ Updates the selected text area for a peer """ 404 | 405 | if self.hl_select.active: 406 | 407 | if (start == end == 0) and (self.hl_select.start != 0): # start and end of 0 is a de-select 408 | 409 | self.hl_select.hide() 410 | 411 | else: 412 | 413 | self.hl_select.update(start, end) 414 | 415 | else: 416 | 417 | self.hl_select.set(start, end) 418 | 419 | return 420 | 421 | def select_set(self, start, end): 422 | """ Override a selection area instead of incrementing """ 423 | 424 | if start == end == 0: # start and end of 0 is a de-select 425 | 426 | self.hl_select.hide() 427 | 428 | self.hl_select.set(start, end) 429 | 430 | return 431 | 432 | def select_start(self): 433 | """ Returns the index of the start of the selection """ 434 | return self.hl_select.start 435 | 436 | def select_end(self): 437 | """ Returns the index of the end of the selection """ 438 | return self.hl_select.end 439 | 440 | def selection_size(self): 441 | return len(self.hl_select) 442 | 443 | def select_overlap(self, other): 444 | """ Returns True if this peer and other have selected areas that overlap """ 445 | a1, b1 = self.select_start(), self.select_end() 446 | a2, b2 = other.select_start(), other.select_end() 447 | return (a1 < a2 < b1) or (a1 < b2 < b1) 448 | 449 | def select_contains(self, index): 450 | """ Returns True if the index is between the start and end of this peer's selection """ 451 | return self.select_start() < index < self.select_end() 452 | 453 | def de_select(self): 454 | """ Remove (not delete) the selection from the text """ 455 | if self.hl_select.active: 456 | self.hl_select.hide() 457 | return True 458 | else: 459 | return False 460 | 461 | def remove(self): 462 | """ Removes the peer from sight, but stays in the address book in case a client reconnects """ 463 | self.connected = False 464 | self.hl_select.hide() 465 | self.hide() 466 | return 467 | 468 | def reconnect(self, name, is_dummy=False): 469 | """ Un-hides a peer and updates the name when a client reconnects """ 470 | self.connected = True 471 | self.visible = True 472 | self.is_dummy = is_dummy 473 | self.name.set(name) 474 | return 475 | 476 | def hide(self): 477 | """ Moves a label out of view """ 478 | self.x_val = -100 479 | self.y_val = (-100, -100) 480 | self.label.place(x=self.x_val, y=self.y_val[0], anchor="nw") 481 | self.insert.place(x=self.x_val, y=self.y_val[1], anchor="nw") 482 | self.index_num = -1 483 | self.visible = False 484 | return 485 | 486 | def has_selection(self): 487 | """ Returns True if this peer is selecting any text """ 488 | return self.hl_select.is_active() 489 | 490 | def __highlight_select(self): 491 | """ Adds background highlighting to text being selected by this peer """ 492 | self.hl_select.clear() 493 | if self.hl_select.start != self.hl_select.end: 494 | self.hl_select.show() 495 | return 496 | 497 | 498 | def highlight(self, start_line, end_line): 499 | """ Highlights (and schedules de-highlight) of block of text. Returns contents 500 | as a string """ 501 | 502 | code = [] 503 | 504 | if start_line == end_line: 505 | end_line += 1 506 | 507 | for line in range(start_line, end_line): 508 | 509 | start = "%d.0" % line 510 | end = "%d.end" % line 511 | 512 | # Highlight text only to last character, not whole line 513 | 514 | self.hl_eval.add(start, end) 515 | 516 | code.append(self.root.get(start, end)) 517 | 518 | self.__highlight_block() 519 | 520 | # Unhighlight the line of text 521 | 522 | self.root.master.after(200, self.__unhighlight_block) 523 | 524 | return "\n".join(code) 525 | 526 | def __highlight_block(self): 527 | """ Adds background highlighting for code being evaluated""" 528 | self.hl_eval.show() 529 | return 530 | 531 | def __unhighlight_block(self): 532 | """ Removes highlight formatting from evaluated text """ 533 | self.hl_eval.hide() 534 | return 535 | 536 | def refresh_highlight(self): 537 | """ If the text is refreshed while code is being evaluated, re-apply it""" 538 | if self.hl_eval.active: 539 | self.__highlight_block() 540 | if self.hl_select.active: 541 | self.__highlight_select() 542 | return 543 | 544 | def refresh(self): 545 | """ Don't move the marker but redraw it """ 546 | return self.shift(0) 547 | 548 | def get_tcl_index(self): 549 | """ Returns the index number as a Tkinter index e.g. "1.0" """ 550 | return self.root.number_index_to_tcl(self.index_num) 551 | 552 | def get_row(self): 553 | return int(self.get_tcl_index().split(".")[0]) 554 | 555 | def get_col(self): 556 | return int(self.get_tcl_index().split(".")[1]) 557 | 558 | def get_index_num(self): 559 | """ Returns the index (a single integer) of this peer """ 560 | return self.index_num 561 | 562 | def __eq__(self, other): 563 | return self.id == other.id 564 | 565 | def __ne__(self, other): 566 | return self.id != other.id 567 | -------------------------------------------------------------------------------- /src/interface/textbox.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | from ..utils import * 4 | from ..config import * 5 | from ..message import * 6 | from ..interpreter import * 7 | from ..ot.client import Client as OTClient 8 | from ..ot.text_operation import TextOperation, IncompatibleOperationError 9 | 10 | from .peer import * 11 | from .constraints import TextConstraint 12 | from .colour_merge import ColourMerge 13 | 14 | try: 15 | from Tkinter import * 16 | import tkFont 17 | except ImportError: 18 | from tkinter import * 19 | from tkinter import font as tkFont 20 | 21 | try: 22 | import queue 23 | except: 24 | import Queue as queue 25 | 26 | import re 27 | import time 28 | import sys 29 | import json 30 | 31 | class ThreadSafeText(Text, OTClient): 32 | is_refreshing = False 33 | def __init__(self, root, **options): 34 | 35 | # Inheret from Tk.Text and OT client 36 | 37 | Text.__init__(self, root.root, **options) 38 | OTClient.__init__(self, revision=0) 39 | 40 | self.constraint = TextConstraint(self) 41 | 42 | self.config(undo=True, autoseparators=True, maxundo=50) 43 | self.undo_stack = [] 44 | self.redo_stack = [] 45 | self.max_undo_size = 50 46 | 47 | # If we are blending font colours 48 | 49 | self.merge = ColourMerge(self) 50 | 51 | # Queue for reading messages 52 | 53 | self.queue = queue.Queue() 54 | self.root = root 55 | 56 | self.padx = 2 57 | self.pady = 2 58 | 59 | # Define message handlers 60 | 61 | self.handles = {} 62 | 63 | self.add_handle(MSG_CONNECT, self.handle_connect) 64 | self.add_handle(MSG_OPERATION, self.handle_operation) 65 | self.add_handle(MSG_SET_MARK, self.handle_set_mark) 66 | self.add_handle(MSG_SELECT, self.handle_select) 67 | self.add_handle(MSG_EVALUATE_BLOCK, self.handle_evaluate) 68 | self.add_handle(MSG_EVALUATE_STRING, self.handle_evaluate_str) 69 | self.add_handle(MSG_REMOVE, self.handle_remove) 70 | self.add_handle(MSG_KILL, self.handle_kill) 71 | self.add_handle(MSG_SET_ALL, self.handle_set_all) 72 | self.add_handle(MSG_RESET, self.handle_soft_reset) 73 | self.add_handle(MSG_REQUEST_ACK, self.handle_request_ack) 74 | self.add_handle(MSG_CONSTRAINT, self.handle_text_constraint) 75 | self.add_handle(MSG_CONSOLE, self.handle_console_message) 76 | self.add_handle(MSG_KEEP_ALIVE, self.handle_keep_alive) 77 | 78 | # Information about other connected users 79 | self.peers = self.root.client.peers 80 | self.peer_tags = [] 81 | 82 | self.marker = None 83 | self.local_peer = None 84 | 85 | self.scroll_view = None 86 | self.scroll_index = None 87 | 88 | self.configure_font() 89 | 90 | self.char_w = self.font.measure(" ") 91 | self.char_h = self.font.metrics("linespace") 92 | 93 | # Brackets 94 | 95 | left_b = list("([{") 96 | right_b = list(")]}") 97 | 98 | self.left_brackets = dict(zip(left_b, right_b)) 99 | self.right_brackets = dict(zip(right_b, left_b)) 100 | 101 | # Set formatting tags 102 | 103 | for tag_name, kwargs in tag_descriptions.items(): 104 | 105 | self.tag_config(tag_name, **kwargs) 106 | 107 | # Create 2 docs - one with chars, one with peer ids 108 | 109 | self.document = "" 110 | self.peer_tag_doc = "" 111 | 112 | # Begin listening for messages 113 | 114 | self.listen() 115 | 116 | # Operational Transformation 117 | # ========================== 118 | 119 | # Override OTClient 120 | def send_operation(self, revision, operation): 121 | """Should send an operation and its revision number to the server.""" 122 | message = MSG_OPERATION(self.marker.id, operation.ops, revision) 123 | return self.root.add_to_send_queue(message) 124 | 125 | def apply_operation(self, operation, peer=None, undo=False): 126 | """Should apply an operation from the server to the current document.""" 127 | 128 | if len(operation.ops): 129 | 130 | if len(self.read()) != len(self.peer_tag_doc): 131 | 132 | print("{} {}".format( len(self.read()) , len(self.peer_tag_doc))) 133 | print("Document length mismatch, please restart the Troop server.") 134 | return 135 | 136 | if peer is None: 137 | 138 | peer = self.active_peer 139 | 140 | # If other peers have added/deleted chars - transform the undo stack 141 | 142 | if peer != self.marker: 143 | 144 | self.transform_undo_stacks(operation) 145 | 146 | # Apply op 147 | 148 | try: 149 | 150 | new_text = operation(self.read()) 151 | 152 | except IncompatibleOperationError as err: 153 | 154 | # Get some debug info 155 | 156 | print("Length of text is {}".format(len(self.read()))) 157 | print("Operation is {}".format(operation.ops)) 158 | print("Length: {}".format(get_operation_size(operation.ops))) 159 | 160 | raise err 161 | 162 | self.set_text(new_text) 163 | 164 | self.insert_peer_id(peer, operation.ops) 165 | 166 | peer.de_select() 167 | 168 | # -- new 169 | 170 | self.refresh() 171 | 172 | return 173 | 174 | def apply_local_operation(self, ops, shift_amount, index=None, undo=False, redo=False): 175 | """ Applies the operation directly after a keypress """ 176 | 177 | # Only apply if the operation is not empty 178 | 179 | if not empty_operation(ops): 180 | 181 | operation = TextOperation(ops) 182 | text = self.read() 183 | 184 | # Set the active peer to the local marker and apply operation 185 | 186 | self.apply_operation(operation, peer=self.marker, undo=undo) 187 | 188 | # Track operations in the undo stack 189 | 190 | self.add_to_undo_stacks(operation, text, undo, redo) 191 | 192 | # Adjust locations of all peers inc. the local one 193 | 194 | self.adjust_peer_locations(self.marker, ops) 195 | 196 | if index is not None: 197 | 198 | self.marker.move(index) 199 | 200 | else: 201 | 202 | self.marker.shift(shift_amount) 203 | 204 | return 205 | 206 | def insert_peer_id(self, peer, ops): 207 | """ Applies a text operation to the `peer_tag_doc` which contains information about which character relates to which peers """ 208 | operation = TextOperation(self.get_peer_loc_ops(peer, ops)) 209 | self.peer_tag_doc = operation(self.peer_tag_doc) 210 | return 211 | 212 | def get_state(self): 213 | """ Returns the state of the OT mechanism as a string """ 214 | return self.state.__class__.__name__ 215 | 216 | def active_peers(self): 217 | """ Returns a list of peers currently connected """ 218 | return [peer for peer in self.peers.values() if peer.connected] 219 | 220 | def transform(self, op1, op2): 221 | """ Transforms two TextOperations and adjusts the first for the length of the document""" 222 | try: 223 | size = max(get_doc_size(op1.ops), len(self.read())) 224 | new_op1 = TextOperation(new_operation(*(list(op1.ops) + [size]))) 225 | new_op2 = TextOperation(new_operation(*(list(op2.ops) + [size]))) 226 | return TextOperation.transform(new_op1, new_op2) 227 | except Exception as e: 228 | print("Error transforming {} and {}".format(new_op1, new_op2)) 229 | raise e 230 | 231 | def transform_undo_stacks(self, operation): 232 | """ Transforms operations in the undo_stack so their locations are adjusted after other 233 | operations are applied to the text """ 234 | if len(self.undo_stack): 235 | self.undo_stack = [self.transform(action, operation)[0] for action in self.undo_stack if len(action.ops)] 236 | return 237 | 238 | def add_to_undo_stacks(self, operation, document, undo=False, redo=False): 239 | """ Adds the inverse of an operation to the undo stack """ 240 | # Keep track of operations for use in undo 241 | if not undo: 242 | self.undo_stack = self.undo_stack[-self.max_undo_size:] + [operation.invert(document)] 243 | if not redo: 244 | self.redo_stack = [] 245 | else: 246 | self.redo_stack.append(operation.invert(document)) 247 | return 248 | 249 | def get_undo_operation(self): 250 | """ Gets the last operation from the undo stack """ 251 | return self.undo_stack.pop() 252 | 253 | def get_redo_operation(self): 254 | """ Gets the last operation from the undo stack """ 255 | return self.redo_stack.pop() 256 | 257 | # Top-level handling 258 | # ================== 259 | 260 | def add_handle(self, msg_cls, func): 261 | """ Associates a received message class with a method or function """ 262 | self.handles[msg_cls.type] = func 263 | return 264 | 265 | def handle(self, message): 266 | ''' Passes the message onto the correct handler ''' 267 | return self.handles[message.type](message) 268 | 269 | # Handle methods 270 | # ============== 271 | 272 | def handle_connect(self, message): 273 | ''' Prints to the console that new user has connected ''' 274 | if self.marker.id != message['src_id']: 275 | 276 | # If a user has connected before, use that Peer instance 277 | 278 | if message["src_id"] in self.peers: 279 | 280 | self.root.reconnect_user(message['src_id'], message['name'], message['dummy']) 281 | 282 | else: 283 | 284 | self.root.add_new_user(message['src_id'], message['name'], message['dummy']) 285 | 286 | print("Peer '{}' has joined the session".format(message['name'])) 287 | 288 | return 289 | 290 | def handle_request_ack(self, message): 291 | """ After a new client connects, respond to the server to acknowledge""" 292 | if message['flag'] == 1: 293 | self.root.block_messages = True 294 | self.root.add_to_send_queue(MSG_CONNECT_ACK(self.marker.id)) 295 | elif message['flag'] == 0: 296 | self.root.block_messages = False 297 | return 298 | 299 | def handle_operation(self, message, client=False): 300 | """ Forwards the operation message to the correct handler based on whether it 301 | was sent by the client or server """ 302 | 303 | if client: 304 | 305 | operation = TextOperation(message["operation"]) 306 | 307 | # This *sends* the operation to the server using text.send_operation(), it does *not* apply it locally 308 | 309 | self.apply_client(operation) 310 | 311 | else: 312 | 313 | # If we recieve a message from the server with our own id, just need acknowledge it 314 | 315 | if message["src_id"] == self.marker.id: 316 | 317 | self.server_ack() 318 | 319 | else: 320 | 321 | self.active_peer = self.get_peer(message) 322 | 323 | operation = TextOperation(message["operation"]) 324 | 325 | # Apply the operation received from the server 326 | 327 | self.apply_server(operation) 328 | 329 | if get_operation_size(message["operation"]) != 0: 330 | 331 | # If the operation is delete/insert, change the indexes of peers that are based after this one 332 | 333 | self.adjust_peer_locations(self.active_peer, message["operation"]) 334 | 335 | # Move the peer marker 336 | 337 | self.active_peer.move(get_operation_index(message["operation"])) 338 | 339 | else: 340 | 341 | # Still need to update the location 342 | 343 | self.active_peer.refresh() 344 | 345 | # Return to old view 346 | 347 | self.reset_view() 348 | 349 | return 350 | 351 | def handle_set_mark(self, message): 352 | """ Updates a peer's location """ 353 | peer = self.get_peer(message) 354 | if peer: 355 | peer.move(message["index"]) 356 | return 357 | 358 | def handle_select(self, message): 359 | """ Update's a peer's selection """ 360 | peer = self.get_peer(message) 361 | if peer: 362 | peer.select_set(message["start"], message["end"]) 363 | self.update_colours() 364 | return 365 | 366 | def handle_evaluate(self, message): 367 | """ Highlights text based on message contents and evaluates the string found """ 368 | 369 | peer = self.get_peer(message) 370 | 371 | if peer: 372 | 373 | string = peer.highlight(message["start"], message["end"]) 374 | 375 | self.root.lang.evaluate(string, name=str(peer), colour=peer.bg) 376 | 377 | return 378 | 379 | def handle_evaluate_str(self, message): 380 | """ Evaluates a string as code """ 381 | 382 | peer = self.get_peer(message) 383 | 384 | if peer: 385 | 386 | self.root.lang.evaluate(message["string"], name=str(peer), colour=peer.bg) 387 | 388 | return 389 | 390 | def handle_remove(self, message): 391 | """ Removes a Peer from the session based on the contents of message """ 392 | peer = self.get_peer(message) 393 | if peer: 394 | print("Peer '{!s}' has disconnected".format(peer)) 395 | peer.remove() 396 | return 397 | 398 | def handle_set_all(self, message): 399 | ''' Sets the contents of the text box and updates the location of peer markers ''' 400 | 401 | self.reset() # inherited from OTClient 402 | 403 | self.document = message["document"] 404 | 405 | self.peer_tag_doc = self.create_peer_tag_doc(message["peer_tag_loc"]) 406 | 407 | self.refresh() 408 | 409 | for peer_id, index in message["peer_loc"].items(): 410 | 411 | peer_id = int(peer_id) 412 | 413 | if peer_id in self.peers: 414 | 415 | self.peers[peer_id].move(index) 416 | 417 | return 418 | 419 | def handle_soft_reset(self, message): 420 | """ Sets the revision number to 0 and sets the document contents """ 421 | self.revision = 0 422 | return self.handle_set_all(message) 423 | 424 | def handle_kill(self, message): 425 | ''' Cleanly terminates the session ''' 426 | return self.root.freeze_kill(message['string']) 427 | 428 | def handle_text_constraint(self, message): 429 | """ A new text constrait is set """ # TODO: implement the constraints again 430 | constraint_id = message["constraint_id"] 431 | dictator_peer = message["src_id"] 432 | 433 | # Don't update if already using 434 | 435 | if constraint_id != self.constraint: 436 | 437 | if not (constraint_id == 0 and self.constraint.rule is None): 438 | 439 | print("New rule received! Setting mode to '{}'".format(str(self.constraint.get_name(constraint_id)).title())) 440 | 441 | self.constraint.set_constraint(constraint_id, dictator_peer) 442 | 443 | return 444 | 445 | def handle_console_message(self, message): 446 | """ Prints another user's console message if this is a dummy interpreter """ 447 | if self.root.lang.id == -1: 448 | print(message["string"]) 449 | return 450 | 451 | def handle_keep_alive(self, message): 452 | """ Receives a keep alive message and responds to it. """ 453 | self.root.client.keepalive = time.time() 454 | return self.root.add_to_send_queue(MSG_KEEP_ALIVE(self.marker.id)) 455 | 456 | # Reading and writing to the text box 457 | # =================================== 458 | 459 | def clear(self): 460 | """ Deletes the contents of the string """ 461 | return self.delete("1.0", END) 462 | 463 | def set_text(self, string): 464 | """ Sets the contents of the textbox to string""" 465 | self.document = string 466 | # self.refresh() 467 | return 468 | 469 | def read(self): 470 | """ Returns the entire contents of the text box as a string """ 471 | return self.document 472 | 473 | def readlines(self): 474 | """ Returns the entire document as a list in which each element is a line from the text""" 475 | return self.read().split("\n") 476 | 477 | def get_num_lines(self): 478 | """ Returns the number of lines in the document """ 479 | return int(self.index('end-1c').split('.')[0]) 480 | 481 | # Updating / retrieving info from peers 482 | # ===================================== 483 | 484 | def adjust_peer_locations(self, peer, operation): 485 | """ When a peer performs an operation, adjust the location of peers following it and update 486 | the location of peer tags """ 487 | 488 | shift = get_operation_size(operation) 489 | index = get_operation_index(operation) 490 | 491 | peer_index = index - shift 492 | 493 | # Un-comment to debug 494 | 495 | # if peer.get_index_num() != peer_index: 496 | 497 | # print("{} index: {}, Op index: {}, shift: {} === {} ".format(str(peer), peer.get_index_num(), index, shift, peer_index)) 498 | 499 | # print("Peer '{}' applying operation of size '{}' at index '{}'".format(str(peer), shift, index)) 500 | 501 | doc_size = len(self.read()) 502 | 503 | for other in self.peers.values(): 504 | 505 | if other != peer: 506 | 507 | other_index = other.get_index_num() 508 | 509 | # Moving whole selections 510 | 511 | if other.has_selection(): 512 | 513 | # If the selections overlap, de-highlight the deleted section 514 | 515 | if peer.has_selection(): 516 | 517 | other.select_remove(peer.select_start(), peer.select_end()) 518 | 519 | # Shift a selected area 520 | 521 | other.select_shift(peer_index, shift) 522 | 523 | # If the other peer is *in* this peer's selection, move it 524 | 525 | if peer.has_selection() and peer.select_contains( other_index ): 526 | 527 | other.move(peer.select_start()) 528 | 529 | # Adjust the index if it comes after the operating peer index 530 | 531 | elif other_index > peer_index: 532 | 533 | other.shift(shift) 534 | 535 | # If behind, just redraw (if on screen) 536 | 537 | else: 538 | 539 | other.refresh() 540 | 541 | self.update_colours() 542 | 543 | return 544 | 545 | def create_peer_tag_doc(self, locations): 546 | """ Re-creates the document of peer_id markers """ 547 | s = [] 548 | for peer_id, length in locations: 549 | s.append("{}".format(get_peer_char(peer_id)) * int(length)) 550 | return "".join(s) 551 | 552 | def get_peer_loc_ops(self, peer, ops): 553 | """ Converts a list of operations on the main document to inserting the peer ID """ 554 | # return [str(peer.id) * len(val) if isinstance(val, str) else val for val in ops] 555 | return [peer.char * len(val) if isinstance(val, str) else val for val in ops] 556 | 557 | def get_peer(self, message): 558 | """ Retrieves the Peer instance using the "src_id" of message """ 559 | 560 | this_peer = None 561 | 562 | if 'src_id' in message and message['src_id'] != -1: 563 | 564 | try: 565 | 566 | this_peer = self.peers[message['src_id']] 567 | 568 | except KeyError as err: 569 | 570 | self.root.freeze_kill(str(err)) 571 | 572 | return this_peer 573 | 574 | def update_colours(self): 575 | """ Sets the peer tags in the text document """ 576 | 577 | processed = [] 578 | 579 | # Go through connected peers and colour the text 580 | 581 | for p_id, peer in self.peers.items(): 582 | 583 | processed.append(peer.char) 584 | 585 | self.update_peer_tag(p_id) 586 | 587 | peer.refresh_highlight() 588 | 589 | # If there are any other left over peers, keep their colours 590 | 591 | for p_id in set(self.peer_tag_doc): 592 | 593 | if str(p_id) not in processed: 594 | 595 | self.update_peer_tag(get_peer_id_from_char(p_id)) 596 | 597 | return 598 | 599 | def update_peer_tag(self, p_id): 600 | """ Refreshes a peer's text_tag colours """ 601 | 602 | text_tag = Peer.get_text_tag(p_id) 603 | 604 | # Make sure we include peers no longer connected 605 | 606 | if text_tag not in self.peer_tags: 607 | 608 | self.peer_tags.append(text_tag) 609 | 610 | fg, _ = PeerFormatting(int(p_id)) 611 | 612 | self.tag_config(text_tag, foreground=fg) 613 | 614 | self.tag_remove(text_tag, "1.0", END) 615 | 616 | for start, end in get_peer_locs(get_peer_char(p_id), self.peer_tag_doc): 617 | 618 | self.tag_add(text_tag, self.number_index_to_tcl(start), self.number_index_to_tcl(end)) 619 | 620 | return 621 | 622 | # Main loop actions 623 | # ================= 624 | 625 | def put(self, message): 626 | """ Checks if a message from a new user then writes a network message to the queue """ 627 | assert isinstance(message, MESSAGE) 628 | self.queue.put(message) 629 | return 630 | 631 | def listen(self): 632 | """ Continuously reads from the queue of messages read from the server 633 | and carries out the specified actions. """ 634 | try: 635 | while True: 636 | 637 | # Pop the message from the queue 638 | 639 | msg = self.queue.get_nowait() 640 | 641 | # Log anything if necesary 642 | 643 | if self.root.is_logging: 644 | 645 | self.root.log_message(msg) 646 | 647 | # Get the handler method and call 648 | 649 | try: 650 | 651 | self.handle(msg) 652 | 653 | except Exception as e: 654 | 655 | func = self.handles.get(msg.type) 656 | 657 | if func: 658 | 659 | func = func.__name__ 660 | 661 | else: 662 | 663 | func = msg.type 664 | 665 | 666 | print("Exception occurred in message {!r}: {!r} {!r}".format(func, type(e), e)) 667 | raise(e) 668 | 669 | # Update any other idle tasks 670 | 671 | self.update_idletasks() 672 | 673 | # Break when the queue is empty 674 | except queue.Empty: 675 | 676 | pass 677 | 678 | # Recursive call 679 | self.after(30, self.listen) 680 | return 681 | 682 | def refresh(self): 683 | """ Clears the text box and loads the current document state, called after an operation """ 684 | 685 | self.is_refreshing = True 686 | 687 | # Store the current "view" to re-apply later 688 | self.store_view() 689 | 690 | # Remove all the text and insert new text 691 | self.clear() 692 | self.insert("1.0", self.document) 693 | 694 | # Update the colours and formatting 695 | self.update_colours() 696 | self.apply_language_formatting() 697 | 698 | self.is_refreshing = False 699 | 700 | return 701 | 702 | def refresh_peer_labels(self): 703 | ''' Updates the locations of the peers to their marks. Called from line_numbers on repeat ''' 704 | 705 | for peer_id, peer in self.peers.items(): 706 | 707 | peer.redraw() 708 | 709 | return 710 | 711 | # handling key events 712 | 713 | def apply_language_formatting(self): 714 | """ Iterates over each line in the text and updates the correct colour / formatting """ 715 | for line, _ in enumerate(self.readlines()): 716 | self.colour_line(line + 1) 717 | return 718 | 719 | def colour_line(self, line): 720 | """ Embold keywords defined in `Interpreter.py` """ 721 | 722 | # Get contents of the line 723 | 724 | start, end = "{}.0".format(line), "{}.end".format(line) 725 | 726 | string = self.get(start, end) 727 | 728 | # Go through the possible tags 729 | 730 | for tag_name, tag_finding_func in self.root.lang.re.items(): 731 | 732 | self.tag_remove(tag_name, start, end) 733 | 734 | for match_start, match_end in tag_finding_func(string): 735 | 736 | tag_start = "{}.{}".format(line, match_start) 737 | tag_end = "{}.{}".format(line, match_end) 738 | 739 | self.tag_add(tag_name, tag_start, tag_end) 740 | 741 | return 742 | 743 | def highlight_brackets(self, bracket): 744 | """ Call this with a bracket """ 745 | 746 | index = self.marker.get_index_num() - 1 747 | assert self.read()[index] == bracket 748 | 749 | start = self.find_starting_bracket(index - 1, self.right_brackets[bracket], bracket) 750 | 751 | if start is not None: 752 | 753 | self.tag_add(self.bracket_tag, self.number_index_to_tcl(start)) 754 | self.tag_add(self.bracket_tag, self.number_index_to_tcl(index)) 755 | 756 | return 757 | 758 | def find_starting_bracket(self, index, left_bracket, right_bracket): 759 | """ Finds the opening bracket to the closing bracket at line, column co-ords. 760 | Returns None if not found. """ 761 | text = self.read() 762 | nests = 0 763 | for i in range(index, -1, -1): 764 | if text[i] == left_bracket: 765 | if nests > 0: 766 | nests -= 1 767 | else: 768 | return i 769 | elif text[i] == right_bracket: 770 | nests += 1 771 | return 772 | 773 | # Housekeeping 774 | # ============ 775 | 776 | def store_view(self): 777 | """ Store the location of the interface view, i.e. scroll, such that the 778 | self.marker.bbox will be the same when self.reset_view() is called """ 779 | 780 | # If we are at the top of the screen, and the marker is less than 2/3 down the page, keep at top 781 | 782 | top_row = self.get_visible_row_top() 783 | marker_row = self.get_marker_row() 784 | bottom_row = self.get_visible_row_bottom() 785 | 786 | if top_row == 1: 787 | 788 | if marker_row < (bottom_row * 0.66): 789 | 790 | self.scroll_distance = self.get_num_lines() * -1 791 | 792 | return 793 | 794 | # Store the current distance between top row and marker.mark 795 | 796 | self.scroll_distance = top_row - marker_row 797 | 798 | return 799 | 800 | def reset_view(self): 801 | """ Sets the view to the last position stored""" 802 | 803 | # Move to top 804 | 805 | self.yview('move', 0.0) 806 | 807 | # Scroll until the scroll-distance is the same as previous (or the end) 808 | 809 | for n in range(self.get_num_lines()): 810 | 811 | if (self.get_visible_row_top() - self.get_marker_row()) >= self.scroll_distance: 812 | 813 | break 814 | 815 | self.yview('scroll', 1, 'units') 816 | 817 | return 818 | 819 | def get_visible_row_top(self): 820 | """ Returns the row number of the top-most visible line """ 821 | index = self.index("@0,0") 822 | return int(index.split(".")[0]) 823 | 824 | def get_visible_row_bottom(self): 825 | """ Returns the row number of the bottom-most visible line """ 826 | index = self.index("@0,{}".format(self.winfo_height())) 827 | return int(index.split(".")[0]) 828 | 829 | def get_marker_row(self): 830 | index = self.index(self.marker.mark) 831 | return int(index.split(".")[0]) 832 | 833 | def configure_font(self): 834 | """ Sets up font for the editor """ 835 | 836 | if SYSTEM == MAC_OS: 837 | 838 | fontfamily = "Monaco" 839 | 840 | elif SYSTEM == WINDOWS: 841 | 842 | fontfamily = "Consolas" 843 | 844 | else: 845 | 846 | fontfamily = "Courier New" 847 | 848 | self.font_names = [] 849 | 850 | self.font = tkFont.Font(family=fontfamily, size=12, name="Font") 851 | self.font.configure(**tkFont.nametofont("Font").configure()) 852 | self.font_names.append("Font") 853 | 854 | self.font_bold = tkFont.Font(family=fontfamily, size=12, weight="bold", name="BoldFont") 855 | self.font_bold.configure(**tkFont.nametofont("BoldFont").configure()) 856 | self.font_names.append("BoldFont") 857 | 858 | self.font_italic = tkFont.Font(family=fontfamily, size=12, slant="italic", name="ItalicFont") 859 | self.font_italic.configure(**tkFont.nametofont("ItalicFont").configure()) 860 | self.font_names.append("ItalicFont") 861 | 862 | self.configure(font="Font") 863 | 864 | self.bracket_style = {'borderwidth': 2, 'relief' : 'groove'} 865 | self.bracket_tag = "tag_open_brackets" 866 | self.tag_config(self.bracket_tag, **self.bracket_style) 867 | 868 | return 869 | 870 | def tcl_index_to_number(self, index): 871 | """ Takes a tcl index e.g. '1.0' and returns the single number it represents if the 872 | text contents were a single list """ 873 | row, col = [int(val) for val in self.index(index).split(".")] 874 | return sum([len(line) + 1 for line in self.read().split("\n")[:row-1]]) + col 875 | 876 | 877 | def number_index_to_tcl(self, number): 878 | """ Takes an integer number and returns the tcl index in the from 'row.col' """ 879 | if number <= 0: 880 | return "1.0" 881 | text = self.read() 882 | # Count columns until a newline, then reset and add 1 to row 883 | count = 0; row = 1; col = 0 884 | for i in range(1, len(text)+1): 885 | char = text[i-1] 886 | if char == "\n": 887 | row += 1 888 | col = 0 889 | else: 890 | col += 1 891 | if i >= number: 892 | break 893 | return "{}.{}".format(row, col) 894 | 895 | def get_num_lines(self): 896 | return int(self.index(END).split(".")[0]) - 1 897 | 898 | def number_index_to_row_col(self, number): 899 | """ Takes an integer number and returns the row and column as integers """ 900 | tcl_index = self.number_index_to_tcl(number) 901 | return tuple(int(x) for x in tcl_index.split(".")) 902 | 903 | def get_line_contents(self, line): 904 | """ Returns the contents of a line specified by an integer """ 905 | return self.get("{}.0".format(line), "{}.end".format(line)) 906 | 907 | def get_leading_whitespace(self, line): 908 | """ Returns the number of spaces that a line starts with, if the line is only whitespace, returns 0""" 909 | line_contents = self.get_line_contents(line) 910 | 911 | if line_contents.startswith(" ") and len(line_contents.strip()) > 0: 912 | 913 | return len(line_contents) - len(line_contents.lstrip(' ')) 914 | 915 | return 0 916 | -------------------------------------------------------------------------------- /src/interpreter.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interpreter 3 | ----------- 4 | 5 | Runs a block of FoxDot code. Designed to be overloaded 6 | for other language communication 7 | 8 | """ 9 | from __future__ import absolute_import 10 | from .config import * 11 | from .message import MSG_CONSOLE 12 | 13 | from subprocess import Popen 14 | from subprocess import PIPE, STDOUT 15 | from datetime import datetime 16 | 17 | # Import OSC library depending on Python version 18 | 19 | if PY_VERSION == 2: 20 | from . import OSC 21 | else: 22 | from . import OSC3 as OSC 23 | 24 | try: 25 | broken_pipe_exception = BrokenPipeError 26 | except NameError: # Python 2 27 | broken_pipe_exception = IOError 28 | 29 | CREATE_NO_WINDOW = 0x08000000 if SYSTEM == WINDOWS else 0 30 | 31 | import sys 32 | import re 33 | import time 34 | import threading 35 | import shlex 36 | import tempfile 37 | import os, os.path 38 | 39 | DATE_FORMAT = "%Y-%m-%d %H:%M:%S.%f" 40 | 41 | def compile_regex(kw): 42 | """ Takes a list of strings and returns a regex that 43 | matches each one """ 44 | return re.compile(r"(?{}'.format(colour, text) 50 | 51 | ## dummy interpreter 52 | 53 | class DummyInterpreter: 54 | name = None 55 | def __init__(self, *args, **kwargs): 56 | self.re={} 57 | 58 | self.syntax_lang = langtypes[kwargs.get("syntax", -1)] 59 | 60 | # If using another snytax, use the appropriate regex 61 | 62 | if self.syntax_lang != self.__class__: 63 | 64 | self.re = {"tag_bold": self.syntax_lang.find_keyword, "tag_italic": self.syntax_lang.find_comment} 65 | 66 | self.syntax_lang.setup() 67 | 68 | else: 69 | 70 | self.syntax_lang = None 71 | 72 | def __repr__(self): 73 | return self.name if self.name is not None else repr(self.__class__.__name__) 74 | 75 | def get_block_of_code(self, text, index): 76 | """ Returns the start and end line numbers of the text to evaluate when pressing Ctrl+Return. """ 77 | 78 | # Get start and end of the buffer 79 | start, end = "1.0", text.index("end") 80 | lastline = int(end.split('.')[0]) + 1 81 | 82 | # Indicies of block to execute 83 | block = [0,0] 84 | 85 | # 1. Get position of cursor 86 | cur_x, cur_y = index.split(".") 87 | cur_x, cur_y = int(cur_x), int(cur_y) 88 | 89 | # 2. Go through line by line (back) and see what it's value is 90 | 91 | for line in range(cur_x, 0, -1): 92 | if not text.get("%d.0" % line, "%d.end" % line).strip(): 93 | break 94 | 95 | block[0] = line 96 | 97 | # 3. Iterate forwards until we get two \n\n or index==END 98 | for line in range(cur_x, lastline): 99 | if not text.get("%d.0" % line, "%d.end" % line).strip(): 100 | break 101 | 102 | block[1] = line 103 | 104 | return block 105 | 106 | def evaluate(self, string, *args, **kwargs): 107 | self.print_stdin(string, *args, **kwargs) 108 | return 109 | 110 | def start(self): 111 | return self 112 | 113 | def stdout(self, *args, **kwargs): 114 | pass 115 | 116 | def kill(self, *args, **kwargs): 117 | pass 118 | 119 | def print_stdin(self, string, name=None, colour="White"): 120 | """ Handles the printing of the execute code to screen with coloured 121 | names and formatting """ 122 | # Split on newlines 123 | string = [line.replace("\n", "") for line in string.split("\n") if len(line.strip()) > 0] 124 | if len(string) > 0 and name is not None: 125 | name = str(name) 126 | print(colour_format(name, colour) + _ + string[0]) 127 | # Use ... for the remainder of the lines 128 | n = len(name) 129 | for i in range(1,len(string)): 130 | sys.stdout.write(colour_format("." * n, colour) + _ + string[i]) 131 | sys.stdout.flush() 132 | return 133 | 134 | # Syntax highlighting methods 135 | 136 | def find_keyword(self, string): 137 | return self.syntax_lang.find_keyword(string) 138 | 139 | def find_comment(self, string): 140 | return self.syntax_lang.find_comment(string) 141 | 142 | def stop_sound(self): 143 | """ Returns the string for stopping all sound in a language """ 144 | return self.syntax_lang.stop_sound() if self.syntax_lang != None else "" 145 | 146 | @staticmethod 147 | def format(string): 148 | """ Method to be overloaded in sub-classes for formatting strings to be evaluated """ 149 | return str(string) + "\n" 150 | 151 | class Interpreter(DummyInterpreter): 152 | id = 99 153 | lang = None 154 | clock = None 155 | boot_file = None 156 | keyword_regex = compile_regex([]) 157 | comment_regex = compile_regex([]) 158 | stdout = None 159 | stdout_thread = None 160 | filetype = ".txt" 161 | client = None 162 | 163 | def __init__(self, client, path, args=""): 164 | 165 | self.client = client 166 | 167 | self.re = {"tag_bold": self.find_keyword, "tag_italic": self.find_comment} 168 | 169 | self.path = self._get_path(path) 170 | 171 | self.args = self._get_args(args) 172 | 173 | self.f_out = tempfile.TemporaryFile("w+", 1) # buffering = 1 174 | self.is_alive = True 175 | 176 | self.setup() 177 | 178 | def _get_path(self, path): 179 | if self.name: 180 | path = (BOOT_DATA.get(self.name.lower(), {}).get('path') or path) 181 | return shlex.split(path) 182 | 183 | def _get_args(self, args): 184 | if isinstance(args, str): 185 | 186 | args = shlex.split(args) 187 | 188 | elif isinstance(args, list) and len(args) == 1: 189 | 190 | args = shlex.split(args[0]) 191 | 192 | return args 193 | 194 | def _get_bootfile(self): 195 | """ 196 | Get the path of a specific custom bootfile or None 197 | """ 198 | if self.name: 199 | return BOOT_DATA.get(self.name.lower(), {}).get('bootfile') 200 | 201 | def setup(self): 202 | """ Overloaded in sub-classes """ 203 | return 204 | 205 | def start(self): 206 | """ Opens the process with the interpreter language """ 207 | 208 | try: 209 | 210 | self.lang = Popen(self.path + self.args, shell=False, universal_newlines=True, bufsize=1, 211 | stdin=PIPE, 212 | stdout=self.f_out, 213 | stderr=self.f_out, 214 | creationflags=CREATE_NO_WINDOW) 215 | 216 | self.stdout_thread = threading.Thread(target=self.stdout) 217 | self.stdout_thread.start() 218 | 219 | except OSError: 220 | 221 | raise ExecutableNotFoundError(self.get_path_as_string()) 222 | 223 | self.load_bootfile() 224 | 225 | return self 226 | 227 | def load_bootfile(self): 228 | """ 229 | Loads the specified boot file. If it exists, it is defined 230 | in the class but can be overridden in conf/boot.txt. 231 | """ 232 | 233 | self.boot_file = self._get_bootfile() 234 | 235 | # Load data 236 | if self.boot_file: 237 | 238 | with open(self.boot_file) as f: 239 | 240 | for line in f.split("\n"): 241 | 242 | self.lang.stdin.write(line.rstrip() + "\n") 243 | self.lang.stdin.flush() 244 | 245 | return 246 | 247 | def get_path_as_string(self): 248 | """ Returns the executable input as a string """ 249 | return " ".join(self.path) 250 | 251 | @classmethod 252 | def find_keyword(cls, string): 253 | return [(match.start(), match.end()) for match in cls.keyword_regex.finditer(string)] 254 | 255 | @classmethod 256 | def find_comment(cls, string): 257 | return [(match.start(), match.end()) for match in cls.comment_regex.finditer(string)] 258 | 259 | def write_stdout(self, string): 260 | if self.is_alive: 261 | self.lang.stdin.write(self.format(string)) 262 | self.lang.stdin.flush() 263 | return 264 | 265 | def evaluate(self, string, *args, **kwargs): 266 | """ Sends a string to the stdin and prints the text to the console """ 267 | # TODO -- get control of stdout 268 | # Print to console 269 | self.print_stdin(string, *args, **kwargs) 270 | # Pipe to the subprocess 271 | self.write_stdout(string) 272 | return 273 | 274 | def stdout(self, text=""): 275 | """ Continually reads the stdout from the self.lang process """ 276 | 277 | while self.is_alive: 278 | if self.lang.poll(): 279 | self.is_alive = False 280 | break 281 | try: 282 | # Check contents of file 283 | # TODO -- get control of f_out and stdout 284 | self.f_out.seek(0) 285 | 286 | message = [] 287 | 288 | for stdout_line in iter(self.f_out.readline, ""): 289 | 290 | line = stdout_line.rstrip() 291 | sys.stdout.write(line) 292 | message.append(line) 293 | 294 | # clear tmpfile 295 | self.f_out.truncate(0) 296 | 297 | # Send console contents to the server 298 | 299 | if len(message) > 0 and self.client.is_master(): 300 | 301 | self.client.send(MSG_CONSOLE(self.client.id, "\n".join(message))) 302 | 303 | time.sleep(0.05) 304 | except ValueError as e: 305 | print(e) 306 | return 307 | return 308 | 309 | def kill(self): 310 | """ Stops communicating with the subprocess """ 311 | # End process if not done so already 312 | self.is_alive = False 313 | if self.lang.poll() is None: 314 | self.lang.communicate() 315 | 316 | class CustomInterpreter: 317 | def __init__(self, *args, **kwargs): 318 | self.args = args 319 | self.kwargs = kwargs 320 | def __call__(self): 321 | return Interpreter(*self.args, **self.kwargs) 322 | 323 | class BuiltinInterpreter(Interpreter): 324 | def __init__(self, client, args): 325 | Interpreter.__init__(self, client, self.path, args) 326 | 327 | class FoxDotInterpreter(BuiltinInterpreter): 328 | filetype=".py" 329 | path = "{} -u -m FoxDot --pipe".format(PYTHON_EXECUTABLE) 330 | name = "FoxDot" 331 | 332 | @classmethod 333 | def setup(cls): 334 | cls.keywords = ["Clock", "Scale", "Root", "var", "linvar", '>>', 'print'] 335 | cls.keyword_regex = compile_regex(cls.keywords) 336 | 337 | @staticmethod 338 | def format(string): 339 | return "{}\n\n".format(string) 340 | 341 | @classmethod 342 | def find_comment(cls, string): 343 | instring, instring_char = False, "" 344 | for i, char in enumerate(string): 345 | if char in ('"', "'"): 346 | if instring: 347 | if char == instring_char: 348 | instring = False 349 | instring_char = "" 350 | else: 351 | instring = True 352 | instring_char = char 353 | elif char == "#": 354 | if not instring: 355 | return [(i, len(string))] 356 | return [] 357 | 358 | def kill(self): 359 | self.evaluate(self.stop_sound()) 360 | Interpreter.kill(self) 361 | return 362 | 363 | @classmethod 364 | def stop_sound(cls): 365 | return "Clock.clear()" 366 | 367 | class TidalInterpreter(BuiltinInterpreter): 368 | path = 'ghci' 369 | filetype = ".tidal" 370 | name = "TidalCycles" 371 | 372 | def start(self): 373 | 374 | # Use ghc-pkg to find location of boot-tidal 375 | 376 | try: 377 | 378 | process = Popen(["ghc-pkg", "field", "tidal", "data-dir"], stdout=PIPE, universal_newlines=True) 379 | 380 | output = process.communicate()[0] 381 | 382 | data_dir = output.split("\n")[0].replace("data-dir:", "").strip() 383 | 384 | self.boot_file = os.path.join(data_dir, "BootTidal.hs") 385 | 386 | except FileNotFoundError: 387 | 388 | # Set to None - might be defined in bootup file 389 | 390 | self.boot_file = None 391 | 392 | Interpreter.start(self) 393 | 394 | return self 395 | 396 | def load_bootfile(self): 397 | """ 398 | Overload for Tidal to use :script /path/to/file 399 | instead of loading each line of a boot file one by 400 | one 401 | """ 402 | self.boot_file = (self._get_bootfile() or self.boot_file) 403 | 404 | if self.boot_file: 405 | 406 | self.write_stdout(":script {}".format(self.boot_file)) 407 | 408 | else: 409 | 410 | err = "Could not find BootTidal.hs! You can specify the path in your Troop boot config file: {}".format(BOOT_CONFIG_FILE) 411 | raise(FileNotFoundError(err)) 412 | 413 | return 414 | 415 | @classmethod 416 | def setup(cls): 417 | cls.keywords = ["d{}".format(n) for n in range(1,17)] + ["\$", "#", "hush", "solo", "silence"] 418 | cls.keyword_regex = compile_regex(cls.keywords) 419 | return 420 | 421 | @classmethod 422 | def find_comment(cls, string): 423 | instring, instring_char = False, "" 424 | for i, char in enumerate(string): 425 | if char in ('"', "'"): 426 | if instring: 427 | if char == instring_char: 428 | instring = False 429 | instring_char = "" 430 | else: 431 | instring = True 432 | instring_char = char 433 | elif char == "-": 434 | if not instring and (i + 1) < len(string) and string[i + 1] == "-": 435 | return [(i, len(string))] 436 | return [] 437 | 438 | @staticmethod 439 | def format(string): 440 | """ Used to formant multiple lines in haskell """ 441 | return ":{\n"+string+"\n:}\n" 442 | 443 | @classmethod 444 | def stop_sound(cls): 445 | """ Triggers the 'hush' command using Ctrl+. """ 446 | return "hush" 447 | 448 | # Interpreters over OSC (e.g. Sonic Pi) 449 | # ------------------------------------- 450 | 451 | class OSCInterpreter(Interpreter): 452 | """ Class for sending messages via OSC instead of using a subprocess """ 453 | def __init__(self, *args, **kwargs): 454 | self.re = {"tag_bold": self.find_keyword, "tag_italic": self.find_comment} 455 | self.lang = OSC.OSCClient() 456 | self.lang.connect((self.host, self.port)) 457 | self._osc_error = False 458 | 459 | # Overload to not activate a server 460 | def start(self): 461 | return self 462 | 463 | def kill(self): 464 | self.evaluate(self.stop_sound()) 465 | self.lang.close() 466 | return 467 | 468 | def new_osc_message(self, string): 469 | """ Overload in sub-class, return OSC.OSCMessage""" 470 | return 471 | 472 | def print_osc_warning_message(self): 473 | print("Warning: No connection made to local {} OSC server instance.".format(self.__repr__())) 474 | return 475 | 476 | def evaluate(self, string, *args, **kwargs): 477 | # Print to the console the message 478 | Interpreter.print_stdin(self, string, *args, **kwargs) 479 | # Create an osc message and send to the server 480 | try: 481 | self.lang.send(self.new_osc_message(string)) 482 | self._osc_error = False 483 | except OSC.OSCClientError: 484 | if not self._osc_error: 485 | self.print_osc_warning_message() 486 | self._osc_error = True 487 | return 488 | 489 | class SuperColliderInterpreter(OSCInterpreter): 490 | filetype = ".scd" 491 | host = 'localhost' 492 | port = 57120 493 | name = "SuperCollider" 494 | 495 | def new_osc_message(self, string): 496 | """ Returns OSC message for Troop Quark """ 497 | msg = OSC.OSCMessage("/troop") 498 | msg.append([string]) 499 | return msg 500 | 501 | @classmethod 502 | def find_comment(cls, string): 503 | instring, instring_char = False, "" 504 | for i, char in enumerate(string): 505 | if char in ('"', "'"): 506 | if instring: 507 | if char == instring_char: 508 | instring = False 509 | instring_char = "" 510 | else: 511 | instring = True 512 | instring_char = char 513 | elif char == "/": 514 | if not instring and (i + 1) < len(string) and string[i + 1] == "/": 515 | return [(i, len(string))] 516 | return [] 517 | 518 | @classmethod 519 | def get_block_of_code(cls, text, index): 520 | """ Returns the start and end line numbers of the text to evaluate when pressing Ctrl+Return. """ 521 | 522 | # Get start and end of the buffer 523 | start, end = "1.0", text.index("end") 524 | lastline = int(end.split('.')[0]) + 1 525 | 526 | # Indicies of block to execute 527 | block = [0,0] 528 | 529 | # 1. Get position of cursor 530 | cur_y, cur_x = index.split(".") 531 | cur_y, cur_x = int(cur_y), int(cur_x) 532 | 533 | left_cur_y, left_cur_x = cur_y, cur_x 534 | right_cur_y, right_cur_x = cur_y, cur_x 535 | 536 | # Go back to find a left bracket 537 | 538 | while True: 539 | 540 | new_left_cur_y, new_left_cur_x = cls.get_left_bracket(text, left_cur_y, left_cur_x) 541 | new_right_cur_y, new_right_cur_x = cls.get_right_bracket(text, right_cur_y, right_cur_x) 542 | 543 | if new_left_cur_y is None or new_right_cur_y is None: 544 | 545 | block = [left_cur_y, right_cur_y + 1] 546 | 547 | break 548 | 549 | else: 550 | 551 | left_cur_y, left_cur_x = new_left_cur_y, new_left_cur_x 552 | right_cur_y, right_cur_x = new_right_cur_y, new_right_cur_x 553 | 554 | return block 555 | 556 | @classmethod 557 | def get_left_bracket(cls, text, cur_y, cur_x): 558 | count = 0 559 | line_text = text.get("{}.{}".format(cur_y, 0), "{}.{}".format(cur_y, "end")) 560 | for line_num in range(cur_y, 0, -1): 561 | # Only check line if it has text 562 | if len(line_text) > 0: 563 | for char_num in range(cur_x - 1, -1, -1): 564 | 565 | try: 566 | char = line_text[char_num] 567 | except IndexError as e: 568 | print("left bracket, string is {}, index is {}".format(line_text, char_num)) 569 | raise(e) 570 | 571 | if char == ")": 572 | count += 1 573 | elif char == "(": 574 | if count == 0: 575 | return line_num, char_num 576 | else: 577 | count -= 1 578 | line_text = text.get("{}.{}".format(line_num - 1, 0), "{}.{}".format(line_num - 1, "end")) 579 | cur_x = len(line_text) 580 | return None, None 581 | 582 | @classmethod 583 | def get_right_bracket(cls, text, cur_y, cur_x): 584 | num_lines = int(text.index("end").split(".")[0]) + 1 585 | count = 0 586 | for line_num in range(cur_y, num_lines): 587 | line_text = text.get("{}.{}".format(line_num, 0), "{}.{}".format(line_num, "end")) 588 | # Only check line if it has text 589 | if len(line_text) > 0: 590 | for char_num in range(cur_x, len(line_text)): 591 | 592 | try: 593 | char = line_text[char_num] 594 | except IndexError as e: 595 | print("right bracket, string is {}, index is {}".format(line_text, char_num)) 596 | raise(e) 597 | 598 | if char == "(": 599 | count += 1 600 | if char == ")": 601 | if count == 0: 602 | return line_num, char_num + 1 603 | else: 604 | count -= 1 605 | cur_x = 0 606 | else: 607 | return None, None 608 | 609 | @classmethod 610 | def stop_sound(cls): 611 | return "s.freeAll" 612 | 613 | 614 | class SonicPiInterpreter(OSCInterpreter): 615 | filetype = ".rb" 616 | host = 'localhost' 617 | name = "Sonic-Pi" 618 | 619 | def __init__(self, *args, **kwargs): 620 | self.port = self._find_port() 621 | OSCInterpreter.__init__(self, *args, **kwargs) 622 | 623 | # Adapted from https://github.com/emlyn/sonic-pi-tool/blob/d8b1a1394052bfa83d91c4d941d1b89b16cd4b4d/sonic-pi-tool.py#L311 624 | def _find_port(self): 625 | try: 626 | homePath = os.environ.get('SONIC_PI_HOME', '~') 627 | with open(os.path.expanduser(homePath + "/.sonic-pi/log/server-output.log")) as f: 628 | for line in f: 629 | m = re.search('^Listen port: *([0-9]+)', line) 630 | if m: 631 | return int(m.groups()[0]) 632 | except FileNotFoundError: 633 | return 4557 634 | 635 | 636 | def new_osc_message(self, string): 637 | """ Returns OSC message for Sonic Pi """ 638 | msg = OSC.OSCMessage("/run-code") 639 | msg.append(["0", string]) 640 | return msg 641 | 642 | @classmethod 643 | def find_comment(cls, string): 644 | instring, instring_char = False, "" 645 | for i, char in enumerate(string): 646 | if char in ('"', "'"): 647 | if instring: 648 | if char == instring_char: 649 | instring = False 650 | instring_char = "" 651 | else: 652 | instring = True 653 | instring_char = char 654 | elif char == "#": 655 | if not instring: 656 | return [(i, len(string))] 657 | return [] 658 | 659 | @classmethod 660 | def get_block_of_code(cls, text, index): 661 | """ Returns first and last line as Sonic Pi evaluates the whole code """ 662 | start, end = "1.0", text.index("end") 663 | return [int(index.split(".")[0]) for index in (start, end)] 664 | 665 | def stop_sound(self): 666 | return 'osc_send({!r}, {}, "/stop-all-jobs")'.format(self.host, self.port) 667 | 668 | 669 | # Set up ID system 670 | 671 | langtypes = { FOXDOT : FoxDotInterpreter, 672 | TIDAL : TidalInterpreter, 673 | SUPERCOLLIDER : SuperColliderInterpreter, 674 | SONICPI : SonicPiInterpreter, 675 | DUMMY : DummyInterpreter } 676 | 677 | for lang_id, lang_cls in langtypes.items(): 678 | lang_cls.id = lang_id 679 | -------------------------------------------------------------------------------- /src/logfile.py: -------------------------------------------------------------------------------- 1 | ### Experimental 2 | 3 | from __future__ import absolute_import 4 | 5 | from threading import Thread 6 | from time import sleep 7 | from .message import * 8 | 9 | class Log: 10 | text = None 11 | def __init__(self, filename): 12 | self.time = [] 13 | self.data = [] 14 | with open(filename) as f: 15 | 16 | for line in f.readlines(): 17 | 18 | time, message = line.split(" ", 1) 19 | 20 | time = float(time) 21 | 22 | message = message.strip()[1:-1].decode('string_escape') 23 | 24 | message = NetworkMessage(message)[0] 25 | 26 | if len(self.time) == 0: 27 | 28 | self.time.append(time) 29 | 30 | else: 31 | 32 | self.time.append(time-last_time) 33 | 34 | last_time = time 35 | 36 | self.data.append(message) 37 | 38 | self.thread = None 39 | 40 | def __len__(self): 41 | return len(self.data) 42 | 43 | def set_marker(self, peer): 44 | """ Sets this thread to imitate the client """ 45 | self.text = peer.root_parent 46 | for i in range(len(self.data)): 47 | self.data[i]['src_id'] = peer.id 48 | 49 | def recreate(self): 50 | """ Recreates the performance of the log """ 51 | self.thread = Thread(target=self.__run) 52 | self.thread.start() 53 | 54 | def stop(self): 55 | """ Stops recreating a log """ 56 | if self.thread.isAlive(): 57 | self.thread.join(1) 58 | return 59 | 60 | def __run(self): 61 | for i in range(len(self)): 62 | sleep(self.time[i]) 63 | self.text.push_queue.put(self.data[i]) 64 | 65 | if __name__ == "__main__": 66 | 67 | l = Log("../logs/log_test.txt") 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /src/message.py: -------------------------------------------------------------------------------- 1 | """ 2 | Server/message.py 3 | ----------------- 4 | 5 | Messages are sent as a series of arguments surrounnded by 6 | . 7 | 8 | """ 9 | 10 | from __future__ import absolute_import 11 | 12 | import re 13 | import inspect 14 | import json 15 | 16 | def escape_chars(s): 17 | return s.replace(">", "\>").replace("<", "\<") 18 | 19 | def unescape_chars(s): 20 | return s.replace("\>", ">").replace("\<", "<") 21 | 22 | class NetworkMessageReader: 23 | def __init__(self): 24 | self.string = "" 25 | self.re_msg = re.compile(r"<(.*?>?)>(?=<|$)", re.DOTALL) 26 | 27 | def findall(self, string): 28 | """ Returns all values in a message as a list """ 29 | return self.re_msg.findall(string) 30 | 31 | def convert_to_json(self, string): 32 | """ Un-escapes special characters and converts a string to a json object """ 33 | return json.loads(unescape_chars(string)) 34 | 35 | def feed(self, data): 36 | """ Adds text (read from server connection) and returns the complete messages within. Any 37 | text un-processed is stored and used the next time `feed` is called. """ 38 | 39 | # Most data is read from the server, which is bytes in Python3 and str in Python2, so make 40 | # sure it is properly decoded to a string. 41 | 42 | string = data.decode() if type(data) is bytes else data 43 | 44 | if string == "": 45 | 46 | raise EmptyMessageError() 47 | 48 | # Join with any existing text 49 | full_message = self.string + string 50 | 51 | # Identify message tags 52 | data = self.findall(full_message) 53 | 54 | # i is the data, pkg is the list of messages 55 | i, pkg = 0, [] 56 | 57 | # length is the size of the string processed 58 | length = 0 59 | 60 | while i < len(data): 61 | 62 | # Find out which message type it is 63 | 64 | cls = MESSAGE_TYPE[int(data[i])] 65 | 66 | # This tells us how many following items are arguments of this message 67 | 68 | j = len(cls.header()) 69 | 70 | try: 71 | 72 | # Collect the arguments 73 | 74 | args = [self.convert_to_json(data[n]) for n in range(i+1, i+j)] 75 | 76 | msg_id, args = args[0], args[1:] 77 | 78 | pkg.append(cls(*args)) 79 | 80 | pkg[-1].set_msg_id(msg_id) 81 | 82 | # Keep track of how much of the string we have processed 83 | 84 | length += len(str(pkg[-1])) 85 | 86 | except IndexError: 87 | 88 | # If there aren't enough arguments, return what we have so far 89 | 90 | break 91 | 92 | except TypeError as e: 93 | 94 | # Debug info 95 | 96 | print( cls.__name__, e ) 97 | print( string ) 98 | 99 | i += j 100 | 101 | # If we process the whole string, reset the stored string 102 | 103 | self.string = full_message[length:] 104 | 105 | return pkg 106 | 107 | 108 | class MESSAGE(object): 109 | """ Abstract base class """ 110 | data = {} 111 | keys = [] 112 | type = None 113 | def __init__(self, src_id, msg_id=0): 114 | self.data = {'src_id' : int(src_id), "type" : self.type, "msg_id": msg_id} 115 | self.keys = ['type', 'msg_id', 'src_id'] 116 | 117 | def __str__(self): 118 | return "".join([self.format(item) for item in self]) 119 | 120 | def set_msg_id(self, value): 121 | self.data["msg_id"] = int(value) 122 | 123 | @staticmethod 124 | def format(value): 125 | return "<{}>".format(escape_chars(json.dumps(value))) 126 | 127 | def bytes(self): 128 | return str(self).encode("utf-8") 129 | 130 | def raw_string(self): 131 | return "<{}>".format(self.type) + "".join(["<{}>".format(repr(item)) for item in self]) 132 | 133 | def __repr__(self): 134 | return str(self) 135 | 136 | def __len__(self): 137 | return len(self.data) 138 | 139 | def info(self): 140 | return self.__class__.__name__ + str(tuple(self)) 141 | 142 | def __iter__(self): 143 | for key in self.keys: 144 | yield self.data[key] 145 | 146 | def dict(self): 147 | return self.data 148 | 149 | def __getitem__(self, key): 150 | return self.data[key] 151 | 152 | def __setitem__(self, key, value): 153 | if key not in self.keys: 154 | self.keys.append(key) 155 | self.data[key] = value 156 | 157 | def __contains__(self, key): 158 | return key in self.data 159 | 160 | def __eq__(self, other): 161 | if isinstance(other, MESSAGE): 162 | return self.type == other.type and self.data == other.data 163 | else: 164 | return False 165 | 166 | def __ne__(self, other): 167 | if isinstance(other, MESSAGE): 168 | return self.type != other or self.data != other.data 169 | else: 170 | return True 171 | 172 | @staticmethod 173 | def compile(*args): 174 | return "".join(["<{}>".format(json.dumps(item)) for item in args]) 175 | 176 | @staticmethod 177 | def password(password): 178 | return MESSAGE.compile(-1, -1, password) 179 | 180 | @classmethod 181 | def header(cls): 182 | # args = inspect.getargspec(cls.__init__).args 183 | # args[0] = 'type' 184 | args = ['type', 'msg_id'] + inspect.getargspec(cls.__init__).args[1:] 185 | return args 186 | 187 | # Define types of message 188 | 189 | class MSG_CONNECT(MESSAGE): 190 | type = 1 191 | def __init__(self, src_id, name, hostname, port, dummy=False): 192 | MESSAGE.__init__(self, src_id) 193 | self['name'] = str(name) 194 | self['hostname'] = str(hostname) 195 | self['port'] = int(port) 196 | self['dummy'] = int(dummy) 197 | 198 | class MSG_OPERATION(MESSAGE): 199 | type = 2 200 | def __init__(self, src_id, operation, revision): 201 | MESSAGE.__init__(self, src_id) 202 | self["operation"] = [str(item) if not isinstance(item, int) else item for item in operation] 203 | self["revision"] = int(revision) 204 | 205 | class MSG_SET_MARK(MESSAGE): 206 | type = 3 207 | def __init__(self, src_id, index, reply=1): 208 | MESSAGE.__init__(self, src_id) 209 | self['index'] = int(index) 210 | self['reply'] = int(reply) 211 | 212 | class MSG_PASSWORD(MESSAGE): 213 | type = 4 214 | def __init__(self, src_id, password, name, version): 215 | MESSAGE.__init__(self, src_id) 216 | self['password']=str(password) 217 | self['name']=str(name) 218 | self['version']=str(version) 219 | 220 | class MSG_REMOVE(MESSAGE): 221 | type = 5 222 | def __init__(self, src_id): 223 | MESSAGE.__init__(self, src_id) 224 | 225 | class MSG_EVALUATE_STRING(MESSAGE): 226 | type = 6 227 | def __init__(self, src_id, string, reply=1): 228 | MESSAGE.__init__(self, src_id) 229 | self['string']=str(string) 230 | self['reply']=int(reply) 231 | 232 | class MSG_EVALUATE_BLOCK(MESSAGE): 233 | type = 7 234 | def __init__(self, src_id, start, end, reply=1): 235 | MESSAGE.__init__(self, src_id) 236 | self['start']=int(start) 237 | self['end']=int(end) 238 | self['reply']=int(reply) 239 | 240 | class MSG_GET_ALL(MESSAGE): 241 | type = 8 242 | def __init__(self, src_id): 243 | MESSAGE.__init__(self, src_id) 244 | 245 | class MSG_SET_ALL(MESSAGE): 246 | type = 9 247 | def __init__(self, src_id, document, peer_tag_loc, peer_loc): 248 | MESSAGE.__init__(self, src_id) 249 | self['document'] = str(document) 250 | self["peer_tag_loc"] = peer_tag_loc 251 | self["peer_loc"] = peer_loc 252 | 253 | class MSG_SELECT(MESSAGE): 254 | type = 10 255 | def __init__(self, src_id, start, end, reply=1): 256 | MESSAGE.__init__(self, src_id) 257 | self['start']=int(start) 258 | self['end']=int(end) 259 | self['reply']=int(reply) 260 | 261 | class MSG_RESET(MSG_SET_ALL): 262 | type = 11 263 | 264 | class MSG_KILL(MESSAGE): 265 | type = 12 266 | def __init__(self, src_id, string): 267 | MESSAGE.__init__(self, src_id) 268 | self['string']=str(string) 269 | 270 | class MSG_CONNECT_ACK(MESSAGE): 271 | type = 13 272 | def __init__(self, src_id, reply=0): 273 | MESSAGE.__init__(self, src_id) 274 | self["reply"] = reply 275 | 276 | class MSG_REQUEST_ACK(MESSAGE): 277 | type = 14 278 | def __init__(self, src_id, flag, reply=0): 279 | MESSAGE.__init__(self, src_id) 280 | self['flag'] = int(flag) 281 | self["reply"] = reply 282 | 283 | class MSG_CONSTRAINT(MESSAGE): 284 | type = 15 285 | def __init__(self, src_id, constraint_id): 286 | MESSAGE.__init__(self, src_id) 287 | self['constraint_id'] = int(constraint_id) 288 | # self.peer_id = int(peer) # 289 | 290 | class MSG_CONSOLE(MESSAGE): 291 | type = 16 292 | def __init__(self, src_id, string): 293 | MESSAGE.__init__(self, src_id) 294 | self['string'] = str(string) 295 | 296 | 297 | class MSG_KEEP_ALIVE(MESSAGE): 298 | type = 17 299 | def __init__(self, src_id=-1): 300 | MESSAGE.__init__(self, src_id) 301 | 302 | # Create a dictionary of message type to message class 303 | 304 | MESSAGE_TYPE = {msg.type : msg for msg in [ 305 | MSG_CONNECT, 306 | MSG_OPERATION, 307 | MSG_SET_ALL, 308 | MSG_GET_ALL, 309 | MSG_SET_MARK, 310 | MSG_SELECT, 311 | MSG_REMOVE, 312 | MSG_PASSWORD, 313 | MSG_KILL, 314 | MSG_EVALUATE_BLOCK, 315 | MSG_EVALUATE_STRING, 316 | MSG_RESET, 317 | MSG_CONNECT_ACK, 318 | MSG_REQUEST_ACK, 319 | MSG_CONSTRAINT, 320 | MSG_CONSOLE, 321 | MSG_KEEP_ALIVE, 322 | ] 323 | } 324 | 325 | # Exceptions 326 | 327 | class EmptyMessageError(Exception): 328 | def __init__(self): 329 | self.value = "Message contained no data" 330 | def __str__(self): 331 | return repr(self.value) 332 | 333 | class ConnectionError(Exception): 334 | def __init__(self, value): 335 | self.value = value 336 | def __str__(self): 337 | return repr(self.value) 338 | 339 | class DeadClientError(Exception): 340 | def __init__(self, name): 341 | self.name = name 342 | def __str__(self): 343 | return "DeadClientError: Could not connect to {}".format(self.name) 344 | 345 | 346 | 347 | if __name__ == "__main__": 348 | 349 | msg = MSG_SET_MARK(42, 24) 350 | print(msg,) 351 | print(msg.header()) -------------------------------------------------------------------------------- /src/ot/__init__.py: -------------------------------------------------------------------------------- 1 | __title__ = 'ot' 2 | __version__ = '0.0.1' 3 | __author__ = 'Tim Baumann' 4 | __license__ = 'MIT' 5 | -------------------------------------------------------------------------------- /src/ot/client.py: -------------------------------------------------------------------------------- 1 | # I have adopted the naming convention from Daniel Spiewak's CCCP: 2 | # https://github.com/djspiewak/cccp/blob/master/agent/src/main/scala/com/codecommit/cccp/agent/state.scala 3 | 4 | 5 | class Client(object): 6 | """Handles the client part of the OT synchronization protocol. Transforms 7 | incoming operations from the server, buffers operations from the user and 8 | sends them to the server at the right time. 9 | """ 10 | 11 | def __init__(self, revision): 12 | self.revision = revision 13 | self.state = synchronized 14 | 15 | def reset(self): 16 | self.revision = 0 17 | self.state = synchronized 18 | 19 | def apply_client(self, operation): 20 | """Call this method when the user (!) changes the document.""" 21 | self.state = self.state.apply_client(self, operation) 22 | 23 | def apply_server(self, operation): 24 | """Call this method with a new operation from the server.""" 25 | self.revision += 1 26 | self.state = self.state.apply_server(self, operation) 27 | 28 | def server_ack(self): 29 | """Call this method when the server acknowledges an operation send by 30 | the current user (via the send_operation method) 31 | """ 32 | self.revision += 1 33 | self.state = self.state.server_ack(self) 34 | 35 | def send_operation(self, revision, operation): 36 | """Should send an operation and its revision number to the server.""" 37 | raise NotImplementedError("You have to override 'send_operation' in your Client child class") 38 | 39 | def apply_operation(self, operation): 40 | """Should apply an operation from the server to the current document.""" 41 | raise NotImplementedError("You have to overrid 'apply_operation' in your Client child class") 42 | 43 | 44 | class Synchronized(object): 45 | """In the 'Synchronized' state, there is no pending operation that the client 46 | has sent to the server. 47 | """ 48 | 49 | def apply_client(self, client, operation): 50 | # When the user makes an edit, send the operation to the server and 51 | # switch to the 'AwaitingConfirm' state 52 | client.send_operation(client.revision, operation) 53 | return AwaitingConfirm(operation) 54 | 55 | def apply_server(self, client, operation): 56 | # When we receive a new operation from the server, the operation can be 57 | # simply applied to the current document 58 | client.apply_operation(operation) 59 | return self 60 | 61 | def server_ack(self, client): 62 | raise RuntimeError("There is no pending operation.") 63 | 64 | 65 | # Singleton 66 | synchronized = Synchronized() 67 | 68 | 69 | class AwaitingConfirm(object): 70 | """In the 'awaitingConfirm' state, there's one operation the client has sent 71 | to the server and is still waiting for an acknowledgement. 72 | """ 73 | 74 | def __init__(self, outstanding): 75 | # Save the pending operation 76 | self.outstanding = outstanding 77 | 78 | def apply_client(self, client, operation): 79 | # When the user makes an edit, don't send the operation immediately, 80 | # instead switch to the 'AwaitingWithBuffer' state 81 | return AwaitingWithBuffer(self.outstanding, operation) 82 | 83 | def apply_server(self, client, operation): 84 | # /\ 85 | # self.outstanding / \ operation 86 | # / \ 87 | # \ / 88 | # operation_p \ / outstanding_p (new self.outstanding) 89 | # (can be applied \/ 90 | # to the client's 91 | # current document) 92 | Operation = self.outstanding.__class__ 93 | (outstanding_p, operation_p) = Operation.transform(self.outstanding, operation) 94 | client.apply_operation(operation_p) 95 | return AwaitingConfirm(outstanding_p) 96 | 97 | def server_ack(self, client): 98 | return synchronized 99 | 100 | 101 | class AwaitingWithBuffer(object): 102 | """In the 'awaitingWithBuffer' state, the client is waiting for an operation 103 | to be acknowledged by the server while buffering the edits the user makes 104 | """ 105 | 106 | def __init__(self, outstanding, buffer): 107 | # Save the pending operation and the user's edits since then 108 | self.outstanding = outstanding 109 | self.buffer = buffer 110 | 111 | def apply_client(self, client, operation): 112 | # Compose the user's changes onto the buffer 113 | newBuffer = self.buffer.compose(operation) 114 | return AwaitingWithBuffer(self.outstanding, newBuffer) 115 | 116 | def apply_server(self, client, operation): 117 | # /\ 118 | # self.outstanding / \ operation 119 | # / \ 120 | # /\ / 121 | # self.buffer / \* / outstanding_p 122 | # / \/ 123 | # \ / 124 | # operation_pp \ / buffer_p 125 | # \/ 126 | # the transformed 127 | # operation -- can 128 | # be applied to the 129 | # client's current 130 | # document 131 | # 132 | # * operation_p 133 | Operation = self.outstanding.__class__ 134 | (outstanding_p, operation_p) = Operation.transform(self.outstanding, operation) 135 | (buffer_p, operation_pp) = Operation.transform(self.buffer, operation_p) 136 | client.apply_operation(operation_pp) 137 | return AwaitingWithBuffer(outstanding_p, buffer_p) 138 | 139 | def server_ack(self, client): 140 | # The pending operation has been acknowledged 141 | # => send buffer 142 | client.send_operation(client.revision, self.buffer) 143 | return AwaitingConfirm(self.buffer) 144 | -------------------------------------------------------------------------------- /src/ot/server.py: -------------------------------------------------------------------------------- 1 | class MemoryBackend(object): 2 | """Simple backend that saves all operations in the server's memory. This 3 | causes the processe's heap to grow indefinitely. 4 | """ 5 | 6 | def __init__(self, operations=[]): 7 | self.operations = operations[:] 8 | self.last_operation = {} 9 | 10 | def save_operation(self, user_id, operation): 11 | """Save an operation in the database.""" 12 | self.last_operation[user_id] = len(self.operations) 13 | self.operations.append(operation) 14 | 15 | def get_operations(self, start, end=None): 16 | """Return operations in a given range.""" 17 | return self.operations[start:end] 18 | 19 | def get_last_revision_from_user(self, user_id): 20 | """Return the revision number of the last operation from a given user.""" 21 | return self.last_operation.get(user_id, None) 22 | 23 | 24 | class Server(object): 25 | """Receives operations from clients, transforms them against all 26 | concurrent operations and sends them back to all clients. 27 | """ 28 | 29 | def __init__(self, document, backend): 30 | self.document = document 31 | self.backend = backend 32 | 33 | def receive_operation(self, user_id, revision, operation): 34 | """Transforms an operation coming from a client against all concurrent 35 | operation, applies it to the current document and returns the operation 36 | to send to the clients. 37 | """ 38 | 39 | last_by_user = self.backend.get_last_revision_from_user(user_id) 40 | if last_by_user and last_by_user >= revision: 41 | return 42 | 43 | Operation = operation.__class__ 44 | 45 | concurrent_operations = self.backend.get_operations(revision) 46 | for concurrent_operation in concurrent_operations: 47 | (operation, _) = Operation.transform(operation, concurrent_operation) 48 | 49 | self.document = operation(self.document) 50 | 51 | self.backend.save_operation(user_id, operation) 52 | 53 | return operation 54 | -------------------------------------------------------------------------------- /src/ot/text_operation.py: -------------------------------------------------------------------------------- 1 | # Operations are lists of ops. There are three types of ops: 2 | # 3 | # * Insert ops: insert a given string at the current cursor position. 4 | # Represented by strings. 5 | # * Retain ops: Advance the cursor position by a given number of characters. 6 | # Represented by positive ints. 7 | # * Delete ops: Delete the next n characters. Represented by negative ints. 8 | 9 | 10 | def _is_retain(op): 11 | return isinstance(op, int) and op > 0 12 | 13 | 14 | def _is_delete(op): 15 | return isinstance(op, int) and op < 0 16 | 17 | 18 | def _is_insert(op): 19 | return isinstance(op, str) 20 | 21 | 22 | def _op_len(op): 23 | if isinstance(op, str): 24 | return len(op) 25 | if op < 0: 26 | return -op 27 | return op 28 | 29 | 30 | def _shorten(op, by): 31 | if isinstance(op, str): 32 | return op[by:] 33 | if op < 0: 34 | return op + by 35 | return op - by 36 | 37 | 38 | def _shorten_ops(a, b): 39 | """Shorten two ops by the part that cancels each other out.""" 40 | 41 | len_a = _op_len(a) 42 | len_b = _op_len(b) 43 | if len_a == len_b: 44 | return (None, None) 45 | if len_a > len_b: 46 | return (_shorten(a, len_b), None) 47 | return (None, _shorten(b, len_a)) 48 | 49 | 50 | class TextOperation(object): 51 | """Diff between two strings.""" 52 | 53 | def __init__(self, ops=[]): 54 | self.ops = ops[:] 55 | 56 | def __repr__(self): 57 | return "O({})".format(self.ops) 58 | 59 | def __eq__(self, other): 60 | return isinstance(other, TextOperation) and self.ops == other.ops 61 | 62 | def __iter__(self): 63 | return self.ops.__iter__() 64 | 65 | def __add__(self, other): 66 | return self.compose(other) 67 | 68 | def len_difference(self): 69 | """Returns the difference in length between the input and the output 70 | string when this operations is applied. 71 | """ 72 | s = 0 73 | for op in self: 74 | if isinstance(op, str): 75 | s += len(op) 76 | elif op < 0: 77 | s += op 78 | return s 79 | 80 | def retain(self, r): 81 | """Skips a given number of characters at the current cursor position.""" 82 | 83 | if r == 0: 84 | return self 85 | if len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] > 0: 86 | self.ops[-1] += r 87 | else: 88 | self.ops.append(r) 89 | return self 90 | 91 | def insert(self, s): 92 | """Inserts the given string at the current cursor position.""" 93 | 94 | if len(s) == 0: 95 | return self 96 | if len(self.ops) > 0 and isinstance(self.ops[-1], str): 97 | self.ops[-1] += s 98 | elif len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] < 0: 99 | # It doesn't matter when an operation is applied whether the operation 100 | # is delete(3), insert("something") or insert("something"), delete(3). 101 | # Here we enforce that in this case, the insert op always comes first. 102 | # This makes all operations that have the same effect when applied to 103 | # a document of the right length equal in respect to the `equals` method. 104 | if len(self.ops) > 1 and isinstance(self.ops[-2], str): 105 | self.ops[-2] += s 106 | else: 107 | self.ops.append(self.ops[-1]) 108 | self.ops[-2] = s 109 | else: 110 | self.ops.append(s) 111 | return self 112 | 113 | def delete(self, d): 114 | """Deletes a given number of characters at the current cursor position.""" 115 | 116 | if d == 0: 117 | return self 118 | if d > 0: 119 | d = -d 120 | if len(self.ops) > 0 and isinstance(self.ops[-1], int) and self.ops[-1] < 0: 121 | self.ops[-1] += d 122 | else: 123 | self.ops.append(d) 124 | return self 125 | 126 | def __call__(self, doc): 127 | """Apply this operation to a string, returning a new string.""" 128 | 129 | i = 0 130 | parts = [] 131 | 132 | for op in self: 133 | if _is_retain(op): 134 | if i + op > len(doc): 135 | raise IncompatibleOperationError("Cannot apply operation: operation is too long.") 136 | parts.append(doc[i:(i + op)]) 137 | i += op 138 | elif _is_insert(op): 139 | parts.append(op) 140 | else: 141 | i -= op 142 | if i > len(doc): 143 | raise IncompatibleOperationError("Cannot apply operation: operation is too long.") 144 | 145 | if i != len(doc): 146 | raise IncompatibleOperationError("Cannot apply operation: operation is too short.") 147 | 148 | return ''.join(parts) 149 | 150 | def invert(self, doc): 151 | """Make an operation that does the opposite. When you apply an operation 152 | to a string and then the operation generated by this operation, you 153 | end up with your original string. This method can be used to implement 154 | undo. 155 | """ 156 | 157 | i = 0 158 | inverse = TextOperation() 159 | 160 | for op in self: 161 | if _is_retain(op): 162 | inverse.retain(op) 163 | i += op 164 | elif _is_insert(op): 165 | inverse.delete(len(op)) 166 | else: 167 | inverse.insert(doc[i:(i - op)]) 168 | i -= op 169 | 170 | return inverse 171 | 172 | def compose(self, other): 173 | """Combine two consecutive operations into one that has the same effect 174 | when applied to a document. 175 | """ 176 | 177 | iter_a = iter(self) 178 | iter_b = iter(other) 179 | operation = TextOperation() 180 | 181 | a = b = None 182 | while True: 183 | if a == None: 184 | a = next(iter_a, None) 185 | if b == None: 186 | b = next(iter_b, None) 187 | 188 | if a == b == None: 189 | # end condition: both operations have been processed 190 | break 191 | 192 | if _is_delete(a): 193 | operation.delete(a) 194 | a = None 195 | continue 196 | if _is_insert(b): 197 | operation.insert(b) 198 | b = None 199 | continue 200 | 201 | if a == None: 202 | print(self.ops, other.ops) 203 | raise IncompatibleOperationError("Cannot compose operations: first operation is too short") 204 | if b == None: 205 | raise IncompatibleOperationError("Cannot compose operations: first operation is too long") 206 | 207 | min_len = min(_op_len(a), _op_len(b)) 208 | if _is_retain(a) and _is_retain(b): 209 | operation.retain(min_len) 210 | elif _is_insert(a) and _is_retain(b): 211 | operation.insert(a[:min_len]) 212 | elif _is_retain(a) and _is_delete(b): 213 | operation.delete(min_len) 214 | # remaining case: _is_insert(a) and _is_delete(b) 215 | # in this case the delete op deletes the text that has been added 216 | # by the insert operation and we don't need to do anything 217 | 218 | (a, b) = _shorten_ops(a, b) 219 | 220 | return operation 221 | 222 | @staticmethod 223 | def transform(operation_a, operation_b): 224 | """Transform two operations a and b to a' and b' such that b' applied 225 | after a yields the same result as a' applied after b. Try to preserve 226 | the operations' intentions in the process. 227 | """ 228 | 229 | iter_a = iter(operation_a) 230 | iter_b = iter(operation_b) 231 | a_prime = TextOperation() 232 | b_prime = TextOperation() 233 | a = b = None 234 | 235 | while True: 236 | if a == None: 237 | a = next(iter_a, None) 238 | if b == None: 239 | b = next(iter_b, None) 240 | 241 | if a == b == None: 242 | # end condition: both operations have been processed 243 | break 244 | 245 | if _is_insert(a): 246 | a_prime.insert(a) 247 | b_prime.retain(len(a)) 248 | a = None 249 | continue 250 | if _is_insert(b): 251 | a_prime.retain(len(b)) 252 | b_prime.insert(b) 253 | b = None 254 | continue 255 | 256 | if a == None: 257 | raise IncompatibleOperationError("Cannot compose operations: first operation is too short") 258 | if b == None: 259 | raise IncompatibleOperationError("Cannot compose operations: first operation is too long") 260 | 261 | min_len = min(_op_len(a), _op_len(b)) 262 | if _is_retain(a) and _is_retain(b): 263 | a_prime.retain(min_len) 264 | b_prime.retain(min_len) 265 | elif _is_delete(a) and _is_retain(b): 266 | a_prime.delete(min_len) 267 | elif _is_retain(a) and _is_delete(b): 268 | b_prime.delete(min_len) 269 | # remaining case: _is_delete(a) and _is_delete(b) 270 | # in this case both operations delete the same string and we don't 271 | # need to do anything 272 | 273 | (a, b) = _shorten_ops(a, b) 274 | 275 | return (a_prime, b_prime) 276 | 277 | 278 | class IncompatibleOperationError(Exception): 279 | """Two operations or an operation and a string have different lengths.""" 280 | pass -------------------------------------------------------------------------------- /src/receiver.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client/Receiver.py 3 | ------------------ 4 | 5 | This listens for incoming messages from the TroopServer 6 | 7 | """ 8 | from __future__ import absolute_import 9 | from .threadserv import ThreadedServer 10 | from .message import * 11 | from .config import * 12 | 13 | import socket 14 | from threading import Thread 15 | from time import sleep 16 | 17 | class Receiver: 18 | """ 19 | Listens for messages from a remote FoxDot Server instance 20 | and send keystroke data 21 | 22 | """ 23 | 24 | def __init__(self, client, socket): 25 | 26 | self.client = client 27 | 28 | self.sock = socket 29 | self.address = self.sock.getsockname() 30 | 31 | self.thread = Thread(target=self.handle) 32 | self.thread.daemon = True 33 | self.running = False 34 | self.bytes = 2048 35 | 36 | self.reader = NetworkMessageReader() 37 | 38 | # Information about other clients 39 | 40 | self.nodes = {} 41 | 42 | # Information about the text widget 43 | 44 | self.ui = None 45 | 46 | def __call__(self, client_id, attr): 47 | """ Returns the information about a connected client """ 48 | return getattr(self.nodes[client_id], attr, None) 49 | 50 | def get_id(self): 51 | """ Returns the client_id nunmber for the local client """ 52 | for node_id, node in self.nodes.items(): 53 | if node == self.address: 54 | return node_id 55 | 56 | def start(self): 57 | self.running = True 58 | self.thread.start() 59 | 60 | def kill(self): 61 | self.running = False 62 | self.sock.close() 63 | return 64 | 65 | def handle(self): 66 | 67 | while self.running: 68 | 69 | try: 70 | 71 | packet = self.reader.feed(self.sock.recv(self.bytes)) 72 | 73 | # We get None if there was a socket error 74 | 75 | if packet is None: 76 | 77 | raise EmptyMessageError 78 | 79 | except(OSError, socket.error) as e: 80 | 81 | print(e) 82 | 83 | self.kill() 84 | 85 | break 86 | 87 | # Ignore empty message errors if we are no longer running 88 | 89 | except EmptyMessageError as e: 90 | 91 | if self.client.is_alive: 92 | 93 | raise(e) 94 | 95 | else: 96 | 97 | pass 98 | 99 | for msg in packet: 100 | 101 | # Create a new client node if it is a connect message 102 | 103 | if isinstance(msg, MSG_CONNECT): 104 | 105 | self.nodes[msg['src_id']] = Node(**msg.dict()) 106 | 107 | # Update the interface based on the message 108 | 109 | self.update_text(msg) 110 | 111 | return 112 | 113 | def update_text(self, message): 114 | ''' Add a Troop message to the Queue ''' 115 | while self.ui is None: 116 | sleep(0.1) 117 | self.ui.text.put(message) 118 | return 119 | 120 | class Node: 121 | """ Class for basic information on other nodes within the network. 122 | """ 123 | def __init__(self, **kwargs): 124 | self.__dict__.update(kwargs) 125 | def __repr__(self): 126 | return "{}: {}".format(self.hostname, self.port) 127 | def __eq__(self, other): 128 | return self.address == other 129 | def __ne__(self, other): 130 | return self.address != other 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /src/sender.py: -------------------------------------------------------------------------------- 1 | """ 2 | Client/Sender.py 3 | ------------------ 4 | 5 | Sends messages *to* the TroopServer 6 | 7 | """ 8 | 9 | from __future__ import absolute_import 10 | from .message import * 11 | from .config import * 12 | from .utils import * 13 | 14 | import socket 15 | from hashlib import md5 16 | 17 | class Sender: 18 | """ 19 | Sends messages to the Server 20 | 21 | """ 22 | def __init__(self, client): 23 | 24 | self.client = client 25 | 26 | self.hostname = None 27 | self.port = None 28 | self.address = None 29 | self.name = None 30 | 31 | self.conn = None 32 | self.conn_id = None 33 | self.connected = False 34 | self.connection_errors = { 35 | ERR_LOGIN_FAIL : "Login attempt failed", 36 | ERR_MAX_LOGINS : "Failed to connect: Maximum number of users connected. Please try again later.", 37 | ERR_NAME_TAKEN : "A user with that name has already connected from your location.", 38 | ERR_VERSION_MISMATCH: "Your client, Troop v{}, does not match the version of the server. Please update your versions to match before connecting.".format(self.client.version) 39 | } 40 | 41 | self.ui = None 42 | 43 | def connect(self, hostname, port=57890, username="", using_ipv6=False, password=""): 44 | """ Connects to the master Troop server and 45 | start a listening instance on this machine """ 46 | if not self.connected: 47 | 48 | # Get details of remote 49 | self.hostname = hostname 50 | self.port = int(port) 51 | self.address = (self.hostname, self.port) 52 | 53 | self.name = username 54 | 55 | # Connect to remote 56 | 57 | try: 58 | 59 | if using_ipv6: 60 | 61 | self.socket_type = socket.AF_INET6 62 | 63 | self.address = (self.hostname, self.port, 0, 0) 64 | 65 | else: 66 | 67 | self.socket_type = socket.AF_INET 68 | 69 | self.conn = socket.socket(self.socket_type, socket.SOCK_STREAM) 70 | 71 | self.conn.connect(self.address) 72 | 73 | except Exception as e: 74 | 75 | raise(e) 76 | 77 | raise(ConnectionError("Could not connect to host '{}'".format( self.hostname ) ) ) 78 | 79 | # Send the password 80 | 81 | self.conn_msg = MSG_PASSWORD(-1, md5(password.encode("utf-8")).hexdigest(), self.name, self.client.version) 82 | 83 | self.send( self.conn_msg ) 84 | 85 | self.conn_id = int(self.conn.recv(4)) # careful here 86 | self.connected = bool(self.conn_id >= 0) 87 | 88 | return self 89 | 90 | def send(self, message): 91 | return self.__call__(message) 92 | 93 | def error_message(self): 94 | return self.connection_errors.get(self.conn_id, "Connected successfully") 95 | 96 | def __call__(self, message): 97 | """ Send data to the server """ 98 | try: 99 | 100 | self.conn.sendall(message.bytes()) 101 | 102 | except Exception as e: 103 | 104 | # Don't raise exceptions if we already killed client 105 | 106 | if self.client.is_alive: 107 | 108 | print(e) 109 | 110 | raise ConnectionError("Can't connect to server") 111 | 112 | return 113 | 114 | def kill(self): 115 | self.conn.close() 116 | return -------------------------------------------------------------------------------- /src/server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Troop Server 3 | ------------ 4 | 5 | Real-time collaborative Live Coding with FoxDot and SuperCollder. 6 | 7 | Sits on a machine (can be a performer machine) and listens for incoming 8 | connections and messages and distributes these to other connected peers. 9 | 10 | """ 11 | 12 | from __future__ import absolute_import 13 | 14 | try: 15 | import socketserver 16 | except ImportError: 17 | import SocketServer as socketserver 18 | 19 | try: 20 | import queue 21 | except: 22 | import Queue as queue 23 | 24 | import socket 25 | import sys 26 | import time 27 | import os.path 28 | import json 29 | 30 | from datetime import datetime 31 | from time import sleep 32 | from getpass import getpass 33 | from hashlib import md5 34 | from threading import Thread 35 | 36 | from .threadserv import ThreadedServer 37 | from .message import * 38 | from .interpreter import * 39 | from .config import * 40 | from .utils import * 41 | from .ot.server import Server as OTServer, MemoryBackend 42 | from .ot.text_operation import TextOperation, IncompatibleOperationError as OTError 43 | 44 | 45 | class TroopServer(OTServer): 46 | """ 47 | This the master Server instance. Other peers on the 48 | network connect to it and send their keypress information 49 | to the server, which then sends it on to the others 50 | """ 51 | bytes = 2048 52 | version = VERSION 53 | def __init__(self, password="", port=57890, log=False, debug=False, keepalive=False): 54 | 55 | # Operation al transform info 56 | 57 | OTServer.__init__(self, "", MemoryBackend()) 58 | self.peer_tag_doc = "" 59 | 60 | # Address information 61 | # self.hostname = str(socket.gethostname()) 62 | self.hostname = socket.gethostbyname("localhost") 63 | 64 | # Listen on any IP 65 | self.ip_addr = "0.0.0.0" 66 | self.port = int(port) 67 | 68 | # Public ip for server is the first IPv4 address we find, else just show the hostname 69 | self.ip_pub = self.hostname 70 | 71 | try: 72 | 73 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 74 | s.connect(("8.8.8.8", 80)) 75 | self.ip_pub = s.getsockname()[0] 76 | s.close() 77 | 78 | except OSError: 79 | 80 | pass 81 | 82 | # Look for an empty port 83 | port_found = False 84 | 85 | while not port_found: 86 | 87 | try: 88 | 89 | self.server = ThreadedServer((self.ip_addr, self.port), TroopRequestHandler) 90 | port_found = True 91 | 92 | except socket.error: 93 | 94 | self.port += 1 95 | 96 | # Is keep alive enabled? 97 | self.keepalive_enabled = False 98 | 99 | # Reference to the thread that is listening for new connections 100 | self.server_thread = Thread(target=self.server.serve_forever) 101 | 102 | self.waiting_for_ack = False # Flagged True after new connected client 103 | 104 | self.text_constraint = MSG_CONSTRAINT(-1, 0) # default 105 | 106 | # Dict of IDs to Client instances 107 | self.clients = {} 108 | 109 | # ID numbers 110 | self.max_id = len(PEER_CHARS) - 1 111 | self.last_id = -1 112 | 113 | # Give request handler information about this server 114 | TroopRequestHandler.master = self 115 | 116 | # Set a password for the server 117 | try: 118 | 119 | self.password = md5(password.encode("utf-8")) 120 | 121 | except KeyboardInterrupt: 122 | 123 | sys.exit("Exited") 124 | 125 | # Set up a char queue 126 | self.msg_queue = queue.Queue() 127 | self.msg_queue_thread = Thread(target=self.update_send) 128 | 129 | # Set up log for logging a performance 130 | 131 | if log: 132 | 133 | # Check if there is a logs folder, if not create it 134 | 135 | log_folder = os.path.join(ROOT_DIR, "logs") 136 | 137 | if not os.path.exists(log_folder): 138 | 139 | os.mkdir(log_folder) 140 | 141 | # Create filename based on date and times 142 | 143 | self.fn = time.strftime("server-log-%d%m%y_%H%M%S.txt", time.localtime()) 144 | path = os.path.join(log_folder, self.fn) 145 | 146 | self.log_file = open(path, "w") 147 | self.is_logging = True 148 | 149 | else: 150 | 151 | self.is_logging = False 152 | self.log_file = None 153 | 154 | def get_client_from_addr(self, client_hostname, username): 155 | """ Returns the server-side representation of a client 156 | using the client address tuple """ 157 | for client in list(self.clients.values()): 158 | #if client.hostname == client_hostname and client.name == username: 159 | if client == (client_hostname, username): 160 | return client 161 | 162 | def get_client(self, client_id): 163 | """ Returns the client instance based on the id """ 164 | return self.clients[client_id] 165 | 166 | def get_client_locs(self): 167 | return { int(client.id): int(client.index) for client in list(self.clients.values()) } 168 | 169 | def get_client_ranges(self): 170 | """ Converts the peer_tag_doc into pairs of tuples to be reconstructed by the client """ 171 | if len(self.peer_tag_doc) == 0: 172 | return [] 173 | else: 174 | data = [] 175 | p_char = self.peer_tag_doc[0] 176 | count = 1 177 | for char in self.peer_tag_doc[1:]: 178 | if char != p_char: 179 | data.append((get_peer_id_from_char(p_char), int(count))) 180 | p_char = char 181 | count = 1 182 | else: 183 | count += 1 184 | if count > 0: 185 | data.append((get_peer_id_from_char(p_char), int(count))) 186 | return data 187 | 188 | def get_text_constraint(self): 189 | return self.text_constraint 190 | 191 | def get_contents(self): 192 | return [self.document, self.get_client_ranges(), self.get_client_locs()] 193 | 194 | def update_all_clients(self): 195 | """ Sends a reset message with the contents from the server to make sure new user starts the same """ 196 | 197 | msg = MSG_RESET(-1, *self.get_contents()) 198 | 199 | for client in list(self.clients.values()): 200 | 201 | # Tell other clients about the new connection 202 | 203 | if client.connected: 204 | 205 | client.send(msg) 206 | client.send(self.get_text_constraint()) 207 | 208 | return 209 | 210 | # Operation info 211 | # ============== 212 | 213 | def handle_operation(self, message): 214 | """ Handles a new MSG_OPERATION by updating the document, performing operational transformation 215 | (if necessary) on it and storing it. """ 216 | 217 | # Apply to document 218 | try: 219 | op = self.receive_operation(message["src_id"], message["revision"], TextOperation(message["operation"])) 220 | 221 | # debug 222 | except OTError as err: 223 | 224 | print(self.document, message["operation"]) 225 | 226 | raise err 227 | 228 | # Returns None if there are inconsistencies in revision numbers 229 | # (if last_by_user and last_by_user >= revision) 230 | if op is None: 231 | 232 | return 233 | 234 | message["operation"] = op.ops 235 | 236 | # Apply to peer tags 237 | peer_op = TextOperation([get_peer_char(message["src_id"]) * len(val) if isinstance(val, str) else val for val in op.ops]) 238 | self.peer_tag_doc = peer_op(self.peer_tag_doc) 239 | 240 | # Get location of peer 241 | client = self.clients[message["src_id"]] 242 | client.set_index(get_operation_index(message["operation"])) 243 | 244 | return message 245 | 246 | def handle_set_mark(self, message): 247 | """ Handles a new MSG_SET_MARK by updating the client model's index """ 248 | client = self.clients[message["src_id"]] 249 | client.set_index(int(message["index"])) 250 | return message 251 | 252 | def set_contents(self, data): 253 | """ Updates the document contents, including the location of user text ranges and marks """ 254 | for key, value in data.items(): 255 | self.contents[key] = value 256 | return 257 | 258 | def start(self): 259 | 260 | self.running = True 261 | self.server_thread.start() 262 | self.msg_queue_thread.start() 263 | 264 | stdout("Server running @ {} on port {}. Ver. {}\n".format(self.ip_pub, self.port, self.version)) 265 | 266 | while True: 267 | 268 | try: 269 | 270 | # Poll users to make sure they are connected 271 | 272 | if self.keepalive_enabled: 273 | 274 | self.msg_queue.put(MSG_KEEP_ALIVE()) 275 | 276 | self.purge_client_timeouts() 277 | 278 | sleep(1) 279 | 280 | except KeyboardInterrupt: 281 | 282 | stdout("\nStopping...\n") 283 | 284 | self.kill() 285 | 286 | break 287 | return 288 | 289 | def get_next_id(self): 290 | """ Increases the ID counter and returns it. If it goes over the maximum number allowed, it tries to go to back to the start and 291 | checks if that client is connected. If all clients are connected, it returns -1, signalling the client to terminate """ 292 | if self.last_id < self.max_id: 293 | self.last_id += 1 294 | else: 295 | for n in list(range(self.last_id, self.max_id)) + list(range(self.last_id)): 296 | if n not in self.clients: 297 | self.last_id = n 298 | else: 299 | return ERR_MAX_LOGINS # error message for max clients exceeded 300 | return self.last_id 301 | 302 | def clear_history(self): 303 | """ Removes revision history - make sure clients' revision numbers reset """ 304 | self.backend = MemoryBackend() 305 | self.msg_queue = queue.Queue() 306 | return 307 | 308 | def wait_for_ack(self, flag): 309 | """ Sets flag to disregard messages that are not MSG_CONNECT_ACK until all clients have responded """ 310 | if flag == True: 311 | 312 | self.waiting_for_ack = True 313 | self.acknowledged_clients = [] 314 | 315 | for client in list(self.clients.values()): 316 | 317 | if client.connected: 318 | 319 | client.send(MSG_REQUEST_ACK(-1, int(flag))) 320 | 321 | return 322 | 323 | def connect_ack(self, message): 324 | """ Handle response from clients confirming the new connected client """ 325 | 326 | client_id = message["src_id"] 327 | 328 | self.acknowledged_clients.append(client_id) 329 | 330 | # When we have all clients acknowledged, stop waiting 331 | 332 | if all([client_id in self.acknowledged_clients for client_id in self.connected_clients()]): 333 | 334 | # Send set_text to all to reset the text 335 | 336 | self.update_all_clients() 337 | 338 | # Stop waiting 339 | 340 | self.waiting_for_ack = False 341 | 342 | self.acknowledged_clients = [] 343 | 344 | self.wait_for_ack(False) 345 | 346 | return 347 | 348 | def connected_clients(self): 349 | """ Returns a list of all the connected clients_id's """ 350 | return (client_id for client_id, client in self.clients.items() if client.connected) 351 | 352 | def purge_client_timeouts(self): 353 | """ 354 | Iterate over connected client and check if they have 355 | received keepalive messages recently. Disconnect those 356 | that has passed the timeout periods 357 | """ 358 | for client_id in self.connected_clients(): 359 | client = self.get_client(client_id) 360 | if client.has_timedout(): 361 | self.remove_client(client_id) 362 | return 363 | 364 | @staticmethod 365 | def read_configuration_file(filename): 366 | conf = {} 367 | with open(filename) as f: 368 | for line in f.readlines(): 369 | line = line.strip().split("=") 370 | conf[line[0]] = line[1] 371 | return conf['host'], int(conf['port']) 372 | 373 | def update_send(self): 374 | """ This continually sends any operations to clients 375 | """ 376 | 377 | while self.running: 378 | 379 | try: 380 | 381 | msg = self.msg_queue.get_nowait() 382 | 383 | # If logging is set to true, store the message info 384 | 385 | if self.is_logging: 386 | 387 | self.log_file.write("%.4f" % time.clock() + " " + repr(str(msg)) + "\n") 388 | 389 | # Store the response of the messages 390 | 391 | if isinstance(msg, MSG_OPERATION): 392 | 393 | msg = self.handle_operation(msg) 394 | 395 | elif isinstance(msg, MSG_SET_MARK): 396 | 397 | msg = self.handle_set_mark(msg) 398 | 399 | elif isinstance(msg, MSG_CONSTRAINT): 400 | 401 | self.text_constraint = msg 402 | 403 | self.respond(msg) 404 | 405 | except queue.Empty: 406 | 407 | sleep(0.01) 408 | 409 | return 410 | 411 | def respond(self, msg): 412 | """ Update all clients with a message. Only sends back messages to 413 | a client if the `reply` flag is nonzero. """ 414 | 415 | if msg is None: 416 | 417 | return 418 | 419 | for client in list(self.clients.values()): 420 | 421 | if client.connected: 422 | 423 | try: 424 | 425 | # Send to all other clients and the sender if "reply" flag is true 426 | 427 | if not self.waiting_for_ack: 428 | 429 | if (client.id != msg['src_id']) or ('reply' not in msg.data) or (msg['reply'] == 1): 430 | 431 | client.send(msg) 432 | 433 | except DeadClientError as err: 434 | 435 | # Remove client if no longer contactable 436 | 437 | self.remove_client(client.id) 438 | 439 | print(err) 440 | 441 | return 442 | 443 | def remove_client(self, client_id): 444 | 445 | # Remove from list(s) 446 | 447 | if client_id in self.clients: 448 | 449 | self.clients[client_id].disconnect() 450 | 451 | # Notify other clients 452 | 453 | for client in list(self.clients.values()): 454 | 455 | if client.connected: 456 | 457 | client.send(MSG_REMOVE(client_id)) 458 | 459 | return 460 | 461 | def kill(self): 462 | """ Properly terminates the server """ 463 | if self.log_file is not None: self.log_file.close() 464 | 465 | outgoing = MSG_KILL(-1, "Warning: Server manually killed by keyboard interrupt. Please close the application") 466 | 467 | for client in list(self.clients.values()): 468 | 469 | if client.connected: 470 | 471 | client.send(outgoing) 472 | 473 | client.force_disconnect() 474 | 475 | sleep(0.5) 476 | 477 | self.running = False 478 | self.server.shutdown() 479 | self.server.server_close() 480 | 481 | return 482 | 483 | def write(self, string): 484 | """ Replaces sys.stdout """ 485 | if string != "\n": 486 | 487 | outgoing = MSG_RESPONSE(-1, string) 488 | 489 | for client in list(self.clients.values()): 490 | 491 | if client.connected: 492 | 493 | client.send(outgoing) 494 | 495 | return 496 | 497 | # Request Handler for TroopServer 498 | 499 | class TroopRequestHandler(socketserver.BaseRequestHandler): 500 | master = None 501 | name = None 502 | client_name = "" 503 | 504 | def client(self): 505 | return self.get_client(self.get_client_id()) 506 | 507 | def get_client(self, client_id): 508 | return self.master.get_client(client_id) 509 | 510 | def get_client_id(self): 511 | return self.client_id 512 | 513 | def authenticate(self, packet): 514 | 515 | addr = self.client_address[0] 516 | 517 | password = packet[0]['password'] 518 | username = packet[0]['name'] 519 | version = packet[0]['version'] 520 | 521 | if password == self.master.password.hexdigest(): 522 | 523 | # See if this is a reconnecting client 524 | 525 | client = self.master.get_client_from_addr(addr, username) 526 | 527 | self.client_info = (addr, username) 528 | 529 | # If the IP address already exists, re-connect the client (if not connected) 530 | 531 | if client is not None: 532 | 533 | if client.connected: 534 | 535 | # Don't reconnect 536 | 537 | stdout("User already connected: {}@{}".format(username, addr)) 538 | 539 | self.client_id = ERR_NAME_TAKEN 540 | 541 | else: 542 | 543 | # User re-connecting 544 | 545 | stdout("{} re-connected user from {}".format(username, addr)) 546 | 547 | self.client_id = client.id 548 | 549 | else: 550 | 551 | # Reply with the client id 552 | 553 | if version != self.master.version: 554 | 555 | stdout("User '{}' attempted connection with wrong version".format(username)) 556 | 557 | self.client_id = ERR_VERSION_MISMATCH 558 | 559 | else: 560 | 561 | self.client_id = self.master.get_next_id() 562 | 563 | if self.client_id > 0: 564 | 565 | stdout("New connected user '{}' from {}".format(username, addr)) 566 | 567 | else: 568 | 569 | # Negative ID indicates failed login 570 | 571 | stdout("Failed login from {}".format(addr)) 572 | 573 | self.client_id = ERR_LOGIN_FAIL 574 | 575 | # Send back the user_id as a 4 digit number 576 | 577 | reply = "{:04d}".format( self.client_id ).encode() 578 | 579 | self.request.send(reply) 580 | 581 | return self.client_id 582 | 583 | def get_message(self): 584 | data = self.request.recv(self.master.bytes) 585 | data = self.reader.feed(data) 586 | return data 587 | 588 | def handle_client_lost(self, verbose=True): 589 | """ Terminates cleanly """ 590 | if verbose: 591 | stdout("Client '{}' @ {} has disconnected".format(self.client_name, self.client_address[0])) 592 | self.master.remove_client(self.client_id) 593 | return 594 | 595 | def handle_connect(self, msg): 596 | """ Stores information about the new client. Wait for acknowledgement from all connected peers before continuing processing messages """ 597 | assert isinstance(msg, MSG_CONNECT) 598 | 599 | # Create the client and connect to other clients 600 | 601 | if self.client_address not in list(self.master.clients.values()): 602 | 603 | new_client = Client(self, name=msg['name'], is_dummy=msg['dummy']) 604 | 605 | self.client_name = new_client.name 606 | 607 | self.connect_clients(new_client) # Contacts other clients 608 | 609 | # Don't accept more messages while connecting 610 | 611 | self.master.wait_for_ack(True) 612 | 613 | return new_client 614 | 615 | def leader(self): 616 | """ Returns the peer client that is "leading" """ 617 | return self.master.leader() 618 | 619 | def handle(self): 620 | """ self.request = socket 621 | self.server = ThreadedServer 622 | self.client_address = (address, port) 623 | """ 624 | 625 | # This takes strings read from the socket and returns json objects 626 | 627 | self.reader = NetworkMessageReader() 628 | 629 | # self.messages = [] 630 | # self.msg_count = 0 631 | 632 | # Password test 633 | 634 | packet = self.get_message() 635 | 636 | if self.authenticate(packet) < 0: 637 | 638 | return 639 | 640 | # Enter loop 641 | 642 | while self.master.running: 643 | 644 | try: 645 | 646 | packet = self.get_message() 647 | 648 | # If we get none, just read in again 649 | 650 | if packet is None: 651 | 652 | self.handle_client_lost() 653 | 654 | break 655 | 656 | except Exception as e: # TODO be more specific 657 | 658 | # Handle the loss of a client 659 | 660 | self.handle_client_lost() 661 | 662 | break 663 | 664 | for msg in packet: 665 | 666 | if isinstance(msg, MSG_CONNECT): 667 | 668 | # Add the new client 669 | 670 | new_client = self.handle_connect(msg) 671 | 672 | # Clear server history 673 | 674 | self.master.clear_history() 675 | 676 | elif isinstance(msg, MSG_KEEP_ALIVE): 677 | 678 | self.client().recv_keepalive() 679 | 680 | elif self.master.waiting_for_ack and isinstance(msg, MSG_CONNECT_ACK): 681 | 682 | self.master.connect_ack(msg) 683 | 684 | elif not self.master.waiting_for_ack: 685 | 686 | # Add any other messages to the send queue 687 | 688 | self.master.msg_queue.put(msg) 689 | 690 | return 691 | 692 | # def store_messages(self, packet): 693 | # """ Stores messages to be returned in order with any existing messages in the queue """ 694 | # self.messages.extend(packet) 695 | # self.messages = list(sorted(self.messages, key=lambda msg: msg["src_id"])) 696 | # return 697 | 698 | # def get_message_queue(self): 699 | # """ Returns a list of messages that are sorted in ascending 'msg_id' order 700 | # up until we don't find items that are in the next position """ 701 | # popped = [] 702 | # i = 0 703 | # for msg in self.messages: 704 | # if msg["msg_id"] == self.msg_count: 705 | # popped.append(msg) 706 | # i += 1 707 | # self.msg_count += 1 708 | # else: 709 | # i -= 1 710 | # break 711 | # self.messages = self.messages[i+1:] 712 | # return popped 713 | 714 | def connect_clients(self, new_client): 715 | """ Update all other connected clients with info on new client & vice versa """ 716 | 717 | # Store the client 718 | 719 | self.master.clients[new_client.id] = new_client 720 | 721 | # Connect each client 722 | 723 | msg1 = MSG_CONNECT(new_client.id, new_client.name, new_client.hostname, new_client.port, new_client.is_dummy) 724 | 725 | for client in list(self.master.clients.values()): 726 | 727 | # Tell other clients about the new connection 728 | 729 | if client.connected: 730 | 731 | client.send(msg1) 732 | 733 | # Tell the new client about other clients 734 | 735 | if client != self.client_info: 736 | 737 | msg2 = MSG_CONNECT(client.id, client.name, client.hostname, client.port, client.is_dummy) 738 | 739 | new_client.send(msg2) 740 | 741 | return 742 | 743 | def update_client(self): 744 | """ Send all the previous operations to the client to keep it up to date """ 745 | 746 | client = self.client() 747 | client.send(MSG_SET_ALL(-1, *self.master.get_contents())) 748 | client.send(self.master.get_text_constraint()) 749 | 750 | return 751 | 752 | # Keeps information about each connected client 753 | 754 | class Client: 755 | bytes = TroopServer.bytes 756 | timeout = 30 757 | def __init__(self, handler, name="", is_dummy=False): 758 | 759 | self.handler = handler 760 | 761 | self.is_dummy = is_dummy 762 | 763 | self.address = self.handler.client_address 764 | self.hostname = self.address[0] 765 | self.port = self.address[1] 766 | 767 | self.source = self.handler.request 768 | 769 | self.keepalive = None 770 | 771 | # For identification purposes 772 | 773 | self.id = int(self.handler.get_client_id()) 774 | self.name = name 775 | 776 | # Location 777 | 778 | self.index = 0 779 | self.connected = True 780 | 781 | # A list of messages to process 782 | 783 | self.messages = [] 784 | 785 | def disconnect(self): 786 | self.connected = False 787 | self.source.close() 788 | 789 | def connect(self, socket): 790 | self.connected = True 791 | self.source = socket 792 | 793 | def get_index(self): 794 | return self.index 795 | 796 | def set_index(self, i): 797 | self.index = i 798 | 799 | def __repr__(self): 800 | return repr(self.address) 801 | 802 | def send(self, message): 803 | try: 804 | self.source.sendall(message.bytes()) 805 | except Exception as e: 806 | print(e) 807 | raise DeadClientError(self.hostname) 808 | return 809 | 810 | def recv_keepalive(self): 811 | """ 812 | Raises an error if it's been more than 3 seconds 813 | since the last keepalive message was received 814 | """ 815 | self.keepalive = time.time() 816 | return 817 | 818 | def has_timedout(self): 819 | if self.keepalive: 820 | return (time.time() > self.keepalive + self.timeout) 821 | return False 822 | 823 | def force_disconnect(self): 824 | return self.handler.handle_client_lost(verbose=False) 825 | 826 | def __eq__(self, other): 827 | #return self.address == other 828 | #return self.hostname == other 829 | return (self.hostname, self.name) == other 830 | 831 | def __ne__(self, other): 832 | #return self.address != other 833 | #return self.hostname != other 834 | return (self.hostname, self.name) != other 835 | -------------------------------------------------------------------------------- /src/threadserv.py: -------------------------------------------------------------------------------- 1 | """ 2 | 3 | threadserv.py 4 | ------------- 5 | 6 | Server object used in receiver.py and server.py. Stops receiver.py 7 | import FoxDot and running a clock thread when importing from server.py 8 | 9 | """ 10 | 11 | try: 12 | import socketserver 13 | except ImportError: 14 | import SocketServer as socketserver 15 | 16 | class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer): 17 | pass 18 | -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | # Connection error flags 2 | 3 | ERR_LOGIN_FAIL = -1 4 | ERR_MAX_LOGINS = -2 5 | ERR_NAME_TAKEN = -3 6 | ERR_VERSION_MISMATCH = -4 7 | 8 | # List of all the possible characters used to represent peers in the document 9 | 10 | import string 11 | 12 | PEER_CHARS = list(string.digits + string.ascii_letters) 13 | 14 | def _is_retain(op): 15 | return isinstance(op, int) and op > 0 16 | 17 | def _is_delete(op): 18 | return isinstance(op, int) and op < 0 19 | 20 | def _is_insert(op): 21 | return isinstance(op, str) 22 | 23 | def new_operation(*args): 24 | """ Returns an operation as a list and removes index/tail if they are 0 """ 25 | 26 | values = args[:-1] 27 | length = args[-1] 28 | 29 | operation = [] 30 | 31 | for value in values: 32 | 33 | if value != 0: 34 | 35 | operation.append(value) 36 | 37 | if isinstance(value, int): 38 | 39 | if value > 0: 40 | 41 | length -= value 42 | 43 | else: 44 | 45 | length += value 46 | 47 | if length > 0: 48 | 49 | operation.append(length) 50 | 51 | elif len(operation) and _is_retain(operation[-1]): 52 | 53 | # Trim the final retain 54 | 55 | operation[-1] += length 56 | 57 | if len(operation) and operation[-1] == 0: 58 | 59 | operation.pop() 60 | 61 | return operation 62 | 63 | def get_operation_index(ops): 64 | """ Returns the index that a marker should be *after* an operation """ 65 | 66 | # If the last operation is a "skip", offset the index or 67 | # else it just moves it to the end of the document 68 | if isinstance(ops[-1], int) and ops[-1] > 0: 69 | index = ops[-1] * -1 70 | else: 71 | index = 0 72 | 73 | for op in ops: 74 | if isinstance(op, int) and op > 0: 75 | index += op 76 | elif isinstance(op, str): 77 | index += len(op) 78 | return index 79 | 80 | def get_operation_size(ops): 81 | """ Returns the number of characters added by an operation (can be negative) """ 82 | count = 0 83 | for op in ops: 84 | if isinstance(op, str): 85 | count += len(op) 86 | elif isinstance(op, int) and op < 0: 87 | count += op 88 | return count 89 | 90 | def empty_operation(ops): 91 | """ Returns True if the operation is an empty list or only contains positive integers """ 92 | return (ops == [] or all([isinstance(x, int) and (x > 0) for x in ops])) 93 | 94 | def get_doc_size(ops): 95 | """ Returns the size of the document this operation is operating on """ 96 | total = 0 97 | for value in ops: 98 | if _is_retain(value): 99 | total += value 100 | elif _is_delete(value): 101 | total += (value * -1) 102 | return total 103 | 104 | import re 105 | def get_peer_locs(n, text): 106 | return ( (match.start(), match.end()) for match in re.finditer("{}+".format(n), text)) 107 | 108 | def get_peer_char(id_num): 109 | """ Returns the ID character to identify a peer """ 110 | return PEER_CHARS[id_num] 111 | 112 | def get_peer_id_from_char(char): 113 | """ Returns the numeric index for a ID character """ 114 | return PEER_CHARS.index(str(char)) 115 | --------------------------------------------------------------------------------