diff options
author | Eddy Pedroni <epedroni@pm.me> | 2025-02-25 09:30:59 +0100 |
---|---|---|
committer | Eddy Pedroni <epedroni@pm.me> | 2025-02-25 09:30:59 +0100 |
commit | 5f6d352aaa4c1273d95a740768c0079ae02005c5 (patch) | |
tree | 8e46db6ec28b329fdaa8de68a0bf57c72723c5e7 | |
parent | 625d7c970dc6cdacc8768bb4ae486009096f2191 (diff) |
Remove legacy gui, update makefile and readme
-rw-r--r-- | Makefile | 2 | ||||
-rw-r--r-- | gui-project/pyproject.toml | 24 | ||||
-rw-r--r-- | gui-project/src/MainWindow.py | 111 | ||||
-rw-r--r-- | gui-project/src/mainwindow.ui | 161 | ||||
-rw-r--r-- | gui-project/src/solo_tool_gui.py | 265 | ||||
-rw-r--r-- | pacman.txt | 6 | ||||
-rw-r--r-- | readme.md | 8 | ||||
-rw-r--r-- | requirements.txt | 1 |
8 files changed, 9 insertions, 569 deletions
@@ -11,7 +11,7 @@ clean: .venv: .venv/touchfile -.venv/touchfile: requirements.txt solo-tool-project/pyproject.toml cli-project/pyproject.toml gui-project/pyproject.toml +.venv/touchfile: requirements.txt solo-tool-project/pyproject.toml cli-project/pyproject.toml web-project/pyproject.toml rm -rf venv uv venv uv pip install -r requirements.txt diff --git a/gui-project/pyproject.toml b/gui-project/pyproject.toml deleted file mode 100644 index 1e6fcf4..0000000 --- a/gui-project/pyproject.toml +++ /dev/null @@ -1,24 +0,0 @@ -[build-system] -requires = ["setuptools"] -build-backend = "setuptools.build_meta" - -[project] -name = "solo_tool_gui" -authors = [ - { name = "Eddy Pedroni", email = "epedroni@pm.me" }, -] -description = "A Qt5-based GUI frontend for the solo_tool library" -requires-python = ">=3.12" -dependencies = [ - "PyQt5>=5.6", - "solo_tool" -] -dynamic = ["version"] - -[project.optional-dependencies] -dev = [ -] - -[project.gui-scripts] -solo-tool-gui = "solo_tool_gui:main" - diff --git a/gui-project/src/MainWindow.py b/gui-project/src/MainWindow.py deleted file mode 100644 index 137bd33..0000000 --- a/gui-project/src/MainWindow.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- coding: utf-8 -*- - -# Form implementation generated from reading ui file 'mainwindow.ui' -# -# Created by: PyQt5 UI code generator 5.15.6 -# -# WARNING: Any manual changes made to this file will be lost when pyuic5 is -# run again. Do not edit this file unless you know what you are doing. - - -from PyQt5 import QtCore, QtGui, QtWidgets - - -class Ui_MainWindow(object): - def setupUi(self, MainWindow): - MainWindow.setObjectName("MainWindow") - MainWindow.resize(971, 767) - self.centralwidget = QtWidgets.QWidget(MainWindow) - self.centralwidget.setObjectName("centralwidget") - self.verticalLayout = QtWidgets.QVBoxLayout(self.centralwidget) - self.verticalLayout.setObjectName("verticalLayout") - self.listsLayout = QtWidgets.QHBoxLayout() - self.listsLayout.setSizeConstraint(QtWidgets.QLayout.SetDefaultConstraint) - self.listsLayout.setSpacing(6) - self.listsLayout.setObjectName("listsLayout") - self.songListView = QtWidgets.QListView(self.centralwidget) - self.songListView.setObjectName("songListView") - self.listsLayout.addWidget(self.songListView) - self.abListView = QtWidgets.QListView(self.centralwidget) - self.abListView.setObjectName("abListView") - self.listsLayout.addWidget(self.abListView) - self.verticalLayout.addLayout(self.listsLayout) - self.slidersLayout = QtWidgets.QVBoxLayout() - self.slidersLayout.setObjectName("slidersLayout") - self.songSlider = QtWidgets.QSlider(self.centralwidget) - self.songSlider.setMinimumSize(QtCore.QSize(0, 0)) - self.songSlider.setOrientation(QtCore.Qt.Horizontal) - self.songSlider.setObjectName("songSlider") - self.slidersLayout.addWidget(self.songSlider) - self.aSlider = QtWidgets.QSlider(self.centralwidget) - self.aSlider.setOrientation(QtCore.Qt.Horizontal) - self.aSlider.setObjectName("aSlider") - self.slidersLayout.addWidget(self.aSlider) - self.bSlider = QtWidgets.QSlider(self.centralwidget) - self.bSlider.setOrientation(QtCore.Qt.Horizontal) - self.bSlider.setObjectName("bSlider") - self.slidersLayout.addWidget(self.bSlider) - self.verticalLayout.addLayout(self.slidersLayout) - self.buttonsLayout = QtWidgets.QGridLayout() - self.buttonsLayout.setObjectName("buttonsLayout") - self.pauseButton = QtWidgets.QPushButton(self.centralwidget) - self.pauseButton.setObjectName("pauseButton") - self.buttonsLayout.addWidget(self.pauseButton, 0, 1, 1, 1) - self.initMidiButton = QtWidgets.QPushButton(self.centralwidget) - self.initMidiButton.setObjectName("initMidiButton") - self.buttonsLayout.addWidget(self.initMidiButton, 0, 4, 1, 1) - self.rateSlider = QtWidgets.QSlider(self.centralwidget) - sizePolicy = QtWidgets.QSizePolicy(QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed) - sizePolicy.setHorizontalStretch(0) - sizePolicy.setVerticalStretch(0) - sizePolicy.setHeightForWidth(self.rateSlider.sizePolicy().hasHeightForWidth()) - self.rateSlider.setSizePolicy(sizePolicy) - self.rateSlider.setOrientation(QtCore.Qt.Horizontal) - self.rateSlider.setObjectName("rateSlider") - self.buttonsLayout.addWidget(self.rateSlider, 2, 0, 1, 1) - self.playButton = QtWidgets.QPushButton(self.centralwidget) - self.playButton.setObjectName("playButton") - self.buttonsLayout.addWidget(self.playButton, 0, 0, 1, 1) - self.saveSessionButton = QtWidgets.QPushButton(self.centralwidget) - self.saveSessionButton.setObjectName("saveSessionButton") - self.buttonsLayout.addWidget(self.saveSessionButton, 0, 2, 1, 1) - self.storeAbButton = QtWidgets.QPushButton(self.centralwidget) - self.storeAbButton.setObjectName("storeAbButton") - self.buttonsLayout.addWidget(self.storeAbButton, 2, 3, 1, 1) - self.loadSessionButton = QtWidgets.QPushButton(self.centralwidget) - self.loadSessionButton.setObjectName("loadSessionButton") - self.buttonsLayout.addWidget(self.loadSessionButton, 0, 3, 1, 1) - self.abRepeatCheckBox = QtWidgets.QCheckBox(self.centralwidget) - self.abRepeatCheckBox.setObjectName("abRepeatCheckBox") - self.buttonsLayout.addWidget(self.abRepeatCheckBox, 2, 1, 1, 1) - self.addSongButton = QtWidgets.QPushButton(self.centralwidget) - self.addSongButton.setObjectName("addSongButton") - self.buttonsLayout.addWidget(self.addSongButton, 2, 4, 1, 1) - self.horizontalLayout = QtWidgets.QHBoxLayout() - self.horizontalLayout.setObjectName("horizontalLayout") - self.setAButton = QtWidgets.QPushButton(self.centralwidget) - self.setAButton.setObjectName("setAButton") - self.horizontalLayout.addWidget(self.setAButton) - self.setBButton = QtWidgets.QPushButton(self.centralwidget) - self.setBButton.setObjectName("setBButton") - self.horizontalLayout.addWidget(self.setBButton) - self.buttonsLayout.addLayout(self.horizontalLayout, 2, 2, 1, 1) - self.verticalLayout.addLayout(self.buttonsLayout) - MainWindow.setCentralWidget(self.centralwidget) - - self.retranslateUi(MainWindow) - QtCore.QMetaObject.connectSlotsByName(MainWindow) - - def retranslateUi(self, MainWindow): - _translate = QtCore.QCoreApplication.translate - MainWindow.setWindowTitle(_translate("MainWindow", "MainWindow")) - self.pauseButton.setText(_translate("MainWindow", "Pause")) - self.initMidiButton.setText(_translate("MainWindow", "Connect MIDI")) - self.playButton.setText(_translate("MainWindow", "Play")) - self.saveSessionButton.setText(_translate("MainWindow", "Save session")) - self.storeAbButton.setText(_translate("MainWindow", "Store AB")) - self.loadSessionButton.setText(_translate("MainWindow", "Load session")) - self.abRepeatCheckBox.setText(_translate("MainWindow", "AB repeat")) - self.addSongButton.setText(_translate("MainWindow", "Add song")) - self.setAButton.setText(_translate("MainWindow", "Set A")) - self.setBButton.setText(_translate("MainWindow", "Set B")) diff --git a/gui-project/src/mainwindow.ui b/gui-project/src/mainwindow.ui deleted file mode 100644 index ac4d97b..0000000 --- a/gui-project/src/mainwindow.ui +++ /dev/null @@ -1,161 +0,0 @@ -<?xml version="1.0" encoding="UTF-8"?> -<ui version="4.0"> - <class>MainWindow</class> - <widget class="QMainWindow" name="MainWindow"> - <property name="geometry"> - <rect> - <x>0</x> - <y>0</y> - <width>971</width> - <height>767</height> - </rect> - </property> - <property name="windowTitle"> - <string>MainWindow</string> - </property> - <widget class="QWidget" name="centralwidget"> - <layout class="QVBoxLayout" name="verticalLayout"> - <item> - <layout class="QHBoxLayout" name="listsLayout"> - <property name="spacing"> - <number>6</number> - </property> - <property name="sizeConstraint"> - <enum>QLayout::SetDefaultConstraint</enum> - </property> - <item> - <widget class="QListView" name="songListView"/> - </item> - <item> - <widget class="QListView" name="abListView"/> - </item> - </layout> - </item> - <item> - <layout class="QVBoxLayout" name="slidersLayout"> - <item> - <widget class="QSlider" name="songSlider"> - <property name="minimumSize"> - <size> - <width>0</width> - <height>0</height> - </size> - </property> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - </widget> - </item> - <item> - <widget class="QSlider" name="aSlider"> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - </widget> - </item> - <item> - <widget class="QSlider" name="bSlider"> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - </widget> - </item> - </layout> - </item> - <item> - <layout class="QGridLayout" name="buttonsLayout"> - <item row="0" column="1"> - <widget class="QPushButton" name="pauseButton"> - <property name="text"> - <string>Pause</string> - </property> - </widget> - </item> - <item row="0" column="4"> - <widget class="QPushButton" name="initMidiButton"> - <property name="text"> - <string>Connect MIDI</string> - </property> - </widget> - </item> - <item row="2" column="0"> - <widget class="QSlider" name="rateSlider"> - <property name="sizePolicy"> - <sizepolicy hsizetype="Minimum" vsizetype="Fixed"> - <horstretch>0</horstretch> - <verstretch>0</verstretch> - </sizepolicy> - </property> - <property name="orientation"> - <enum>Qt::Horizontal</enum> - </property> - </widget> - </item> - <item row="0" column="0"> - <widget class="QPushButton" name="playButton"> - <property name="text"> - <string>Play</string> - </property> - </widget> - </item> - <item row="0" column="2"> - <widget class="QPushButton" name="saveSessionButton"> - <property name="text"> - <string>Save session</string> - </property> - </widget> - </item> - <item row="2" column="3"> - <widget class="QPushButton" name="storeAbButton"> - <property name="text"> - <string>Store AB</string> - </property> - </widget> - </item> - <item row="0" column="3"> - <widget class="QPushButton" name="loadSessionButton"> - <property name="text"> - <string>Load session</string> - </property> - </widget> - </item> - <item row="2" column="1"> - <widget class="QCheckBox" name="abRepeatCheckBox"> - <property name="text"> - <string>AB repeat</string> - </property> - </widget> - </item> - <item row="2" column="4"> - <widget class="QPushButton" name="addSongButton"> - <property name="text"> - <string>Add song</string> - </property> - </widget> - </item> - <item row="2" column="2"> - <layout class="QHBoxLayout" name="horizontalLayout"> - <item> - <widget class="QPushButton" name="setAButton"> - <property name="text"> - <string>Set A</string> - </property> - </widget> - </item> - <item> - <widget class="QPushButton" name="setBButton"> - <property name="text"> - <string>Set B</string> - </property> - </widget> - </item> - </layout> - </item> - </layout> - </item> - </layout> - </widget> - </widget> - <resources/> - <connections/> -</ui> diff --git a/gui-project/src/solo_tool_gui.py b/gui-project/src/solo_tool_gui.py deleted file mode 100644 index cd73c9a..0000000 --- a/gui-project/src/solo_tool_gui.py +++ /dev/null @@ -1,265 +0,0 @@ -import sys - -from PyQt5.QtGui import * -from PyQt5.QtWidgets import * -from PyQt5.QtCore import * -from MainWindow import Ui_MainWindow - -from solo_tool import SoloTool -from solo_tool.midi_controller_launchpad_mini import MidiController - -POSITION_FACTOR = 100000 -RATE_FACTOR = 10 -UI_REFRESH_PERIOD_MS = 500 - -CHANGE_GUI = 0 -CHANGE_INTERNAL = 1 - -class PlaylistModel(QAbstractListModel): - def __init__(self, soloTool, *args, **kwargs): - super(PlaylistModel, self).__init__(*args, **kwargs) - self.soloTool = soloTool - - def data(self, index, role): - if role == Qt.DisplayRole: - from pathlib import Path - path = Path(self.soloTool.getSongs()[index.row()]) - return path.name - - def rowCount(self, index): - return len(self.soloTool.getSongs()) - -class ABListModel(QAbstractListModel): - def __init__(self, soloTool, *args, **kwargs): - super(ABListModel, self).__init__(*args, **kwargs) - self.soloTool = soloTool - - def data(self, index, role): - if role == Qt.DisplayRole: - ab = self.soloTool.getStoredAbLimits()[index.row()] - return f"{ab[0]} - {ab[1]}" - - def rowCount(self, index): - return len(self.soloTool.getStoredAbLimits()) - -class MainWindow(QMainWindow, Ui_MainWindow): - songChangeSignal = pyqtSignal(int) - abLimitsChangeSignal = pyqtSignal(int) - - def __init__(self, *args, **kwargs): - super(MainWindow, self).__init__(*args, **kwargs) - - self.setupUi(self) - - self.timer = QTimer(self) - self.timer.setInterval(UI_REFRESH_PERIOD_MS) - self.timer.timeout.connect(self.timerCallback) - - self.soloTool = SoloTool() - self.midiController = MidiController(self.soloTool) - - self.playlistModel = PlaylistModel(self.soloTool) - self.songListView.setModel(self.playlistModel) - self.songListView.selectionModel().selectionChanged.connect(self.playlistSelectionChanged) - self.songChangePending = None - self.songChangeSignal.connect(self.currentSongChanged) - self.soloTool.registerCurrentSongCallback(self.songChangeSignal.emit) - - self.abListModel = ABListModel(self.soloTool) - self.abListView.setModel(self.abListModel) - self.abListView.selectionModel().selectionChanged.connect(self.abListSelectionChanged) - self.abLimitsChangePending = None - self.abLimitsChangeSignal.connect(self.currentAbLimitsChanged) - self.soloTool.registerCurrentAbLimitsCallback(self.abLimitsChangeSignal.emit) - - self.songSlider.setMaximum(POSITION_FACTOR) - self.songSlider.sliderPressed.connect(self.songSliderPressed) - self.songSlider.sliderReleased.connect(self.songSliderReleased) - - self.aSlider.setMaximum(POSITION_FACTOR) - self.aSlider.sliderReleased.connect(self.abSliderReleased) - self.bSlider.setMaximum(POSITION_FACTOR) - self.bSlider.sliderReleased.connect(self.abSliderReleased) - - self.rateSlider.setRange(int(0.5 * RATE_FACTOR), int(1.2 * RATE_FACTOR)) - self.rateSlider.setSingleStep(int(0.1 * RATE_FACTOR)) - self.rateSlider.setValue(int(1.0 * RATE_FACTOR)) - self.rateSlider.sliderReleased.connect(self.rateSliderReleased) - - self.playButton.pressed.connect(self.soloTool.play) - self.pauseButton.pressed.connect(self.soloTool.pause) - self.storeAbButton.pressed.connect(self.storeAbLimits) - self.setAButton.pressed.connect(self.setA) - self.setBButton.pressed.connect(self.setB) - self.saveSessionButton.pressed.connect(self.saveSession) - self.loadSessionButton.pressed.connect(self.loadSession) - self.addSongButton.pressed.connect(self.addSong) - self.abRepeatCheckBox.clicked.connect(self.toggleAbRepeat) - self.initMidiButton.pressed.connect(self.initMidi) - - self.timer.start() - - if len(sys.argv) > 1: - self.loadSession(sys.argv[1]) - - self.show() - - def timerCallback(self): - position = self.soloTool.getPlaybackPosition() * POSITION_FACTOR - self.songSlider.setValue(int(position)) - self.soloTool.tick() - - def addSong(self): - path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "mp3 Audio (*.mp3);FLAC audio (*.flac);All files (*.*)") - if path: - self.soloTool.addSong(path) - self.playlistModel.layoutChanged.emit() - - def storeAbLimits(self): - a = self.aSlider.value() / float(POSITION_FACTOR) - b = self.bSlider.value() / float(POSITION_FACTOR) - self.soloTool.storeAbLimits(a, b) - self.abListModel.layoutChanged.emit() - - def setA(self): - position = self.songSlider.value() - self.aSlider.setValue(position) - self.abSliderReleased() - - def setB(self): - position = self.songSlider.value() - self.bSlider.setValue(position) - self.abSliderReleased() - - def toggleAbRepeat(self): - enable = self.abRepeatCheckBox.isChecked() - self.soloTool.setAbLimitEnable(enable) - - def saveSession(self): - path, _ = QFileDialog.getSaveFileName(self, "Open file", "", "session file (*.json)") - if path: - self.soloTool.saveSession(path) - - def loadSession(self, path=None): - if path is None: - path, _ = QFileDialog.getOpenFileName(self, "Open file", "", "session file (*.json)") - if path is not None: - self.soloTool.loadSession(path) - self.playlistModel.layoutChanged.emit() - self.abListModel.layoutChanged.emit() - - def songSliderPressed(self): - self.timer.stop() - - def songSliderReleased(self): - position = self.songSlider.value() / float(POSITION_FACTOR) - self.soloTool.setPlaybackPosition(position) - self.timer.start() - - def clearListViewSelection(self, listView): - i = listView.selectionModel().currentIndex() - listView.selectionModel().select(i, QItemSelectionModel.Deselect) - - def abSliderReleased(self): - a = self.aSlider.value() / float(POSITION_FACTOR) - b = self.bSlider.value() / float(POSITION_FACTOR) - self.soloTool.setAbLimits(a, b) - self.clearListViewSelection(self.abListView) - - def rateSliderReleased(self): - rate = self.rateSlider.value() / float(RATE_FACTOR) - self.soloTool.setPlaybackRate(rate) - - def playlistSelectionChanged(self, i): - if self.songChangePending == CHANGE_INTERNAL: - self.songChangePending = None - else: - assert self.songChangePending is None - self.songChangePending = CHANGE_GUI - index = i.indexes()[0].row() - self.soloTool.setSong(index) - - self.clearListViewSelection(self.abListView) - self.abListModel.layoutChanged.emit() - - def currentSongChanged(self, songIndex): - if self.songChangePending == CHANGE_GUI: - self.songChangePending = None - else: - assert self.songChangePending is None - self.songChangePending = CHANGE_INTERNAL - i = self.playlistModel.createIndex(songIndex, 0) - self.songListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) - - def abListSelectionChanged(self, i): - if self.abLimitsChangePending == CHANGE_INTERNAL: - print("Ack internal change") - self.abLimitsChangePending = None - else: - assert self.abLimitsChangePending is None - if i is not None and not i.isEmpty(): - print("Processing GUI change") - self.abLimitsChangePending = CHANGE_GUI - index = i.indexes()[0].row() - ab = self.soloTool.getStoredAbLimits()[index] - self.soloTool.loadAbLimits(index) - self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) - self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) - - def currentAbLimitsChanged(self, abIndex): - if self.abLimitsChangePending == CHANGE_GUI: - print("Ack GUI change") - self.abLimitsChangePending = None - else: - assert self.abLimitsChangePending is None - print("Processing internal change") - self.abLimitsChangePending = CHANGE_INTERNAL - i = self.abListModel.createIndex(abIndex, 0) - self.abListView.selectionModel().select(i, QItemSelectionModel.ClearAndSelect) - ab = self.soloTool.getStoredAbLimits()[abIndex] - self.aSlider.setValue(int(ab[0] * POSITION_FACTOR)) - self.bSlider.setValue(int(ab[1] * POSITION_FACTOR)) - - def initMidi(self): - try: - self.midiController.connect() - except Exception as e: - print("Error: could not connect to MIDI controller") - print(e) - - def keyPressEvent(self, event): - if event.key() == Qt.Key_Super_L: - self.soloTool.jumpToA() - - def closeEvent(self, event): - self.midiController.disconnect() - event.accept() - -def main(): - app = QApplication([]) - app.setApplicationName("Solo Tool") - app.setStyle("Fusion") - - # Fusion dark palette from https://gist.github.com/QuantumCD/6245215. - palette = QPalette() - palette.setColor(QPalette.Window, QColor(53, 53, 53)) - palette.setColor(QPalette.WindowText, Qt.white) - palette.setColor(QPalette.Base, QColor(25, 25, 25)) - palette.setColor(QPalette.AlternateBase, QColor(53, 53, 53)) - palette.setColor(QPalette.ToolTipBase, Qt.white) - palette.setColor(QPalette.ToolTipText, Qt.white) - palette.setColor(QPalette.Text, Qt.white) - palette.setColor(QPalette.Button, QColor(53, 53, 53)) - palette.setColor(QPalette.ButtonText, Qt.white) - palette.setColor(QPalette.BrightText, Qt.red) - palette.setColor(QPalette.Link, QColor(42, 130, 218)) - palette.setColor(QPalette.Highlight, QColor(42, 130, 218)) - palette.setColor(QPalette.HighlightedText, Qt.black) - app.setPalette(palette) - app.setStyleSheet("QToolTip { color: #ffffff; background-color: #2a82da; border: 1px solid white; }") - - window = MainWindow() - app.exec_() - -if __name__ == '__main__': - main() @@ -1,6 +1,8 @@ pkgconf gst-plugins-good -qt5-multimedia - +For VLC backend: vlc + +For mplayer backend: +mplayer @@ -10,16 +10,16 @@ Non-Python dependencies are listed in pacman.txt and should be manually installe ## Usage -To set up the environment and run the tests, just use `make`: +To set up the environment and run the tests, run `make`: ``` make ``` -The GUI can then be executed in the venv: +The web GUI can also be run with `make`: ``` -./venv/bin/solo_tool_gui +make gui ``` Alternatively, the tool can be executed in headless mode. In this case all it does is load the provided session and connect to the MIDI controller: @@ -30,7 +30,7 @@ Alternatively, the tool can be executed in headless mode. In this case all it do ## MIDI -It is currently possible to control the tool with MIDI. With the device plugged in, a connection can be established by clicking on "Connect MIDI" in the GUI or running the headless binary. Currently the only device supported is the Novation Launchpad Mini Mk II. +It is currently possible to control the tool with MIDI. With the device plugged in, a connection is automatically established by the CLI. Currently the only device supported is the Novation Launchpad Mini Mk II. The MIDI device button mapping is documented in `doc/diagram.drawio`. diff --git a/requirements.txt b/requirements.txt index 7c19832..459ff68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,4 @@ -e solo-tool-project[dev] -e cli-project[dev] --e gui-project[dev] -e web-project[dev] |