mirror of
https://github.com/antopilo/Nuake.git
synced 2026-09-15 20:08:54 +03:00
Parsing css and css groups
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
|
||||
|
||||
.button {
|
||||
width: 10px;
|
||||
height: 100px;
|
||||
color: rgba(1, 1, 2, 1);
|
||||
}
|
||||
width: 25%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left {
|
||||
width: 100px;
|
||||
height: 150px;
|
||||
margin: 50px 100px;
|
||||
}
|
||||
|
||||
|
||||
31
Editor/resources/Interface/Testing.interface
Normal file
31
Editor/resources/Interface/Testing.interface
Normal file
@@ -0,0 +1,31 @@
|
||||
<Canvas
|
||||
script="Interface/Testing.lua"
|
||||
height="100%"
|
||||
width="100%"
|
||||
padding="10px 10px 10px 10px"
|
||||
color="0 0 128 255"
|
||||
groups="button"
|
||||
>
|
||||
<!-- PINK -->
|
||||
<Rect
|
||||
height="80%"
|
||||
width="80%"
|
||||
padding="10px 10px 10px 10px"
|
||||
color="255 0 255 255"
|
||||
|
||||
>
|
||||
<!-- BROWN -->
|
||||
<Rect
|
||||
groups="left"
|
||||
margin="0px 1%"
|
||||
color="111 77 22 255"
|
||||
>
|
||||
</Rect>
|
||||
<!-- BLUE -->
|
||||
<Rect
|
||||
width="49%"
|
||||
height="50%"
|
||||
color="15 66 128 255"
|
||||
></Rect>
|
||||
</Rect>
|
||||
</Canvas>
|
||||
5
Nuake/src/UI/Canvas.cpp
Normal file
5
Nuake/src/UI/Canvas.cpp
Normal file
@@ -0,0 +1,5 @@
|
||||
#include "Canvas.h"
|
||||
Canvas::Canvas()
|
||||
{
|
||||
|
||||
}
|
||||
3
Nuake/src/UI/InterfaceParser.cpp
Normal file
3
Nuake/src/UI/InterfaceParser.cpp
Normal file
@@ -0,0 +1,3 @@
|
||||
#include "InterfaceParser.h"
|
||||
|
||||
Ref<Canvas> InterfaceParser::Root = CreateRef<Canvas>();
|
||||
@@ -32,6 +32,8 @@ public:
|
||||
return Root;
|
||||
}
|
||||
|
||||
|
||||
|
||||
static void Iterate(const pugi::xml_node& xml_node, Ref<Node> node)
|
||||
{
|
||||
for (auto& e : xml_node.children())
|
||||
@@ -58,6 +60,13 @@ public:
|
||||
for (auto& a : xml_node.attributes())
|
||||
{
|
||||
std::string name = a.name();
|
||||
if (name == "groups")
|
||||
{
|
||||
for (auto& g : split(a.value(), ' '))
|
||||
{
|
||||
node->AddGroup(g);
|
||||
}
|
||||
}
|
||||
if (name == "height")
|
||||
node->Height = GetUnit(a.value());
|
||||
if (name == "width")
|
||||
@@ -72,7 +81,6 @@ public:
|
||||
node->Position = GetVec4Unit(a.value());
|
||||
if (name == "color")
|
||||
node->BackgroundColor = GetColor(a.value());
|
||||
|
||||
}
|
||||
|
||||
return node;
|
||||
@@ -200,6 +208,13 @@ public:
|
||||
for (auto& a : xml_node.attributes())
|
||||
{
|
||||
std::string name = a.name();
|
||||
if (name == "groups")
|
||||
{
|
||||
for (auto& g : split(a.value(), ' '))
|
||||
{
|
||||
node->AddGroup(g);
|
||||
}
|
||||
}
|
||||
if (name == "height")
|
||||
node->Height = GetUnit(a.value());
|
||||
if (name == "width")
|
||||
|
||||
@@ -1,8 +1,62 @@
|
||||
#include "Node.h"
|
||||
|
||||
void Node::ApplyStyle(Ref<StyleGroup> stylegroup)
|
||||
{
|
||||
for (auto& s : stylegroup->GetProps())
|
||||
{
|
||||
switch (s.first)
|
||||
{
|
||||
case PropType::HEIGHT:
|
||||
{
|
||||
Height.Unit = (Layout::Unit)(s.second.type);
|
||||
float value = s.second.value.Number;
|
||||
Height.Value = value;
|
||||
}
|
||||
break;
|
||||
case PropType::MIN_HEIGHT:
|
||||
MinHeight.Unit = (Layout::Unit)s.second.type;
|
||||
MinHeight.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MAX_HEIGHT:
|
||||
MaxHeight.Unit = (Layout::Unit)s.second.type;
|
||||
MaxHeight.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::WIDTH:
|
||||
Width.Unit = (Layout::Unit)s.second.type;
|
||||
Width.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MIN_WIDTH:
|
||||
MinWidth.Unit = (Layout::Unit)s.second.type;
|
||||
MinWidth.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MAX_WIDTH:
|
||||
MaxWidth.Unit = (Layout::Unit)s.second.type;
|
||||
MaxWidth.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MARGIN_RIGHT:
|
||||
Margin.Right.Unit = (Layout::Unit)s.second.type;
|
||||
Margin.Right.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MARGIN_LEFT:
|
||||
Margin.Left.Unit = (Layout::Unit)s.second.type;
|
||||
Margin.Left.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MARGIN_TOP:
|
||||
Margin.Top.Unit = (Layout::Unit)s.second.type;
|
||||
Margin.Top.Value = s.second.value.Number;
|
||||
break;
|
||||
case PropType::MARGIN_BOTTOM:
|
||||
Margin.Bottom.Unit = (Layout::Unit)s.second.type;
|
||||
Margin.Bottom.Value = s.second.value.Number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Node::Node()
|
||||
{
|
||||
Childrens = std::vector<Ref<Node>>();
|
||||
Groups = std::vector<std::string>();
|
||||
|
||||
PositionType = Layout::PositionType::RELATIVE;
|
||||
Position = Layout::LayoutVec4();
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
#include "yoga/Yoga.h"
|
||||
#include "../Rendering/Renderer2D.h"
|
||||
#include "../Core/Logger.h"
|
||||
#include "Style.h"
|
||||
|
||||
namespace Layout
|
||||
{
|
||||
enum class LayoutDirection
|
||||
@@ -70,6 +72,10 @@ public:
|
||||
YGNodeRef YogaNode;
|
||||
std::vector<Ref<Node>> Childrens = std::vector<Ref<Node>>();
|
||||
|
||||
void ApplyStyle(Ref<StyleGroup> stylegroup);
|
||||
Ref<Style> style;
|
||||
std::vector<std::string> Groups;
|
||||
|
||||
Layout::PositionType PositionType;
|
||||
Layout::LayoutVec4 Position;
|
||||
|
||||
@@ -97,6 +103,20 @@ public:
|
||||
|
||||
Node();
|
||||
|
||||
void AddGroup(const std::string& group)
|
||||
{
|
||||
Groups.push_back(group);
|
||||
}
|
||||
|
||||
bool IsInGroup(const std::string& group)
|
||||
{
|
||||
for (auto& g : Groups)
|
||||
if (g == group) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<std::string> GetGroups() const { return Groups; }
|
||||
|
||||
void SetMargin()
|
||||
{
|
||||
switch (Margin.Left.Unit)
|
||||
|
||||
84
Nuake/src/UI/Style.h
Normal file
84
Nuake/src/UI/Style.h
Normal file
@@ -0,0 +1,84 @@
|
||||
#pragma once
|
||||
#include "../Core/Maths.h"
|
||||
#include <map>
|
||||
enum class PropType
|
||||
{
|
||||
HEIGHT, MAX_HEIGHT, MIN_HEIGHT, WIDTH, MAX_WIDTH, MIN_WIDTH,
|
||||
LAYOUT_DIRECTION, JUSTIFY_CONTENT,
|
||||
FLEX_BASIS, FLEX_GROW, FLEX_SHRINK,FLEX_WRAP, FLEX_DIRECTION,
|
||||
ASPECT_RATIO,
|
||||
ALIGN_ITEMS, SELF_ALIGN, ALIGN_CONTENT,
|
||||
POSITION,
|
||||
LEFT, RIGHT, TOP, BOTTOM,
|
||||
MARGIN, MARGIN_LEFT, MARGIN_RIGHT, MARGIN_TOP, MARGIN_BOTTOM,
|
||||
PADDING, PADDING_LEFT, PADDING_RIGHT, PADDING_TOP, PADDING_BOTTOM,
|
||||
BORDER, BORDER_LEFT, BORDER_RIGHT, BORDER_TOP, BORDER_BOTTOM,
|
||||
BORDER_RADIUS, BORDER_RADIUS_LEFT, BORDER_RADIUS_RIGHT, BORDER_RADIUS_TOP, BORDER_RADIUS_BOTTOM,
|
||||
BACKGROUND_COLOR, COLOR,
|
||||
};
|
||||
|
||||
enum class PropValueType
|
||||
{
|
||||
PIXEL, PERCENT, AUTO, COLOR, ENUM
|
||||
};
|
||||
union Value
|
||||
{
|
||||
float Number;
|
||||
Color Color;
|
||||
int Enum;
|
||||
};
|
||||
struct PropValue
|
||||
{
|
||||
PropValueType type;
|
||||
Value value;
|
||||
};
|
||||
|
||||
class StyleGroup
|
||||
{
|
||||
public:
|
||||
std::map<PropType, PropValue> Props;
|
||||
|
||||
StyleGroup()
|
||||
{
|
||||
this->Props = std::map<PropType, PropValue>();
|
||||
}
|
||||
|
||||
void SetProp(PropType type, PropValue prop)
|
||||
{
|
||||
Props[type] = prop;
|
||||
}
|
||||
|
||||
PropValue& GetProp(PropType type)
|
||||
{
|
||||
if (Props.find(type) != Props.end())
|
||||
return Props[type];
|
||||
|
||||
return PropValue();
|
||||
}
|
||||
|
||||
bool HasProp(PropType type)
|
||||
{
|
||||
return Props.find(type) != Props.end();
|
||||
}
|
||||
|
||||
std::map<PropType, PropValue> GetProps()
|
||||
{
|
||||
return Props;
|
||||
}
|
||||
|
||||
StyleGroup operator+(StyleGroup other)
|
||||
{
|
||||
for (auto p : other.Props)
|
||||
{
|
||||
this->Props[p.first] = p.second;
|
||||
}
|
||||
|
||||
return *this;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
class Style
|
||||
{
|
||||
|
||||
};
|
||||
53
Nuake/src/UI/StyleSheetParser.cpp
Normal file
53
Nuake/src/UI/StyleSheetParser.cpp
Normal file
@@ -0,0 +1,53 @@
|
||||
#include "StyleSheetParser.h"
|
||||
#include <regex>
|
||||
#include <src/Vendors/pugixml/pugixml.hpp>
|
||||
|
||||
PropValue StyleSheetParser::ParsePropType(const std::string& str, PropType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case PropType::HEIGHT: case PropType::MIN_HEIGHT: case PropType::MAX_HEIGHT:
|
||||
case PropType::WIDTH: case PropType::MIN_WIDTH: case PropType::MAX_WIDTH:
|
||||
case PropType::LEFT: case PropType::RIGHT: case PropType::TOP: case PropType::BOTTOM:
|
||||
case PropType::MARGIN_LEFT: case PropType::MARGIN_RIGHT: case PropType::MARGIN_TOP: case PropType::MARGIN_BOTTOM:
|
||||
case PropType::PADDING_LEFT: case PropType::PADDING_RIGHT: case PropType::PADDING_TOP: case PropType::PADDING_BOTTOM:
|
||||
case PropType::BORDER_LEFT: case PropType::BORDER_RIGHT: case PropType::BORDER_TOP: case PropType::BORDER_BOTTOM:
|
||||
{
|
||||
std::regex only_number("[0-9]+");
|
||||
std::regex not_number("[^0-9]+");
|
||||
// is percentage
|
||||
std::smatch match_value;
|
||||
if (std::regex_search(str.begin(), str.end(), match_value, only_number))
|
||||
{
|
||||
std::smatch match_type;
|
||||
if (std::regex_search(str.begin(), str.end(), match_type, not_number))
|
||||
{
|
||||
float value = std::stof(match_value[0]);
|
||||
if (match_type[0] == "%")
|
||||
{
|
||||
PropValue returnValue = PropValue{
|
||||
PropValueType::PERCENT,
|
||||
{0}
|
||||
};
|
||||
returnValue.value.Number = value;
|
||||
return returnValue;
|
||||
}
|
||||
else if (match_type[0] == "px")
|
||||
{
|
||||
PropValue returnValue = PropValue{
|
||||
PropValueType::PIXEL,
|
||||
{0}
|
||||
};
|
||||
returnValue.value.Number = value;
|
||||
return returnValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
}
|
||||
|
||||
|
||||
return PropValue();
|
||||
}
|
||||
10
Nuake/src/UI/StyleSheetParser.h
Normal file
10
Nuake/src/UI/StyleSheetParser.h
Normal file
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
#include "Style.h"
|
||||
|
||||
class StyleSheetParser
|
||||
{
|
||||
|
||||
public:
|
||||
static PropValue ParsePropType(const std::string& str, PropType type);
|
||||
};
|
||||
@@ -4,9 +4,12 @@ namespace UI
|
||||
{
|
||||
|
||||
|
||||
|
||||
Ref<StyleSheet> StyleSheet::New(const std::string& path)
|
||||
{
|
||||
return CreateRef<StyleSheet>(path);
|
||||
|
||||
|
||||
return CreateRef<StyleSheet>(path);
|
||||
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,48 @@
|
||||
#pragma once
|
||||
#include <string>
|
||||
|
||||
#include "katana-parser/katana.h"
|
||||
|
||||
#include "../Core/FileSystem.h"
|
||||
#include "../Core/Logger.h"
|
||||
#include "../Core/Core.h"
|
||||
#include "Style.h"
|
||||
|
||||
#include "katana-parser/katana.h"
|
||||
#include <string>
|
||||
#include <map>
|
||||
#include <src/UI/StyleSheetParser.h>
|
||||
#include <regex>
|
||||
#include <regex>
|
||||
#include <src/UI/InterfaceParser.h>
|
||||
#include <src/UI/InterfaceParser.h>
|
||||
|
||||
namespace UI
|
||||
{
|
||||
class StyleSheet
|
||||
{
|
||||
private:
|
||||
KatanaOutput* Data;
|
||||
|
||||
std::map<std::string, Ref<StyleGroup>> Styles;
|
||||
public:
|
||||
std::string Path;
|
||||
static Ref<StyleSheet> New(const std::string& path);
|
||||
|
||||
void AddStyleGroup(std::string selector, Ref<StyleGroup> group)
|
||||
{
|
||||
Styles[selector] = group;
|
||||
}
|
||||
|
||||
bool HasStyleGroup(const std::string& group)
|
||||
{
|
||||
return Styles.find(group) != Styles.end();
|
||||
}
|
||||
|
||||
Ref<StyleGroup> GetStyleGroup(const std::string group)
|
||||
{
|
||||
if (!HasStyleGroup(group))
|
||||
return nullptr;
|
||||
|
||||
return Styles[group];
|
||||
}
|
||||
|
||||
StyleSheet(const std::string& path)
|
||||
{
|
||||
@@ -22,29 +50,96 @@ namespace UI
|
||||
ParseSheet();
|
||||
}
|
||||
|
||||
static Ref<StyleSheet> New(const std::string& path);
|
||||
|
||||
~StyleSheet()
|
||||
{
|
||||
katana_destroy_output(Data);
|
||||
}
|
||||
|
||||
bool ParseRule(KatanaRule* rule)
|
||||
{
|
||||
if (rule->type == KatanaRuleStyle)
|
||||
{
|
||||
Ref<StyleGroup> styleGroup = CreateRef<StyleGroup>();
|
||||
|
||||
// Selectors
|
||||
KatanaStyleRule* stylerule = (KatanaStyleRule*)rule;
|
||||
for (int j = 0; j < stylerule->selectors->length; j++)
|
||||
{
|
||||
KatanaSelector* selector = (KatanaSelector*)stylerule->selectors->data[j];
|
||||
std::string name = selector->data->value;
|
||||
|
||||
AddStyleGroup(name, styleGroup);
|
||||
}
|
||||
|
||||
// Declarations
|
||||
for (int k = 0; k < stylerule->declarations->length; k++)
|
||||
{
|
||||
KatanaDeclaration* declaration = (KatanaDeclaration*)(stylerule->declarations->data[k]);
|
||||
std::string value = declaration->raw;
|
||||
std::string name = declaration->property;
|
||||
PropType type;
|
||||
|
||||
if (name == "height") type = PropType::HEIGHT;
|
||||
if (name == "width")type = PropType::WIDTH;
|
||||
|
||||
if (name == "margin")
|
||||
{
|
||||
std::regex regex("[0-9]+[^ ]+");
|
||||
std::smatch match_value;
|
||||
Layout::LayoutVec4 result;
|
||||
|
||||
std::vector<std::string> splits = InterfaceParser::split(value, ' ');
|
||||
int idx = 0;
|
||||
for (auto& s : splits)
|
||||
{
|
||||
if (idx == 0)
|
||||
{
|
||||
type = PropType::MARGIN_LEFT;
|
||||
styleGroup->SetProp(type, StyleSheetParser::ParsePropType(s, type));
|
||||
}
|
||||
else if (idx == 1)
|
||||
{
|
||||
type = PropType::MARGIN_RIGHT;
|
||||
styleGroup->SetProp(type, StyleSheetParser::ParsePropType(s, type));
|
||||
}
|
||||
else if (idx == 2)
|
||||
{
|
||||
type = PropType::MARGIN_TOP;
|
||||
styleGroup->SetProp(type, StyleSheetParser::ParsePropType(s, type));
|
||||
}
|
||||
else if (idx == 3)
|
||||
{
|
||||
type = PropType::MARGIN_BOTTOM;
|
||||
styleGroup->SetProp(type, StyleSheetParser::ParsePropType(s, type));
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
styleGroup->SetProp(type, StyleSheetParser::ParsePropType(value, type));
|
||||
Logger::Log(declaration->property);
|
||||
}
|
||||
Logger::Log(rule->name);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool ParseSheet()
|
||||
{
|
||||
std::string content = FileSystem::ReadFile(this->Path);
|
||||
|
||||
Data = katana_parse(content.c_str(), content.length(), KatanaParserModeStylesheet);
|
||||
|
||||
for (int i = 0; i < Data->stylesheet->rules.length; i++)
|
||||
{
|
||||
KatanaRule* rule = (KatanaRule*)Data->stylesheet->rules.data[i];
|
||||
ParseRule(rule);
|
||||
}
|
||||
|
||||
if (Data->errors.length > 0)
|
||||
{
|
||||
KatanaArray errors = Data->errors;
|
||||
return false;
|
||||
}
|
||||
//Data->stylesheet->rules
|
||||
katana_dump_output(Data);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
};
|
||||
}
|
||||
@@ -10,8 +10,10 @@ namespace UI
|
||||
{
|
||||
m_Name = name;
|
||||
|
||||
Root = InterfaceParser::Parse("resources/Interface/Testing.interface");
|
||||
m_Stylesheet = StyleSheet::New("/Interface\\Testing.css");
|
||||
|
||||
Root = InterfaceParser::Parse("resources/Interface/Testing.interface");
|
||||
|
||||
if (!Root)
|
||||
{
|
||||
Logger::Log("Failed to generate interface structure");
|
||||
@@ -31,6 +33,7 @@ namespace UI
|
||||
|
||||
void UserInterface::Reload()
|
||||
{
|
||||
m_Stylesheet = StyleSheet::New("/Interface\\Testing.css");
|
||||
Root = InterfaceParser::Parse("resources/Interface/Testing.interface");
|
||||
if (!Root)
|
||||
{
|
||||
@@ -57,6 +60,10 @@ namespace UI
|
||||
{
|
||||
yoga_root = YGNodeNewWithConfig(yoga_config);
|
||||
Root->YogaNode = yoga_root;
|
||||
|
||||
for (auto& g : Root->GetGroups())
|
||||
if (m_Stylesheet->HasStyleGroup(g))
|
||||
Root->ApplyStyle(m_Stylesheet->GetStyleGroup(g));
|
||||
Root->SetYogaLayout();
|
||||
CreateYogaLayoutRecursive(Root, yoga_root);
|
||||
}
|
||||
@@ -71,6 +78,11 @@ namespace UI
|
||||
{
|
||||
YGNodeRef newYogaNode = YGNodeNew();
|
||||
n->YogaNode = newYogaNode;
|
||||
|
||||
for (auto& g : n->GetGroups())
|
||||
if (m_Stylesheet->HasStyleGroup(g))
|
||||
n->ApplyStyle(m_Stylesheet->GetStyleGroup(g));
|
||||
|
||||
n->SetYogaLayout();
|
||||
YGNodeInsertChild(yoga_node, newYogaNode, index);
|
||||
CreateYogaLayoutRecursive(n, newYogaNode);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "Node.h"
|
||||
#include <src/UI/Canvas.h>
|
||||
#include "../Core/Maths.h"
|
||||
#include "Stylesheet.h"
|
||||
namespace UI
|
||||
{
|
||||
class UserInterface
|
||||
@@ -14,6 +15,7 @@ namespace UI
|
||||
Ref<FrameBuffer> m_Framebuffer; // Texture of the interface.
|
||||
std::string m_Name;
|
||||
Ref<Canvas> Root;
|
||||
Ref<StyleSheet> m_Stylesheet;
|
||||
YGConfigRef yoga_config;
|
||||
YGNodeRef yoga_root;
|
||||
public:
|
||||
|
||||
28
docs/Index.md
Normal file
28
docs/Index.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Nuake engine
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# UI system
|
||||
The UI system uses yoga layout and custom styling properties. The styling properties will mimick the standard css properties but will be rendererd using a shader. Since the layout system is based on yoga, the actual writting of the layout will look a lot like html, the differences will lie in the actual components.
|
||||
|
||||
# The UI pipeline maybe
|
||||
|
||||
1. load xml file containing pseudo html with components
|
||||
Potential nodes
|
||||
- Canvas basically a container for UI.
|
||||
- Button with basic state and event
|
||||
- Texture which display a engine texture(can use framebuffer!!)
|
||||
- Slider(ouch)
|
||||
|
||||
2. Create an in engien structure of the layout
|
||||
|
||||
3. Create a yoga layotu from node and calculate when changes occurs
|
||||
|
||||
4. Draw each node in the structure using styling loaded somewhere idk.
|
||||
|
||||
|
||||
# Styling
|
||||
TODO MAN
|
||||
|
||||
17
docs/Interface.md
Normal file
17
docs/Interface.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Interface design
|
||||
Interface component
|
||||
Layout
|
||||
Styling
|
||||
Behaviour
|
||||
|
||||
## Layout
|
||||
The layout is represented using XML
|
||||
|
||||
## Components
|
||||
The components all derives from Rect
|
||||
|
||||
## Styling
|
||||
The styling could be done a file that looks like css
|
||||
|
||||
## Behaviour
|
||||
The behaviour needs to be done using the scripting API
|
||||
Reference in New Issue
Block a user