diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d3691d0 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,92 @@ +# Based on https://data-dive.com/multi-os-deployment-in-cloud-using-pyinstaller-and-github-actions + +name: Build + +on: + push: + tags: + - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 + +jobs: + + createrelease: + name: Create Release + runs-on: [ubuntu-latest] + steps: + - name: Create Release + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.ref }} + release_name: Release ${{ github.ref }} + draft: true + prerelease: false + - name: Output Release URL File + run: echo "${{ steps.create_release.outputs.upload_url }}" > release_url.txt + - name: Save Release URL File for publish + uses: actions/upload-artifact@v1 + with: + name: release_url + path: release_url.txt + + buildapp: + name: Build App + needs: createrelease + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-20.04 + TARGET: linux + CMD_DEPEND: > + sudo apt-get update && sudo apt-get install --no-install-recommends -y python3 python3-dev python3-pip python3-opengl python3-wxgtk4.0 freeglut3-dev && + pip install pyinstaller + CMD_BUILD: > + pyinstaller -F -n logsim --add-data "src/logicgate.png:." src/logsim.py + OUT_FILE_NAME: logsim + ASSET_MIME: application/x-executable + - os: macos-latest + TARGET: macos + CMD_DEPEND: > + brew update --preinstall && brew install python3 && + pip install -r requirements.txt + CMD_BUILD: > + pyinstaller -F -w -n logsim --add-data "src/logicgate.png:." src/logsim.py && + cd dist/ && zip -r9 logsim-macos logsim.app/ + OUT_FILE_NAME: logsim.zip + ASSET_MIME: application/zip + # - os: windows-latest + # TARGET: windows + # CMD_DEPEND: + # CMD_BUILD: pyinstaller -F -w -n logsim --add-data "src/logicgate.png:." src/logsim.py + # OUT_FILE_NAME: logsim.exe + # ASSET_MIME: application/vnd.microsoft.portable-executable + + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: ${{matrix.CMD_DEPEND}}$ + - name: Build with pyinstaller for ${{matrix.TARGET}} + run: ${{matrix.CMD_BUILD}} + - name: Load Release URL File from release job + uses: actions/download-artifact@v1 + with: + name: release_url + - name: Get Release File Name & Upload URL + id: get_release_info + shell: bash + run: | + value=`cat release_url/release_url.txt` + echo ::set-output name=upload_url::$value + - name: Upload Release Asset + id: upload-release-asset + uses: actions/upload-release-asset@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + upload_url: ${{ steps.get_release_info.outputs.upload_url }} + asset_path: ./dist/${{ matrix.OUT_FILE_NAME }} + asset_name: ${{ matrix.OUT_FILE_NAME }} + asset_content_type: ${{ matrix.ASSET_MIME }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5b89da..51290b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,8 @@ on: branches: [master] jobs: - Lint: + lint: + name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 @@ -22,7 +23,8 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - Test: + test: + name: Test runs-on: ubuntu-latest steps: - name: Check out repository code diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 4f86a8d..171edaa 100755 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -6,8 +6,8 @@ on: - master jobs: - build: - + builddocs: + name: Build Docs runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index ccc91ce..31365d1 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ venv/ ENV/ env.bak/ venv.bak/ +myvirtualenv # Spyder project settings .spyderproject @@ -130,3 +131,8 @@ dmypy.json # Pyre type checker .pyre/ + +#GUI testing +src/fonts/ +src/test.py +PyOpenGL-3.1.6-cp39-cp39-win_amd64.whl diff --git a/src/gui.py b/src/gui.py index fc32072..fbab06d 100644 --- a/src/gui.py +++ b/src/gui.py @@ -5,256 +5,29 @@ Classes: -------- -MyGLCanvas - handles all canvas drawing operations. Gui - configures the main window and all the widgets. """ +from pathlib import Path +from typing import Union import wx -import wx.glcanvas as wxcanvas -from OpenGL import GL, GLUT -# from names import Names -# from devices import Devices -# from network import Network -# from monitors import Monitors -# from scanner import Scanner -# from parse import Parser - - -class MyGLCanvas(wxcanvas.GLCanvas): - """Handle all drawing operations. - - This class contains functions for drawing onto the canvas. It - also contains handlers for events relating to the canvas. - - Parameters - ---------- - parent: - parent window. - devices: - instance of the devices.Devices() class. - monitors: - instance of the monitors.Monitors() class. - - Methods - ------- - init_gl(self): - Configures the OpenGL context. - render(self, text): - Handles all drawing operations. - on_paint(self, event): - Handles the paint event. - on_size(self, event): - Handles the canvas resize event. - on_mouse(self, event): - Handles mouse events. - render_text(self, text, x_pos, y_pos): - Handles text drawing operations. - """ - - def __init__(self, parent, devices, monitors): - """Initialise canvas properties and useful variables.""" - super().__init__( - parent, - -1, - attribList=[ - wxcanvas.WX_GL_RGBA, - wxcanvas.WX_GL_DOUBLEBUFFER, - wxcanvas.WX_GL_DEPTH_SIZE, - 16, - 0, - ], - ) - GLUT.glutInit() - self.init = False - self.context = wxcanvas.GLContext(self) - - # Initialise variables for panning - self.pan_x = 0 - self.pan_y = 0 - self.last_mouse_x = 0 # previous mouse x position - self.last_mouse_y = 0 # previous mouse y position - - # Initialise variables for zooming - self.zoom = 1 - - # Bind events to the canvas - self.Bind(wx.EVT_PAINT, self.on_paint) - self.Bind(wx.EVT_SIZE, self.on_size) - self.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse) - - def init_gl(self): - """Configure and initialise the OpenGL context.""" - size = self.GetClientSize() - self.SetCurrent(self.context) - GL.glDrawBuffer(GL.GL_BACK) - GL.glClearColor(1.0, 1.0, 1.0, 0.0) - GL.glViewport(0, 0, size.width, size.height) - GL.glMatrixMode(GL.GL_PROJECTION) - GL.glLoadIdentity() - GL.glOrtho(0, size.width, 0, size.height, -1, 1) - GL.glMatrixMode(GL.GL_MODELVIEW) - GL.glLoadIdentity() - GL.glTranslated(self.pan_x, self.pan_y, 0.0) - GL.glScaled(self.zoom, self.zoom, self.zoom) - - def render(self, text): - """Handle all drawing operations.""" - self.SetCurrent(self.context) - if not self.init: - # Configure the viewport, modelview and projection matrices - self.init_gl() - self.init = True - - # Clear everything - GL.glClear(GL.GL_COLOR_BUFFER_BIT) - - # Draw specified text at position (10, 10) - self.render_text(text, 10, 10) - - # Draw a sample signal trace - GL.glColor3f(0.0, 0.0, 1.0) # signal trace is blue - GL.glBegin(GL.GL_LINE_STRIP) - for i in range(10): - x = (i * 20) + 10 - x_next = (i * 20) + 30 - if i % 2 == 0: - y = 75 - else: - y = 100 - GL.glVertex2f(x, y) - GL.glVertex2f(x_next, y) - GL.glEnd() - - # We have been drawing to the back buffer, flush the graphics pipeline - # and swap the back buffer to the front - GL.glFlush() - self.SwapBuffers() - - def on_paint(self, event): - """Handle the paint event.""" - self.SetCurrent(self.context) - if not self.init: - # Configure the viewport, modelview and projection matrices - self.init_gl() - self.init = True - - size = self.GetClientSize() - text = "".join( - [ - "Canvas redrawn on paint event, size is ", - str(size.width), - ", ", - str(size.height), - ] - ) - self.render(text) - - def on_size(self, event): - """Handle the canvas resize event.""" - # Forces reconfiguration of the viewport, modelview and projection - # matrices on the next paint event - self.init = False - - def on_mouse(self, event): - """Handle mouse events.""" - text = "" - # Calculate object coordinates of the mouse position - size = self.GetClientSize() - ox = (event.GetX() - self.pan_x) / self.zoom - oy = (size.height - event.GetY() - self.pan_y) / self.zoom - old_zoom = self.zoom - if event.ButtonDown(): - self.last_mouse_x = event.GetX() - self.last_mouse_y = event.GetY() - text = "".join( - [ - "Mouse button pressed at: ", - str(event.GetX()), - ", ", - str(event.GetY()), - ] - ) - if event.ButtonUp(): - text = "".join( - [ - "Mouse button released at: ", - str(event.GetX()), - ", ", - str(event.GetY()), - ] - ) - if event.Leaving(): - text = "".join( - [ - "Mouse left canvas at: ", - str(event.GetX()), - ", ", - str(event.GetY()), - ] - ) - if event.Dragging(): - self.pan_x += event.GetX() - self.last_mouse_x - self.pan_y -= event.GetY() - self.last_mouse_y - self.last_mouse_x = event.GetX() - self.last_mouse_y = event.GetY() - self.init = False - text = "".join( - [ - "Mouse dragged to: ", - str(event.GetX()), - ", ", - str(event.GetY()), - ". Pan is now: ", - str(self.pan_x), - ", ", - str(self.pan_y), - ] - ) - if event.GetWheelRotation() < 0: - self.zoom *= 1.0 + ( - event.GetWheelRotation() / (20 * event.GetWheelDelta()) - ) - # Adjust pan so as to zoom around the mouse position - self.pan_x -= (self.zoom - old_zoom) * ox - self.pan_y -= (self.zoom - old_zoom) * oy - self.init = False - text = "".join( - [ - "Negative mouse wheel rotation. Zoom is now: ", - str(self.zoom), - ] - ) - if event.GetWheelRotation() > 0: - self.zoom /= 1.0 - ( - event.GetWheelRotation() / (20 * event.GetWheelDelta()) - ) - # Adjust pan so as to zoom around the mouse position - self.pan_x -= (self.zoom - old_zoom) * ox - self.pan_y -= (self.zoom - old_zoom) * oy - self.init = False - text = "".join( - [ - "Positive mouse wheel rotation. Zoom is now: ", - str(self.zoom), - ] - ) - if text: - self.render(text) - else: - self.Refresh() # triggers the paint event - - def render_text(self, text, x_pos, y_pos): - """Handle text drawing operations.""" - GL.glColor3f(0.0, 0.0, 0.0) # text is black - GL.glRasterPos2f(x_pos, y_pos) - font = GLUT.GLUT_BITMAP_HELVETICA_12 - - for character in text: - if character == "\n": - y_pos = y_pos - 20 - GL.glRasterPos2f(x_pos, y_pos) - else: - GLUT.glutBitmapCharacter(font, ord(character)) +from gui_components import ( + Canvas, + MenuBar, + CyclesWidget, + MonitorWidget, + SwitchWidget, + ButtonsWidget, + Console, + StatusBar, +) + +from names import Names +from devices import Devices +from monitors import Monitors +from network import Network +from scanner import Scanner +from parse import Parser class Gui(wx.Frame): @@ -270,85 +43,233 @@ class Gui(wx.Frame): Methods ------- - on_menu(self, event): - Event handler for the file menu. - on_spin(self, event): - Event handler for when the user changes the spin control value. - on_run_button(self, event): - Event handler for when the user clicks the run button. - on_text_box(self, event): - Event handler for when the user enters text. + handle_file_load(self, path): + Handle file load, parse and build the network. + handle_run_btn_click(self, event): + Handle event when user presses run button. + handle_cont_btn_click(self, event): + Handle event when user presses continue button. + run_network(self, cycles): + Run the network for the specified number of simulation cycles. """ - def __init__(self, title, path, names, devices, network, monitors): + def __init__( + self, + title: str, + path: Union[None, str], + names: Names, + devices: Devices, + network: Network, + monitors: Monitors, + ): """Initialise widgets and layout.""" - super().__init__(parent=None, title=title, size=(800, 600)) - - # Configure the file menu - fileMenu = wx.Menu() - menuBar = wx.MenuBar() - fileMenu.Append(wx.ID_ABOUT, "&About") - fileMenu.Append(wx.ID_EXIT, "&Exit") - menuBar.Append(fileMenu, "&File") - self.SetMenuBar(menuBar) - - # Canvas for drawing signals - self.canvas = MyGLCanvas(self, devices, monitors) - - # Configure the widgets - self.text = wx.StaticText(self, wx.ID_ANY, "Cycles") - self.spin = wx.SpinCtrl(self, wx.ID_ANY, "10") - self.run_button = wx.Button(self, wx.ID_ANY, "Run") - self.text_box = wx.TextCtrl( - self, wx.ID_ANY, "", style=wx.TE_PROCESS_ENTER + super().__init__(parent=None, title=title, size=(1200, 820)) + self.devices = devices + self.names = names + self.network = network + self.monitors = monitors + self.cycles_completed = [0] # use list to force pass by reference + + # Open maximised + # self.Maximize(True) + + # Logo/icon + self.SetIcon( + wx.Icon(str(Path(__file__).resolve().with_name("logicgate.png"))) ) - # Bind events to widgets - self.Bind(wx.EVT_MENU, self.on_menu) - self.spin.Bind(wx.EVT_SPINCTRL, self.on_spin) - self.run_button.Bind(wx.EVT_BUTTON, self.on_run_button) - self.text_box.Bind(wx.EVT_TEXT_ENTER, self.on_text_box) + # Sizer containing everything + self.main_sizer = wx.BoxSizer(wx.HORIZONTAL) + + # Canvas for showing monitor signals + self.canvas = Canvas( + self, + wx.ID_ANY, + (10, 10), + wx.Size(300, 300), + self.devices, + self.network, + self.monitors, + ) + self.canvas.SetSizeHints(500, 500) # Configure sizers for layout - main_sizer = wx.BoxSizer(wx.HORIZONTAL) - side_sizer = wx.BoxSizer(wx.VERTICAL) - - main_sizer.Add(self.canvas, 5, wx.EXPAND | wx.ALL, 5) - main_sizer.Add(side_sizer, 1, wx.ALL, 5) - - side_sizer.Add(self.text, 1, wx.TOP, 10) - side_sizer.Add(self.spin, 1, wx.ALL, 5) - side_sizer.Add(self.run_button, 1, wx.ALL, 5) - side_sizer.Add(self.text_box, 1, wx.ALL, 5) - - self.SetSizeHints(600, 600) - self.SetSizer(main_sizer) - - def on_menu(self, event): - """Handle the event when the user selects a menu item.""" - Id = event.GetId() - if Id == wx.ID_EXIT: - self.Close(True) - if Id == wx.ID_ABOUT: - wx.MessageBox( - "Logic Simulator\nCreated by Mojisola Agboola\n2017", - "About Logsim", - wx.ICON_INFORMATION | wx.OK, + # Add scrollable canvas to left-hand side + self.main_sizer.Add(self.canvas, 2, wx.EXPAND | wx.ALL, 5) + # main_sizer.Add(self.scrollable_canvas, 1, wx.EXPAND + wx.TOP, 10) + + # Widgets + self._build_side_sizer() + + # Show everything. + self.SetSizeHints(200, 200) + self.SetSizer(self.main_sizer) + + # Menu bar and status bar + self.StatusBar = StatusBar(self) + if path is not None: + self.StatusBar.PushStatusText(path) + # important: load menu bar last, after side sizer + self.MenuBar = MenuBar( + self, file_opened=path is not None, on_file=self.handle_file_load + ) + + def _build_side_sizer(self): + """Build right-hand plane, containing all controls.""" + self.side_sizer = wx.BoxSizer(wx.VERTICAL) + + # Components + # load console first to show errors during file load + self.Console = Console(self) + self.CyclesWidget = CyclesWidget(self) + self.MonitorWidget = MonitorWidget( + self, + self.cycles_completed, + self.names, + self.devices, + self.network, + self.monitors, + ) + self.SwitchWidget = SwitchWidget(self, self.names, self.devices) + self.ButtonsWidget = ButtonsWidget( + self, + on_run=self.handle_run_btn_click, + on_continue=self.handle_cont_btn_click, + ) + + # Add vertical space at top of right-hand side + self.side_sizer.AddSpacer(15) + self.side_sizer.Add(self.CyclesWidget, 1, wx.ALIGN_CENTRE, 130) + self.side_sizer.Add( + wx.StaticText(self, wx.ID_ANY, "Monitors"), 1, wx.LEFT, 10 + ) + self.side_sizer.AddSpacer(-25) + self.side_sizer.Add(self.MonitorWidget, 1, wx.EXPAND | wx.ALL, 10) + + # Vertical space between elements + self.side_sizer.AddSpacer(15) + self.side_sizer.Add( + wx.StaticText(self, wx.ID_ANY, "Switches"), 1, wx.LEFT, 10 + ) + self.side_sizer.AddSpacer(-25) + self.side_sizer.Add(self.SwitchWidget, 1, wx.EXPAND | wx.ALL, 10) + + # Add vertical space + self.side_sizer.AddSpacer(15) + # Add run + continue buttons at bottom + self.side_sizer.Add(self.ButtonsWidget, 1, wx.ALIGN_CENTRE, 130) + + self.side_sizer.AddSpacer(15) + self.side_sizer.Add( + wx.StaticText(self, wx.ID_ANY, "Console"), 1, wx.LEFT, 10 + ) + self.side_sizer.AddSpacer(-25) + self.side_sizer.Add(self.Console, 1, wx.EXPAND | wx.ALL, 10) + + self.main_sizer.Add(self.side_sizer, 1, wx.ALL, 5) + + def handle_file_load(self, path: str): + """Handle file load, parse and build the network.""" + self.names = Names() + self.devices = Devices(self.names) + self.network = Network(self.names, self.devices) + self.monitors = Monitors(self.names, self.devices, self.network) + self.cycles_completed[0] = 0 + + scanner = Scanner(path, self.names) + parser = Parser( + self.names, self.devices, self.network, self.monitors, scanner + ) + parser.parse_network() + if parser.errors.error_counter > 0: + parser.errors.print_error_messages() + return # only rebuild buttons if new file has no error + + self.main_sizer.Hide(self.side_sizer) + self._build_side_sizer() + self.Layout() + + self.StatusBar.PushStatusText(path) + + def handle_run_btn_click(self, event): + """Handle event when user presses run button.""" + cycles = self.CyclesWidget.GetValue() + + self.monitors.reset_monitors() + print("".join(["Running for ", str(cycles), " cycles"])) + self.devices.cold_startup() + if self.run_network(cycles): + self.cycles_completed[0] = cycles + self.StatusBar.push_cycle_count(self.cycles_completed[0]) + + def handle_cont_btn_click(self, event): + """Handle event when user presses continue button.""" + cycles = self.CyclesWidget.GetValue() + if self.run_network(cycles): + self.cycles_completed[0] += cycles + self.canvas.cycles += cycles + self.StatusBar.push_cycle_count(self.cycles_completed[0]) + print( + " ".join( + [ + "Continuing for", + str(cycles), + "cycles.", + "Total:", + str(self.cycles_completed[0]), + ] ) + ) + + def run_network(self, cycles): + """Run the network for the specified number of simulation cycles. + + Return True if successful. + """ + self.canvas.signals = [] + for _ in range(cycles): + if self.network.execute_network(): + self.monitors.record_signals() + else: + print("Error! Network oscillating.") + return False + # self.monitors.display_signals() + for ( + device_id, + pin_id, + ), value in self.monitors.monitors_dictionary.items(): + signal_name = self.devices.get_signal_name(device_id, pin_id) + self.canvas.signals.append([signal_name, value]) + self.canvas.cycles = cycles + self.canvas.render() + return True + + def open_file_dialog(self) -> Union[None, str]: + """Open the file dialog. + + Returns + ------- + path: Union[None, str] + Returns None if user cancels + """ + openFileDialog = wx.FileDialog( + self, + message="Open Logic Description File", + wildcard="TXT files (*.txt)|*.txt", + style=wx.FD_OPEN + wx.FD_FILE_MUST_EXIST, + ) + if openFileDialog.ShowModal() == wx.ID_CANCEL: + print("The user cancelled") + return # User closed file dialog + + path = openFileDialog.GetPath() + print("File chosen=", path) + return path + + def handle_file_open(self) -> None: + """Call callback function if file selected.""" + path = self.open_file_dialog() + if path is None: + return - def on_spin(self, event): - """Handle the event when the user changes the spin control value.""" - spin_value = self.spin.GetValue() - text = "".join(["New spin control value: ", str(spin_value)]) - self.canvas.render(text) - - def on_run_button(self, event): - """Handle the event when the user clicks the run button.""" - text = "Run button pressed." - self.canvas.render(text) - - def on_text_box(self, event): - """Handle the event when the user enters text.""" - text_box_value = self.text_box.GetValue() - text = "".join(["New text box value: ", text_box_value]) - self.canvas.render(text) + self.on_file(path) diff --git a/src/gui_components.py b/src/gui_components.py new file mode 100644 index 0000000..7c05036 --- /dev/null +++ b/src/gui_components.py @@ -0,0 +1,858 @@ +"""Create componenets of the GUI for implementation later. + +Classes: +-------- +Canvas - Handle all drawing operations. +MenuBar - Menu bar component. +CyclesWidget - Sizer containing 'Cycles' text and number selector. +MonitorWidget - Scrolled window for monitors. +SwitchWidget - Scrollable window for switches. +ButtonsWidget - Widget containing the control buttons. +Console - Console component that redirects stdout to gui. +StatusBar - Status bar to display cycle count. +""" +import sys +from typing import Callable, Union + +import webbrowser +import wx +import wx.glcanvas as wxcanvas +from OpenGL import GL, GLUT + +from names import Names +from devices import Devices +from monitors import Monitors +from network import Network + + +class Canvas(wxcanvas.GLCanvas): + """Handle all drawing operations. + + This class contains functions for drawing onto the canvas. It + also contains handlers for events relating to the canvas. + + Parameters + ---------- + parent: + parent window. + id: + unique id. + pos: + position of canvas. + size: + size of canvas. + devices: + instance of the devices.Devices() class. + network: + instance of the network.Network() class. + monitors: + instance of the monitors.Monitors() class. + + Methods + ------- + init_gl(self): + Configures the OpenGL context. + render(self, text): + Handles all drawing operations. + on_paint(self, event): + Handles the paint event. + on_size(self, event): + Handles the canvas resize event. + on_mouse(self, event): + Handles mouse events. + render_text(self, text, x_pos, y_pos): + Handles text drawing operations. + """ + + def __init__(self, parent, id, pos, size, devices, network, monitors): + """Initialise canvas for displaying monitor signals.""" + super().__init__( + parent, + -1, + pos=pos, + size=size, + attribList=[ + wxcanvas.WX_GL_RGBA, + wxcanvas.WX_GL_DOUBLEBUFFER, + wxcanvas.WX_GL_DEPTH_SIZE, + 16, + 0, + ], + ) + GLUT.glutInit() + self.init = False + self.context = wxcanvas.GLContext(self) + + self.pan_x = 0 # Initialise variables for panning + self.pan_y = 0 + self.zoom = 1 # Initialise variable for zooming + + self.scale_x = 50 + self.scale_y = 50 + + self.devices = devices + self.network = network + self.monitors = monitors + self.signals = ( + [] + ) # list of lists, where the sublists are [signal_name, value] + # for single output devices, signal_name is device_name. + # for double output devices (d-type), + # signal_name is device_name + '.Q' or '.QBAR' + + self.Bind(wx.EVT_PAINT, self.on_paint) + self.Bind(wx.EVT_SIZE, self.on_size) + self.Bind(wx.EVT_MOUSE_EVENTS, self.on_mouse) + + self.line_colours = [ + [0.85, 0.16, 0.69], # pink + [0.07, 0.81, 0.86], # blue + [0.90, 0.58, 0], # orange + [0.24, 0.89, 0.09], # green + [0.56, 0.09, 1], # purple + ] + + def init_gl(self): + """Configure and initialise OpenGL context.""" + size = self.GetClientSize() + self.SetCurrent(self.context) + GL.glDrawBuffer(GL.GL_BACK) + GL.glClearColor(0.2, 0.2, 0.2, 0.2) + GL.glViewport(0, 0, size.width, size.height) + GL.glMatrixMode(GL.GL_PROJECTION) + GL.glLoadIdentity() + GL.glOrtho(0, size.width, 0, size.height, -1, 1) + GL.glMatrixMode(GL.GL_MODELVIEW) + GL.glLoadIdentity() + GL.glTranslated(self.pan_x, self.pan_y, 0.0) + GL.glScaled(self.zoom, self.zoom, self.zoom) + + def render(self): + """Handle all drawing operations.""" + # Initialise + self.SetCurrent(self.context) + size = self.GetClientSize() + # Five preset colours for signal lines + if not self.init: + # Configure the viewport, modelview and projection matrices + self.init_gl() + self.init = True + + # Clear everything + GL.glClearColor(0.2, 0.2, 0.2, 0.2) # dark gray default background + GL.glClear(GL.GL_COLOR_BUFFER_BIT) + + # If there are signals to display + # Only if run/continue have been pressed + # AND at least 1 monitor has been selected + if len(self.signals) > 0: + for i in range( + len(self.signals[0][-1]) + ): # Create vertical lines for time steps + GL.glColor3f(1, 1, 1) # White text + # X-axis labels (time) + self.render_text( + str(i), 125 + i * self.scale_x, size.height - 30 + ) + # Generate vertical grid lines + GL.glColor3f(0.6, 0.6, 0.6) # light grey grid lines + GL.glLineWidth(0.25) + GL.glBegin(GL.GL_LINES) + GL.glVertex2f(130 + i * self.scale_x, size.height - 40) + GL.glVertex2f( + 130 + i * self.scale_x, + size.height - len(self.signals) * 115, + ) # Lines extend depending on number of monitors + GL.glEnd() + # Draw signals + for i, signal in enumerate(self.signals, 1): + # Draw two horizontal gridlines for each signal + GL.glColor3f(0.6, 0.6, 0.6) + GL.glLineWidth(0.25) + GL.glBegin(GL.GL_LINES) + GL.glVertex2f( + 130, size.height - 2 * i * self.scale_y + ) # Horizontal line at value of 0 + GL.glVertex2f( + 130 + + len( + self.signals[0][-1] * 50 + ), # lines extend depending on number of cycles + size.height - 2 * i * self.scale_y, + ) + GL.glVertex2f( + 130, size.height - 2 * i * self.scale_y + self.scale_y + ) # Horizontal line at value of 1 + GL.glVertex2f( + 130 + len(self.signals[0][-1] * 50), + size.height - 2 * i * self.scale_y + self.scale_y, + ) + GL.glEnd() + + # Cycle through five colours for signal lines + colour_index = i % 5 + + GL.glLineWidth(3) + # Draw signal line + self.draw_signal( + signal[-1], + (130, size.height - 2 * i * self.scale_y), + colour_index, + ) + GL.glClearColor(1, 1, 1, 0) + # Write out name of device (and pin) + self.render_text( + signal[0], 20, size.height - 2 * i * self.scale_y + 20 + ) + # Y-axis labels: 1 and 0, for each monitored device + self.render_text( + "1", 110, size.height - 2 * i * self.scale_y + 46 + ) + self.render_text( + "0", 110, size.height - 2 * i * self.scale_y - 3 + ) + # Flush the graphics pipeline + # and swap the back buffer to the front + # Necessary! + GL.glFlush() + self.SwapBuffers() + + def on_paint(self, event): + """Handle paint event.""" + self.SetCurrent(self.context) + if not self.init: + # Configure the viewport, modelview and projection matrices + self.init_gl() + self.init = True + self.render() + + def on_size(self, event): + """Handle canvas resize event. + + Forces reconfiguration of the viewport, modelview and projection + matrices on the next paint event. + """ + self.init = False + + def render_text(self, text, x_pos, y_pos): + """Handle text drawing operations.""" + GL.glColor3f(1, 1, 1) # text is white + GL.glRasterPos2f(x_pos, y_pos) + font = GLUT.GLUT_BITMAP_HELVETICA_12 # noqa + + for character in text: + if character == "\n": + y_pos = y_pos - 20 + GL.glRasterPos2f(x_pos, y_pos) + else: + GLUT.glutBitmapCharacter( + font, ord(character) + ) # ord() converts character into Unicode code value + + def on_mouse(self, event): + """Handle mouse events.""" + # Calculate object coordinates of the mouse position + size = self.GetClientSize() + ox = (event.GetX() - self.pan_x) / self.zoom + oy = (size.height - event.GetY() - self.pan_y) / self.zoom + old_zoom = self.zoom + + if event.ButtonDown(): + self.last_mouse_x = event.GetX() + self.last_mouse_y = event.GetY() + + if event.Dragging(): + # If user drags on canvas, canvas pans. + self.pan_x += event.GetX() - self.last_mouse_x + self.pan_y -= event.GetY() - self.last_mouse_y + self.last_mouse_x = event.GetX() + self.last_mouse_y = event.GetY() + self.init = False + + if event.GetWheelRotation() < 0: + # Zoom on wheel rotation + self.zoom *= 1.0 + ( + event.GetWheelRotation() / (20 * event.GetWheelDelta()) + ) + # Adjust pan so as to zoom around the mouse position + self.pan_x -= (self.zoom - old_zoom) * ox + self.pan_y -= (self.zoom - old_zoom) * oy + self.init = False + + if event.GetWheelRotation() > 0: + # Zoom opposite direction + self.zoom /= 1.0 - ( + event.GetWheelRotation() / (20 * event.GetWheelDelta()) + ) + # Adjust pan so as to zoom around the mouse position + self.pan_x -= (self.zoom - old_zoom) * ox + self.pan_y -= (self.zoom - old_zoom) * oy + self.init = False + + self.Refresh() # triggers the paint event + + def draw_signal(self, signal, offset, colour_index): + """Draw line for a given signal.""" + self.max_X = self.scale_x * (len(signal) - 1) + GL.glBegin(GL.GL_LINE_STRIP) + for i in range(len(signal)): + sig_val = signal[i] + # Choose colour + GL.glColor3f( + self.line_colours[colour_index][0], + self.line_colours[colour_index][1], + self.line_colours[colour_index][2], + ) + # If the monitor was unmonitored and + # then monitored later, the singal before + # being set as a monitor is a different colour. + # For these periods, the signal value is not 0 or 1. + if sig_val != 0: + if sig_val != 1: + sig_val = 0.01 + GL.glColor3f(0.6, 0.6, 0.6) + if sig_val == 0: + GL.glVertex2f(offset[0] + i * self.scale_x, offset[1]) + elif sig_val == 1: + GL.glVertex2f( + offset[0] + i * self.scale_x, offset[1] + self.scale_y + ) + + try: + if signal[i + 1] != 0: + if signal[i + 1] != 1: + signal[i + 1] = 0.01 + next_val = (signal[i + 1]) * self.scale_y + GL.glVertex2f( + offset[0] + i * self.scale_x, offset[1] + next_val + ) + except IndexError: + pass + GL.glEnd() + + +class MenuBar(wx.MenuBar): + """Menu bar component. + + Handles file load and help. + + Parameters + ---------- + parent: + parent window + file_opened: + TODO + on_file: + function to load new logic description files + + Methods + ------- + on_menu(self, event): + Handle menu events. + open_file_dialog(self): + Open the file dialog. + handle_file_open(self): + Call callback function if file selected. + """ + + OpenID = 998 + HelpID = 110 + + def __init__(self, parent: wx.Frame, file_opened: bool, on_file: Callable): + """Initialize the widget.""" + self.on_file = on_file + + super().__init__() + fileMenu = wx.Menu() + fileMenu.Append(self.OpenID, "&Open") + fileMenu.Append(self.HelpID, "&Help") + self.Append(fileMenu, "&Menu") + self.Bind(wx.EVT_MENU, self.on_menu) # Menu functionality + + parent.SetMenuBar(self) + + if not file_opened: + self.handle_file_open() + + def on_menu(self, event) -> None: + """Handle menu events. + + If Open button is selected, file dialog opens + to select a .txt description file. + If Help button is selected, web browser is + opened to GitHub readme. + """ + if event.GetId() == self.OpenID: + self.handle_file_open() + + if event.GetId() == self.HelpID: + webbrowser.open("https://github.com/WeixuanZ/logsim#readme") + + def open_file_dialog(self) -> Union[None, str]: + """Open the file dialog. + + Returns + ------- + path: Union[None, str] + Returns None if user cancels + """ + openFileDialog = wx.FileDialog( + self, + message="Open Logic Description File", + wildcard="TXT files (*.txt)|*.txt", + style=wx.FD_OPEN + wx.FD_FILE_MUST_EXIST, + ) + if openFileDialog.ShowModal() == wx.ID_CANCEL: + print("The user cancelled") + return # User closed file dialog + + path = openFileDialog.GetPath() + print("File chosen=", path) + return path + + def handle_file_open(self) -> None: + """Call callback function if file selected.""" + path = self.open_file_dialog() + if path is None: + return + + self.on_file(path) + + +class CyclesWidget(wx.BoxSizer): + """Sizer containing 'Cycles' text and number selector. + + Parameters + ---------- + parent: + parent window + + Methods + ------- + GetValue(self): + Get the current cycle selector value. + """ + + def __init__(self, parent: wx.Window): + """Initialize the widget.""" + super().__init__(wx.HORIZONTAL) + + # Number selected to specify #cycles + self.cycles = wx.SpinCtrl( + parent, wx.ID_ANY, "10", size=(60, 30), min=1, name="#cycles" + ) + # Text 'Cycles' + self.cycles_text = wx.StaticText(parent, wx.ID_ANY, "Cycles") + + # Add text and cycle number selector to sizer. + self.Add(self.cycles_text, 1, wx.LEFT, 20) + self.Add(self.cycles, 1, wx.LEFT, 20) + + def GetValue(self): + """Get the current cycle selector value.""" + return self.cycles.GetValue() + + +class MonitorWidget(wx.ScrolledWindow): + """Scrolled window for monitors. + + All devices are listed, with a button + for each to toggle monitoring. + + Parameters + ---------- + parent: + parent window + cycles_completed: + list containing number of cycles completed + names: + instance of the names.Names() class. + devices: + instance of the devices.Devices() class. + network: + instance of the network.Network() class. + monitors: + instance of the monitors.Monitors() class. + + Methods + ------- + on_monitor_button(self, event): + Handle toggle monitor state of output. + monitor_command(self, device_id, port): + Set the specified monitor. + zap_command(self, device_id, pin): + Remove the specified monitor. + """ + + def __init__( + self, + parent, + cycles_completed: list, + names: Names, + devices: Devices, + network: Network, + monitors: Monitors, + ): + """Initialize the widget.""" + self.devices = devices + self.names = names + self.network = network + self.monitors = monitors + self.cycles_completed = cycles_completed + + super().__init__( + parent, + -1, + wx.DefaultPosition, + (100, 200), + wx.SUNKEN_BORDER | wx.HSCROLL | wx.VSCROLL, + ) + monitors_sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(monitors_sizer) + self.SetScrollRate(10, 10) + self.SetAutoLayout(True) + + self.monitor_dict = {} + self.monitor_buttons = [] + self.initial_monitors_lst = [] + self.initial_monitor_pins = [] + + for i in range(len(self.monitors.monitors_dictionary)): + self.initial_monitors_lst.append( + list(self.monitors.monitors_dictionary.keys())[i][0] + ) + pin = list(self.monitors.monitors_dictionary.keys())[i][1] + if pin is not None: + self.initial_monitor_pins.append(pin) + + # Loop over devices, creating button for each + i = 0 + for device in self.devices.devices_list: + if device.device_kind == self.devices.D_TYPE: + for pin in list(device.outputs.keys()): + if device.device_id in self.initial_monitors_lst: + if pin in self.initial_monitor_pins: + label = "Remove" + value = True + else: + label = "Add" + value = False + else: + label = "Add" + value = False + + self.monitor_buttons.append( + wx.ToggleButton( + self, wx.ID_ANY, label=label, size=(130, 30) + ) + ) + if self.names.get_name_string(pin) == "Q": + info = self.devices.Q_ID + else: + info = self.devices.QBAR_ID + self.monitor_dict[self.monitor_buttons[i].GetId()] = [ + device, + info, + ] + self.monitor_buttons[i].Bind( + wx.EVT_TOGGLEBUTTON, self.on_monitor_button + ) + self.monitor_buttons[i].SetValue(value) + i += 1 + else: + if device.device_id in self.initial_monitors_lst: + label = "Remove" + value = True + else: + label = "Add" + value = False + self.monitor_buttons.append( + wx.ToggleButton( + self, wx.ID_ANY, label=label, size=(130, 30) + ) + ) + self.monitor_dict[self.monitor_buttons[i].GetId()] = [ + device, + None, + ] + self.monitor_buttons[i].SetValue(value) + self.monitor_buttons[i].Bind( + wx.EVT_TOGGLEBUTTON, self.on_monitor_button + ) + i += 1 + + # Iterate over list of buttons, adding each + # to scrollable sizer for monitors. + for i, monitor_button in enumerate(self.monitor_buttons): + button_id = monitor_button.GetId() + device = self.monitor_dict[button_id][0] + device_info = self.monitor_dict[button_id][1] + device_id = device.device_id + if device_info is None: + device_name = self.names.get_name_string(device_id) + elif device_info is self.devices.Q_ID: + device_name = self.names.get_name_string(device_id) + ".Q" + elif device_info is self.devices.QBAR_ID: + device_name = self.names.get_name_string(device_id) + " .QBAR" + device_name = ( + device_name + + " <" + + self.names.get_name_string(device.device_kind) + + ">" + ) + device_sizer = wx.BoxSizer( + wx.HORIZONTAL + ) # Sizer for single device containing text + # and one button horizontally + monitors_sizer.Add(device_sizer, 1, wx.ALIGN_CENTRE, 70) + self.device_text = wx.StaticText(self, wx.ID_ANY, device_name) + device_sizer.Add(self.device_text, 1, wx.ALL, 10) + device_sizer.Add(monitor_button, 1, wx.ALL, 10) + + # Create a list of ids for each monitor button + # so that we can find out which button was pressed + # and edit the corresponding device (to be monitored or not) + self.monitor_buttons_id = [] + for i in self.monitor_buttons: + self.monitor_buttons_id.append(i.GetId()) + + def on_monitor_button(self, event): + """Handle toggle monitor state of output. + + If output is being monitored, button says 'Remove'. + If output is not being monitored, button says 'Add'. + """ + obj = event.GetEventObject() + button_id = obj.GetId() + device = self.monitor_dict[button_id] + device_id = device[0].device_id + pin = device[1] + if obj.GetValue(): + # monitor device + self.monitor_command(device_id, pin) + obj.SetLabel("Remove") + else: + # stop monitoring device + self.zap_command(device_id, pin) + obj.SetLabel("Add") + + def monitor_command(self, device_id, port): + """Set the specified monitor.""" + if self.monitors is not None: + monitor_error = self.monitors.make_monitor( + device_id, port, self.cycles_completed[0] + ) + if monitor_error == self.monitors.NO_ERROR: + print( + "Successfully made " + + self.names.get_name_string(device_id) + + " a monitor." + ) + else: + print("Error! Could not make monitor.") + + def zap_command(self, device_id, pin): + """Remove the specified monitor.""" + if self.monitors is not None: + if self.monitors.remove_monitor(device_id, pin): + print( + "Successfully zapped monitor " + + self.names.get_name_string(device_id) + + "." + ) + else: + print("Error! Could not zap monitor.") + + +class SwitchWidget(wx.ScrolledWindow): + """Scrollable window for switches. + + Parameters + ---------- + parent: + parent window. + names: + instance of the names.Names() class. + devices: + instance of the devices.Devices() class. + + Methods + ------- + on_toggle_button(self, event): + Handle event when user presses a button to toggle switch value. + """ + + def __init__(self, parent: wx.Window, names: Names, devices: Devices): + """Initialize the widget.""" + super().__init__( + parent, + -1, + wx.DefaultPosition, + (100, 200), + wx.SUNKEN_BORDER | wx.HSCROLL | wx.VSCROLL, + ) + switches_sizer = wx.BoxSizer(wx.VERTICAL) + self.SetSizer(switches_sizer) + self.SetScrollRate(10, 10) + self.SetAutoLayout(True) + + self.names = names + self.devices = devices + + switches_id_val = list( + map( + lambda device: (device.device_id, device.switch_state), + filter( + lambda device: device.device_kind == self.devices.SWITCH, + self.devices.devices_list, + ), + ) + ) + + self.switch_btn_id_to_device_id = dict() + # Iterate over switches, creating button for each and appending to list + for i, switch in enumerate(switches_id_val): + if switch[1]: # Initial value + label of switch + # depends on initial state of switch + label = "On" + value = True + else: + label = "Off" + value = False + switch_button = wx.ToggleButton( + self, wx.ID_ANY, label=label, size=(130, 30) + ) + switch_button.SetValue(value) + switch_button.Bind(wx.EVT_TOGGLEBUTTON, self.on_toggle_button) + + single_switch_sizer = wx.BoxSizer(wx.HORIZONTAL) + switch_text = wx.StaticText( + self, wx.ID_ANY, names.get_name_string(switches_id_val[i][0]) + ) + single_switch_sizer.Add(switch_text, 1, wx.ALL, 10) + single_switch_sizer.Add(switch_button, 1, wx.ALL, 10) + + switches_sizer.Add(single_switch_sizer, 1, wx.ALIGN_CENTRE, 110) + + self.switch_btn_id_to_device_id[switch_button.GetId()] = switch[0] + + def on_toggle_button(self, event): + """Handle event when user presses a button to toggle switch value. + + Text on button changes between On/Off depending on state. + """ + obj = event.GetEventObject() + button_id = obj.GetId() + switch_id = self.switch_btn_id_to_device_id[button_id] + if obj.GetValue(): + switch_state = 1 + label = "On" + obj.SetLabel(label) + else: + switch_state = 0 + label = "Off" + obj.SetLabel(label) + if self.devices.set_switch(switch_id, switch_state): + print( + "Successfully set " + + self.names.get_name_string(switch_id) + + " " + + label.lower() + + "." + ) + else: + print("Error! Invalid switch.") + + +class ButtonsWidget(wx.BoxSizer): + """Widget containing the control buttons. + + Parameters + ---------- + parent: + parent window. + on_run: + function to handle event of pressing run button. + on_continue: + function to handle event of pressing continue button. + """ + + def __init__( + self, parent: wx.Window, on_run: Callable, on_continue: Callable + ): + """Initialize the widget.""" + super().__init__(wx.HORIZONTAL) + + # Run button + self.run_button = wx.Button(parent, wx.ID_ANY, "Run", size=(100, 30)) + # Continue button + self.cont_button = wx.Button( + parent, wx.ID_ANY, "Continue", size=(100, 30) + ) + + # Bind events to widgets + self.run_button.Bind(wx.EVT_BUTTON, on_run) + self.cont_button.Bind(wx.EVT_BUTTON, on_continue) + + self.Add(self.run_button, 1, wx.LEFT, 10) + self.Add(self.cont_button, 1, wx.LEFT, 10) + + +class Console(wx.TextCtrl): + """Console component. + + The console redirects from stdout. + + Parameters + ---------- + parent: + parent window. + + Methods + ------- + write(self, string): + Write string to console. + """ + + def __init__(self, parent: wx.Window): + """Initialize the component. + + Redirection only happens after initialization, so load this component + before code that throws errors. + """ + super().__init__( + parent, + -1, + size=(200, 100), + style=wx.TE_MULTILINE | wx.TE_READONLY | wx.HSCROLL, + ) + sys.stdout = self + + def write(self, string): + """Write string to console.""" + self.WriteText(string) + + +class StatusBar(wx.StatusBar): + """Status bar to display cycle count. + + Parameters + ---------- + parent: + parent window. + + Methods + ------- + push_cycle_count(self, cycle_completed: int): + Push the current cycle count to status bar. + """ + + def __init__(self, parent): + """Initialize the component.""" + super().__init__(parent) + self.SetFieldsCount(2) + self.SetStatusWidths((-4, -1)) + + def push_cycle_count(self, cycle_completed: int): + """Push the current cycle count to status bar.""" + self.SetStatusText(f"Cycles completed: {cycle_completed}", i=1) diff --git a/src/logicgate.png b/src/logicgate.png new file mode 100644 index 0000000..9e451a0 Binary files /dev/null and b/src/logicgate.png differ diff --git a/src/logsim.py b/src/logsim.py index 6cda66c..e2189dd 100755 --- a/src/logsim.py +++ b/src/logsim.py @@ -66,23 +66,21 @@ def main(arg_list): userint.command_interface() if not options: # no option given, use the graphical user interface + path = None - if len(arguments) != 1: # wrong number of arguments - print("Error: one file path required\n") - print(usage_message) - sys.exit() + if len(arguments) == 1: # wrong number of arguments + [path] = arguments + scanner = Scanner(path, names) + parser = Parser(names, devices, network, monitors, scanner) + parser.parse_network() + if parser.errors.error_counter > 0: + parser.errors.print_error_messages() + return - [path] = arguments - scanner = Scanner(path, names) - parser = Parser(names, devices, network, monitors, scanner) - if parser.parse_network(): - # Initialise an instance of the gui.Gui() class - app = wx.App() - gui = Gui( - "Logic Simulator", path, names, devices, network, monitors - ) - gui.Show(True) - app.MainLoop() + app = wx.App() + gui = Gui("Logic Simulator", path, names, devices, network, monitors) + gui.Show(True) + app.MainLoop() if __name__ == "__main__": diff --git a/tests/circuit3.txt b/tests/circuit3.txt new file mode 100644 index 0000000..0333c04 --- /dev/null +++ b/tests/circuit3.txt @@ -0,0 +1 @@ +DEVICES: