Commit 944d3d5f authored by Benjamin Mummery's avatar Benjamin Mummery 💻

Merge branch 'ui_dev' of https://ohwr.org/project/hev into ui_dev

parents 8b4110b1 0a7f7ac2
......@@ -18,26 +18,23 @@ __email__ = "benjamin.mummery@stfc.ac.uk"
__status__ = "Prototype"
import argparse
import git
import json
import logging
import sys
import os
from PySide2 import QtCore
import re
import sys
from threading import Lock
import git
import numpy as np
from global_widgets.global_send_popup import confirmPopup
from hevclient import HEVClient
from ui_layout import Layout
from ui_widgets import Widgets
from threading import Lock
from PySide2 import QtCore
from PySide2.QtCore import Signal, Slot
from PySide2.QtGui import QColor, QFont, QPalette
from PySide2.QtWidgets import QApplication, QMainWindow, QWidget
from ui_layout import Layout
from ui_widgets import Widgets
logging.basicConfig(
level=logging.WARNING, format="%(asctime)s - %(levelname)s - %(message)s"
......@@ -47,7 +44,9 @@ logging.basicConfig(
class NativeUI(HEVClient, QMainWindow):
"""Main application with client logic"""
battery_signal = Signal()
BatterySignal = Signal(dict)
PlotSignal = Signal(dict)
MeasurementSignal = Signal(dict, dict)
def __init__(self, *args, **kwargs):
super(NativeUI, self).__init__(*args, **kwargs)
......@@ -173,35 +172,102 @@ class NativeUI(HEVClient, QMainWindow):
self.__define_connections()
@Slot(str)
def change_page(self, page_to_show: str):
def change_page(self, page_to_show: str) -> int:
"""
Change the page shown in page_stack.
"""
self.widgets.page_stack.setCurrentWidget(getattr(self.widgets, page_to_show))
return 0
def __define_connections(self):
def __define_connections(self) -> int:
"""
Connect the signals and slots necessary for the UI to function.
Connections defined here:
BatterySignal -> battery_display
BatterySignal is emitted in get_updates() in response to a battery payload.
HistoryButtonPressed -> normal_plots, detailed_plots
History buttons control the amount of time shown on the x axes of the
value-time plots on the main page. HistoryButtonPressed is emitted by the
history button when pressed.
PageButtonPressed -> change_page
Page buttons control which page is shown in the page_stack.
PageButtonPressed is emitted by the page button when pressed.
ToggleButtonPressed -> charts_widget.show_line
ToggleButtonPressed -> charts_widget.hide_line
Lines in the charts_widget are shown or hidden depending on whether the
relevent toggle button is in the checked or unchecked state.
ToggleButtonPressed is emitted by the toggle button when pressed.
PlotSignal -> normal_plots, detailed_plots, charts_widget
Data plotted in the normal, detailed, and charts plots is updated in
response to PlotSignal. PlotSignal is emitted in __emit_plots_signal() which
is triggered at timer.timeout.
MeasurementSignal -> normal_measurements, detailed_measurements
Data shown in the normal and detailed measurments widgets is updated in
response to MeasurementSignal. MeasurementSignal is emitted in
__emit_measurements_signal() which is triggered at timer.timeout.
"""
# Battery Display should update when we get battery info
self.battery_signal.connect(self.widgets.battery_display.update_value)
self.BatterySignal.connect(self.widgets.battery_display.update_value)
# Plots should update when we press the history buttons
for button in self.widgets.history_buttons.buttons:
for widget in [self.widgets.normal_plots, self.widgets.detailed_plots]:
button.HistoryButtonPressed.connect(widget.update_plot_time_range)
# The shown page should change when we press the page buttons
for button in self.widgets.page_buttons.buttons:
button.PageButtonPressed.connect(self.change_page)
# Plot data should update on a timer
# TODO: make this actually grab the data and send it to the plots, rather than
# having the plots reach to NativeUI for the data.
# Lines displayed on the charts page should update when the corresponding
# buttons are toggled.
for button in self.widgets.chart_buttons_widget.buttons:
button.ToggleButtonPressed.connect(self.widgets.charts_widget.show_line)
button.ToggleButtonReleased.connect(self.widgets.charts_widget.hide_line)
button.on_press() # Ensure states of the plots match states of buttons.
button.toggle()
# Plot data and measurements should update on a timer
self.timer = QtCore.QTimer()
self.timer.setInterval(16) # just faster than 60Hz
self.timer.timeout.connect(self.widgets.normal_plots.update_plot_data)
self.timer.timeout.connect(self.widgets.detailed_plots.update_plot_data)
self.timer.timeout.connect(self.widgets.normal_measurements.update_value)
self.timer.timeout.connect(self.widgets.detailed_measurements.update_value)
self.timer.timeout.connect(self.__emit_plots_signal)
self.timer.timeout.connect(self.__emit_measurements_signal)
self.timer.timeout.connect(self.widgets.alarm_tab.update_alarms)
self.timer.start()
def get_db(self, database_name: str):
# When plot data is updated, plots should update
self.PlotSignal.connect(self.widgets.normal_plots.update_plot_data)
self.PlotSignal.connect(self.widgets.detailed_plots.update_plot_data)
self.PlotSignal.connect(self.widgets.charts_widget.update_plot_data)
# When measurement data is updated, measurement widgets shouldupdate
self.MeasurementSignal.connect(self.widgets.normal_measurements.update_value)
self.MeasurementSignal.connect(self.widgets.detailed_measurements.update_value)
return 0
def __emit_plots_signal(self) -> int:
"""
Get the current status of the 'plots' db and emit the plot_signal signal.
"""
self.PlotSignal.emit(self.get_db("plots"))
return 0
def __emit_measurements_signal(self) -> int:
"""
Get the current status of the 'cycle' and 'readback' dbs and emit the
measurements_signal signal.
"""
self.MeasurementSignal.emit(self.get_db("cycle"), self.get_db("readback"))
return 0
def get_db(self, database_name: str) -> str:
"""
Return the contents of the specified database dict, assuming that it is present
in __database_list.
......@@ -370,8 +436,8 @@ class NativeUI(HEVClient, QMainWindow):
self.set_plots_db(payload["DATA"])
self.ongoingAlarms = payload["alarms"]
if payload["type"] == "BATTERY":
self.set_battery_db(payload["BATTERY"])
self.battery_signal.emit()
self.__set_db("battery", payload["BATTERY"])
self.BatterySignal.emit(self.get_db("battery"))
if payload["type"] == "ALARM":
self.set_alarms_db(payload["ALARM"])
if payload["type"] == "TARGET":
......
#! /usr/bin/env python3
import pytest
import sys
"""
Unit tests for NativeUI
"""
import json
from PySide2.QtWidgets import QApplication
import sys
import hevclient
import numpy as np
import pytest
from PySide2.QtWidgets import QApplication
sys.path.append("../..")
from NativeUI import NativeUI
import hevclient
hevclient.mmFileName = "/home/pi/hev/NativeUI/tests/integration/fixtures/HEVClient_lastData.mmap"
sys.path.append("../..")
hevclient.mmFileName = (
"/home/pi/hev/NativeUI/tests/integration/fixtures/HEVClient_lastData.mmap"
)
@pytest.fixture(scope="session", autouse=True)
def widget():
app = QApplication(sys.argv)
widget = NativeUI()
return widget
return NativeUI()
# Test default values of databases(no set method involved)
......@@ -63,7 +69,7 @@ def test_must_return_correct_db_item_from_get_db_plots(widget):
def test_must_return_correct_db_item_from_get_db_alarms(widget):
assert widget.get_db("__alarms") == [] and widget.get_db("alarms") == []
assert widget.get_db("__alarms") == {} and widget.get_db("alarms") == {}
def test_must_return_correct_db_item_from_get_db_targets(widget):
......@@ -78,44 +84,46 @@ def test_must_return_correct_db_item_from_get_db_personal(widget):
def test_must_return_0_for_set_data_db(widget):
with open("/home/pi/hev/samples/dataSample.json", "r") as f:
data_payload = json.load(f)
assert widget.set_data_db(data_payload) == 0
assert widget.__set_db("data", data_payload) == 0
def test_must_return_0_for_set_targets_db(widget):
with open("/home/pi/hev/samples/targetSample.json", "r") as g:
target_payload = json.load(g)
assert widget.set_targets_db(target_payload) == 0
assert widget.__set_db("targets", target_payload) == 0
def test_must_return_0_for_set_readback_db(widget):
with open("/home/pi/hev/samples/readbackSample.json", "r") as f:
readback_payload = json.load(f)
assert widget.set_readback_db(readback_payload) == 0
assert widget.__set_db("readback", readback_payload) == 0
def test_must_return_0_for_set_cycle_db(widget):
with open("/home/pi/hev/NativeUI/tests/unittests/fixtures/cycleSample.json", "r") as f:
with open(
"/home/pi/hev/NativeUI/tests/unittests/fixtures/cycleSample.json", "r"
) as f:
cycle_payload = json.load(f)
assert widget.set_cycle_db(cycle_payload) == 0
assert widget.__set_db("cycle", cycle_payload) == 0
def test_must_return_0_for_set_battery_db(widget):
with open("/home/pi/hev/samples/batterySample.json", "r") as f:
battery_payload = json.load(f)
assert widget.set_battery_db(battery_payload) == 0
assert widget.__set_db("battery", battery_payload) == 0
def test_must_return_0_for_set_plots_db(widget):
with open("/home/pi/hev/samples/dataSample.json", "r") as f:
data_payload = json.load(f)
assert widget.set_plots_db(data_payload) == 0
assert widget.__set_plots_db(data_payload) == 0
def test_must_return_error_if_not_data_is_sent_as_payload_for_set_plots_db(widget):
with open("/home/pi/hev/samples/batterySample.json", "r") as f:
battery_payload = json.load(f)
with pytest.raises(KeyError):
widget.set_plots_db(battery_payload)
widget.__set_plots_db(battery_payload)
def test_must_return_0_when__update_plot_ranges_correctly(widget):
......@@ -125,13 +133,15 @@ def test_must_return_0_when__update_plot_ranges_correctly(widget):
def test_must_return_0_for_set_alarms_db(widget):
with open("/home/pi/hev/samples/alarmSample.json", "r") as f:
alarm_payload = json.load(f)
assert widget.set_alarms_db(alarm_payload) == 0
assert widget.__set_db("alarms", alarm_payload) == 0
def test_must_return_0_for_set_personal_db(widget):
with open("/home/pi/hev/NativeUI/tests/unittests/fixtures/personalSample.json", "r") as f:
with open(
"/home/pi/hev/NativeUI/tests/unittests/fixtures/personalSample.json", "r"
) as f:
personal_payload = json.load(f)
assert widget.set_personal_db(personal_payload) == 0
assert widget.__set_db("personal", personal_payload) == 0
# Asyncio can handle event loops, but we need to add more interaction i think
......@@ -183,4 +193,3 @@ def test_must_return_0_when__find_icons_svg_directory(widget):
def test_must_return_0_when_cannot__find_icons_directory(widget):
with pytest.raises(FileNotFoundError):
widget.__find_icons("images")
......@@ -190,7 +190,11 @@ class Layout:
"""
page_alarms = SwitchableStackWidget(
self.NativeUI,
[self.widgets.alarm_tab, self.widgets.alarm_table_tab, self.widgets.clinical_tab],
[
self.widgets.alarm_tab,
self.widgets.alarm_table_tab,
self.widgets.clinical_tab,
],
["List of Alarms", "Alarm Table", "Clinical Limits"],
)
page_alarms.setFont(self.NativeUI.text_font)
......@@ -200,9 +204,17 @@ class Layout:
"""
Layout for the settings page.
"""
# Create the Charts tab
tab_charts = self.layout_tab_charts(
[self.widgets.charts_widget, self.widgets.chart_buttons_widget]
)
self.widgets.chart_buttons_widget.setFont(self.NativeUI.text_font)
self.widgets.chart_buttons_widget.set_size(self.left_bar_width, None)
# Create the stack
page_settings = SwitchableStackWidget(
self.NativeUI,
[self.widgets.settings_expert_tab, self.widgets.settings_chart_tab],
[self.widgets.settings_expert_tab, tab_charts],
["Expert", "Charts"],
)
page_settings.setFont(self.NativeUI.text_font)
......@@ -279,6 +291,14 @@ class Layout:
tab_main_detailed.setLayout(tab_main_detailed_layout)
return tab_main_detailed
def layout_tab_charts(self, widgets: list) -> QtWidgets.QWidget:
tab_charts = QtWidgets.QWidget()
tab_charts_layout = QtWidgets.QHBoxLayout(tab_charts)
for widget in widgets:
tab_charts_layout.addWidget(widget)
tab_charts.setLayout(tab_charts_layout)
return tab_charts
def __make_stack(self, widgets):
"""
Make a stack of widgets
......
......@@ -13,29 +13,34 @@ __maintainer__ = "Benjamin Mummery"
__email__ = "benjamin.mummery@stfc.ac.uk"
__status__ = "Prototype"
from PySide2.QtWidgets import QWidget
from alarm_widgets.tab_alarm_table import TabAlarmTable
from alarm_widgets.tab_alarms import TabAlarm
from alarm_widgets.tab_clinical import TabClinical
from global_widgets.tab_modeswitch_button import TabModeswitchButton
from mode_widgets.tab_modes import TabModes
from mode_widgets.tab_personal import TabPersonal
from PySide2.QtWidgets import QWidget
from widget_library.battery_display_widget import BatteryDisplayWidget
# from widget_library.tab_charts import TabChart
from widget_library.chart_buttons_widget import ChartButtonsWidget
from widget_library.history_buttons_widget import HistoryButtonsWidget
from widget_library.measurements_widget import (
NormalMeasurementsBlockWidget,
ExpertMeasurementsBloackWidget,
NormalMeasurementsBlockWidget,
)
from widget_library.plot_widget import TimePlotsWidget, CirclePlotsWidget
from widget_library.spin_buttons_widget import SpinButtonsWidget
from widget_library.page_buttons_widget import PageButtonsWidget
from widget_library.personal_display_widget import PersonalDisplayWidget
from widget_library.battery_display_widget import BatteryDisplayWidget
from widget_library.plot_widget import (
ChartsPlotWidget,
CirclePlotsWidget,
TimePlotsWidget,
)
from widget_library.spin_buttons_widget import SpinButtonsWidget
from widget_library.tab_expert import TabExpert
from widget_library.ventilator_start_stop_buttons_widget import (
VentilatorStartStopButtonsWidget,
)
from widget_library.tab_expert import TabExpert
from widget_library.tab_charts import TabChart
from mode_widgets.tab_modes import TabModes
from mode_widgets.tab_personal import TabPersonal
from alarm_widgets.tab_alarms import TabAlarm
from alarm_widgets.tab_alarm_table import TabAlarmTable
from alarm_widgets.tab_clinical import TabClinical
class Widgets:
......@@ -75,7 +80,9 @@ class Widgets:
# Settings Page Widgets
self.settings_expert_tab = TabExpert(NativeUI)
self.settings_chart_tab = TabChart(NativeUI)
self.charts_widget = ChartsPlotWidget(colors=NativeUI.colors)
self.chart_buttons_widget = ChartButtonsWidget(colors=NativeUI.colors)
# self.settings_chart_tab = TabChart(NativeUI)
# Modes Page Widgets
self.mode_settings_tab = TabModes(NativeUI)
......
#!/usr/bin/env python3
"""
chart_buttons_widget.py
"""
__author__ = ["Benjamin Mummery", "Tiago Sarmento"]
__credits__ = ["Benjamin Mummery", "Dónal Murray", "Tim Powell", "Tiago Sarmento"]
__license__ = "GPL"
__version__ = "0.0.1"
__maintainer__ = "Benjamin Mummery"
__email__ = "benjamin.mummery@stfc.ac.uk"
__status__ = "Development"
from PySide2 import QtGui, QtWidgets
from PySide2.QtCore import QSize, Signal, Slot
class ChartButtonsWidget(QtWidgets.QWidget):
def __init__(self, *args, colors: dict = {}, **kwargs):
super().__init__(*args, **kwargs)
self.buttons = [
ToggleButtonWidget("Pressure", signal_value="pressure"),
ToggleButtonWidget("Flow", signal_value="flow"),
]
stylesheet = (
"QPushButton{"
" background-color:" + colors["button_background_enabled"].name() + ";"
" color:" + colors["button_foreground_enabled"].name() + ";"
" border: none"
"}"
"QPushButton:checked{"
" background-color:" + colors["button_background_disabled"].name() + ";"
" color:" + colors["button_foreground_enabled"].name() + ";"
" border: none"
"}"
)
for button in self.buttons:
button.setStyleSheet(stylesheet)
# Layout buttons block
grid = QtWidgets.QGridLayout()
i_row = 0
i_col = 0
for widget in self.buttons:
grid.addWidget(widget, i_row, i_col)
i_row += 1
self.setLayout(grid)
def set_size(self, x: int, y: int, spacing: int = 10) -> int:
"""
Set the size of the widget and its subwidgets.
"""
pass
def setFont(self, font: QtGui.QFont) -> int:
"""
Overrides the existing setFont method in order to propogate the change to
subwidgets.
"""
for button in self.buttons:
button.setFont(font)
return 0
class ToggleButtonWidget(QtWidgets.QPushButton):
"""
Variant of the QPushButton that emits a signal containing a string (signal_value)
"""
ToggleButtonPressed = Signal(str)
ToggleButtonReleased = Signal(str)
def __init__(self, *args, signal_value: str = None, **kwargs):
super().__init__(*args, **kwargs)
self.__signal_value = signal_value
self.setCheckable(True)
self.pressed.connect(self.on_press)
def on_press(self) -> int:
"""
When the button is pressed, emit the either the ToggleButtonPressed or
ToggleButtonReleased signal depending on whether the button was in the checked
or unchacked state.
"""
if self.isChecked(): # active -> inactive
self.ToggleButtonReleased.emit(self.__signal_value)
else: # inactive -> active
self.ToggleButtonPressed.emit(self.__signal_value)
return 0
......@@ -144,7 +144,7 @@ class HistoryButton(QtWidgets.QPushButton):
HistoryButtonPressed = Signal(int)
def __init__(self, *args, signal_value=None, **kwargs):
def __init__(self, *args, signal_value: int = None, **kwargs):
super().__init__(*args, **kwargs)
self.__signal_value = signal_value
self.pressed.connect(self.on_press)
......
......@@ -158,10 +158,10 @@ class MeasurementsBlockWidget(QtWidgets.QWidget):
widget.value_display.setFont(font)
return 0
@QtCore.Slot()
def update_value(self) -> int:
@QtCore.Slot(dict, dict)
def update_value(self, cycle: dict, readback: dict) -> int:
for widget in self.widget_list:
widget.update_value()
widget.update_value({"cycle": cycle, "readback": readback})
class MeasurementWidget(QtWidgets.QWidget):
......@@ -231,7 +231,7 @@ class MeasurementWidget(QtWidgets.QWidget):
layout.setSpacing(0)
self.setLayout(layout)
def update_value(self) -> int:
def update_value(self, db: dict) -> int:
"""
Poll the database in NativeUI and update the displayed value.
"""
......@@ -239,14 +239,12 @@ class MeasurementWidget(QtWidgets.QWidget):
self.value_display.setText("-")
return 0
data = self.NativeUI.get_db(self.keydir)
data = db[self.keydir]
if len(data) == 0: # means that the db hasn't been populated yet
self.value_display.setText("-")
return 0
self.value_display.setText(
self.__format_value(self.NativeUI.get_db(self.keydir)[self.key])
)
self.value_display.setText(self.__format_value(data[self.key]))
return 0
def __format_value(self, number):
......
......@@ -19,7 +19,7 @@ import logging
import numpy as np
import pyqtgraph as pg
from pyqtgraph import mkColor
from PySide2 import QtCore, QtWidgets
from PySide2 import QtCore, QtGui, QtWidgets
class TimePlotsWidget(QtWidgets.QWidget):
......@@ -96,17 +96,16 @@ class TimePlotsWidget(QtWidgets.QWidget):
self.setLayout(layout)
self.update_plot_data()
def plot(self, canvas, x, y, plotname, color):
pen = pg.mkPen(color=color, width=3)
return canvas.plot(x, y, name=plotname, pen=pen)
def update_plot_data(self):
@QtCore.Slot(dict)
def update_plot_data(self, plots: dict):
"""
Get the current plots database and update the plots to match
"""
plots = self.NativeUI.get_db("plots")
# plots = self.NativeUI.get_db("plots")
# Extend the non-time scales if we need to
self.pressure_plot.setYRange(*plots["pressure_axis_range"])
......@@ -209,22 +208,21 @@ class CirclePlotsWidget(QtWidgets.QWidget):
self.setLayout(layout)
self.timer = QtCore.QTimer()
self.timer.setInterval(16) # just faster than 60Hz
self.timer.timeout.connect(self.update_plot_data)
self.timer.start()
self.update_plot_data()
# self.timer = QtCore.QTimer()
# self.timer.setInterval(16) # just faster than 60Hz
# self.timer.timeout.connect(self.update_plot_data)
# self.timer.start()
def plot(self, canvas, x, y, plotname, color):
pen = pg.mkPen(color=color, width=3)
return canvas.plot(x, y, name=plotname, pen=pen)
def update_plot_data(self):
@QtCore.Slot(dict)
def update_plot_data(self, plots: dict):
"""
Get the current plots database and update the plots to match
"""
plots = self.NativeUI.get_db("plots")
# plots = self.NativeUI.get_db("plots")
# Extend the non-time scales if we need to
self.pressure_flow_plot.setXRange(*plots["flow_axis_range"])
......@@ -247,3 +245,84 @@ class CirclePlotsWidget(QtWidgets.QWidget):
plot.setXRange(self.time_range * (-1), 0, padding=0)
plot.enableAutoRange("y", True)
return 0
class ChartsPlotWidget(QtWidgets.QWidget):
def __init__(self, port=54322, *args, colors: dict = {}, **kwargs):
super().__init__(*args, **kwargs)
self.port = port
layout = QtWidgets.QHBoxLayout()
# Set up the graph widget
self.graph_widget = pg.GraphicsLayoutWidget()
layout.addWidget(self.graph_widget)
# Add the plot axes to the graph widget
self.display_plot = self.graph_widget.addPlot(
labels={"left": "????", "bottom": "????"}
)
self.graph_widget.nextRow()
# Store plots in a list in case we need to add additional axes in the future.
plots = [self.display_plot]
# Create lines
self.lines = {
"pressure": self.plot(
self.display_plot, [0, 10], [5, -5], "pressure", (0, 0, 0, 0)
),
"flow": self.plot(
self.display_plot,
[0, 2, 4, 6, 8, 10],
[3, 1, 4, 1, 5, 9],
"flow",
(0, 0, 0, 0),
),
}
# Store the colors of the lines
self.colors = {"pressure": colors["pressure_plot"], "flow": colors["flow_plot"]}
self.graph_widget.setContentsMargins(0.0, 0.0, 0.0, 0.0)
self.graph_widget.setBackground(colors["page_background"])
self.legends = []
for plot in plots:
plot.showGrid(x=True, y=True)
plot.hideButtons()
plot.setMouseEnabled(x=False, y=False)
self.legends.append(plot.addLegend(offset=(-1, 1)))
self.setLayout(layout)
self.hide_line("pressure")
self.show_line("pressure")
def setFont(self, font: QtGui.QFont) -> int:
for l in self.legends:
l.setFont(font)
return 0
def update_plot_data(self):
pass
def plot(self, canvas, x, y, plotname, color):
pen = pg.mkPen(color=color, width=3)
return canvas.plot(x, y, name=plotname, pen=pen)
@QtCore.Slot(str)
def show_line(self, key: str) -> int:
"""
Show the specified line
"""
self.lines[key].setPen(pg.mkPen(color=self.colors[key], width=3))
return 0
@QtCore.Slot(str)
def hide_line(self, key: str) -> int:
"""
Hide the specified line
"""
self.lines[key].setPen(pg.mkPen(color=(0, 0, 0, 0), width=0))
return 0
#!/usr/bin/env python3
"""
tab_charts.py
"""
__author__ = ["Benjamin Mummery", "Tiago Sarmento"]
__credits__ = ["Benjamin Mummery", "Dónal Murray", "Tim Powell", "Tiago Sarmento"]
__license__ = "GPL"
__version__ = "0.0.1"
__maintainer__ = "Tiago Sarmento"
__email__ = "tiago.sarmento@stfc.ac.uk"
__status__ = "Development"
from PySide2 import QtWidgets, QtGui, QtCore
import sys
class TabChart(
QtWidgets.QWidget
): # chose QWidget over QDialog family because easier to modify
def __init__(self, *args, **kwargs):
super(TabChart, self).__init__(*args, **kwargs)
label = QtWidgets.QLabel("charting")
vlayout = QtWidgets.QVBoxLayout()
vlayout.addWidget(label)
self.setLayout(vlayout)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment