Reimplemented all obsolete modifier via AxisFilter and ButtonFilter
This commit is contained in:
parent
7d9b61d9c6
commit
66776ed81a
9 changed files with 210 additions and 113 deletions
|
@ -1315,7 +1315,7 @@ pos = (1.0f - (1.0f - pos) ** t) ** (1 / t);]]></programlisting>
|
|||
</varlistentry>
|
||||
|
||||
<varlistentry>
|
||||
<term><option>--autofire BUTTON=FREQUENCY</option></term>
|
||||
<term><option>--autofire BUTTON=FREQUENCY,...</option></term>
|
||||
<listitem>
|
||||
<para>Autofire mapping allows you to let a button automatically fire with a
|
||||
given frequency in miliseconds:</para>
|
||||
|
|
|
@ -26,6 +26,7 @@
|
|||
#include <boost/lexical_cast.hpp>
|
||||
#include <boost/tokenizer.hpp>
|
||||
#include <boost/format.hpp>
|
||||
#include <boost/tokenizer.hpp>
|
||||
|
||||
#include "arg_parser.hpp"
|
||||
#include "button_filter.hpp"
|
||||
|
@ -776,7 +777,8 @@ CommandLineParser::print_version() const
|
|||
<< "Copyright © 2008-2010 Ingo Ruhnke <grumbel@gmx.de>\n"
|
||||
<< "Licensed under GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>\n"
|
||||
<< "This program comes with ABSOLUTELY NO WARRANTY.\n"
|
||||
<< "This is free software, and you are welcome to redistribute it under certain conditions; see the file COPYING for details.\n";
|
||||
<< "This is free software, and you are welcome to redistribute it under certain\n"
|
||||
<< "conditions; see the file COPYING for details.\n";
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -788,13 +790,13 @@ CommandLineParser::set_modifier(const std::string& name, const std::string& valu
|
|||
void
|
||||
CommandLineParser::set_axismap(const std::string& name, const std::string& value)
|
||||
{
|
||||
m_options->controller.modifier.push_back(ModifierPtr(AxismapModifier::from_string(name, value)));
|
||||
m_options->controller.axismap->add(AxisMapping::from_string(name, value));
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_buttonmap(const std::string& name, const std::string& value)
|
||||
{
|
||||
m_options->controller.modifier.push_back(ModifierPtr(ButtonmapModifier::from_string(name, value)));
|
||||
m_options->controller.buttonmap->add(ButtonMapping::from_string(name, value));
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -826,37 +828,77 @@ CommandLineParser::set_evdev_keymap(const std::string& name, const std::string&
|
|||
void
|
||||
CommandLineParser::set_relative_axis(const std::string& name, const std::string& value)
|
||||
{
|
||||
//FIXME:m_options->controller.modifier.push_back(ModifierPtr(RelativeAxisModifier::from_string(name, value)));
|
||||
m_options->controller.axismap->add_filter(string2axis(name),
|
||||
AxisFilterPtr(new RelativeAxisFilter(boost::lexical_cast<int>(value))));
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_autofire(const std::string& name, const std::string& value)
|
||||
{
|
||||
//FIXME: m_options->controller.modifier.push_back(ModifierPtr(AutofireModifier::from_string(name, value)));
|
||||
m_options->controller.buttonmap->add_filter(string2btn(name),
|
||||
ButtonFilterPtr(new AutofireButtonFilter(boost::lexical_cast<int>(value), 0)));
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_calibration(const std::string& name, const std::string& value)
|
||||
{
|
||||
//FIXME: m_options->controller.modifier.push_back(ModifierPtr(CalibrationModifier::from_string(name, value)));
|
||||
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
|
||||
tokenizer tokens(value, boost::char_separator<char>(":", "", boost::keep_empty_tokens));
|
||||
std::vector<std::string> args(tokens.begin(), tokens.end());
|
||||
|
||||
if (args.size() != 3)
|
||||
{
|
||||
throw std::runtime_error("calibration requires MIN:CENTER:MAX as argument");
|
||||
}
|
||||
else
|
||||
{
|
||||
m_options->controller.axismap->add_filter(string2axis(name),
|
||||
AxisFilterPtr(new CalibrationAxisFilter(boost::lexical_cast<int>(args[0]),
|
||||
boost::lexical_cast<int>(args[1]),
|
||||
boost::lexical_cast<int>(args[2]))));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_axis_sensitivity(const std::string& name, const std::string& value)
|
||||
{
|
||||
//FIXME: m_options->controller.modifier.push_back(ModifierPtr(AxisSensitivityModifier::from_string(name, value)));
|
||||
m_options->controller.axismap->add_filter(string2axis(name),
|
||||
AxisFilterPtr(new SensitivityAxisFilter(boost::lexical_cast<float>(value))));
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_deadzone(const std::string& value)
|
||||
{
|
||||
//FIXME: m_options->controller.modifier.push_back(ModifierPtr(new DeadzoneModifier(to_number(32767, value), 0)));
|
||||
int deadzone = boost::lexical_cast<int>(value);
|
||||
XboxAxis axes[] = { XBOX_AXIS_X1,
|
||||
XBOX_AXIS_Y1,
|
||||
|
||||
XBOX_AXIS_X2,
|
||||
XBOX_AXIS_Y2 };
|
||||
|
||||
for(size_t i = 0; i < sizeof(axes)/sizeof(XboxAxis); ++i)
|
||||
{
|
||||
m_options->controller.axismap->add_filter(axes[i],
|
||||
AxisFilterPtr(new DeadzoneAxisFilter(-deadzone,
|
||||
deadzone,
|
||||
true)));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
CommandLineParser::set_deadzone_trigger(const std::string& value)
|
||||
{
|
||||
//FIXME: m_options->controller.modifier.push_back(ModifierPtr(new DeadzoneModifier(0, to_number(255, value))));
|
||||
int deadzone_trigger = boost::lexical_cast<int>(value);
|
||||
XboxAxis axes[] = { XBOX_AXIS_LT,
|
||||
XBOX_AXIS_RT };
|
||||
|
||||
for(size_t i = 0; i < sizeof(axes)/sizeof(XboxAxis); ++i)
|
||||
{
|
||||
m_options->controller.axismap->add_filter(axes[i],
|
||||
AxisFilterPtr(new DeadzoneAxisFilter(-deadzone_trigger,
|
||||
deadzone_trigger,
|
||||
true)));
|
||||
}
|
||||
}
|
||||
|
||||
void
|
||||
|
@ -881,25 +923,6 @@ CommandLineParser::set_dpad_rotation(const std::string& value)
|
|||
m_options->controller.modifier.push_back(ModifierPtr(DpadRotationModifier::from_string(args)));
|
||||
}
|
||||
|
||||
/*
|
||||
void set_deadzone(int value);
|
||||
void set_deadzone_trigger(int value);
|
||||
void set_square_axis();
|
||||
void set_four_way_restrictor();
|
||||
|
||||
if (opts.controller.deadzone != 0 || opts.controller.deadzone_trigger != 0)
|
||||
modifier.push_back(ModifierPtr(new DeadzoneModifier(opts.controller.deadzone, opts.controller.deadzone_trigger)));
|
||||
|
||||
if (opts.controller.square_axis)
|
||||
modifier.push_back(ModifierPtr(new SquareAxisModifier()));
|
||||
|
||||
if (opts.controller.four_way_restrictor)
|
||||
modifier.push_back(ModifierPtr(new FourWayRestrictorModifier()));
|
||||
|
||||
if (opts.controller.dpad_rotation)
|
||||
modifier.push_back(ModifierPtr(new DpadRotationModifier(opts.controller.dpad_rotation)));
|
||||
*/
|
||||
|
||||
void
|
||||
CommandLineParser::read_config_file(Options* opts, const std::string& filename)
|
||||
{
|
||||
|
|
|
@ -27,19 +27,19 @@ inline float to_float(int value, int min, int max)
|
|||
return static_cast<float>(value - min) / static_cast<float>(max - min) * 2.0f - 1.0f;
|
||||
}
|
||||
|
||||
AxismapModifier*
|
||||
AxismapModifier::from_string(const std::string& lhs_, const std::string& rhs)
|
||||
AxisMapping
|
||||
AxisMapping::from_string(const std::string& lhs_, const std::string& rhs)
|
||||
{
|
||||
std::string lhs = lhs_;
|
||||
std::auto_ptr<AxismapModifier> mapping(new AxismapModifier);
|
||||
AxisMapping mapping;
|
||||
|
||||
mapping->m_invert = false;
|
||||
mapping->m_lhs = XBOX_AXIS_UNKNOWN;
|
||||
mapping->m_rhs = XBOX_AXIS_UNKNOWN;
|
||||
mapping.invert = false;
|
||||
mapping.lhs = XBOX_AXIS_UNKNOWN;
|
||||
mapping.rhs = XBOX_AXIS_UNKNOWN;
|
||||
|
||||
if (lhs[0] == '-')
|
||||
{
|
||||
mapping->m_invert = true;
|
||||
mapping.invert = true;
|
||||
lhs = lhs.substr(1);
|
||||
}
|
||||
|
||||
|
@ -50,71 +50,95 @@ AxismapModifier::from_string(const std::string& lhs_, const std::string& rhs)
|
|||
{
|
||||
switch(idx)
|
||||
{
|
||||
case 0: mapping->m_lhs = string2axis(*t); break;
|
||||
default: mapping->m_filters.push_back(AxisFilter::from_string(*t));
|
||||
case 0: mapping.lhs = string2axis(*t); break;
|
||||
default: mapping.filters.push_back(AxisFilter::from_string(*t));
|
||||
}
|
||||
}
|
||||
|
||||
if (rhs.empty())
|
||||
{
|
||||
mapping->m_rhs = mapping->m_lhs;
|
||||
mapping.rhs = mapping.lhs;
|
||||
}
|
||||
else
|
||||
{
|
||||
mapping->m_rhs = string2axis(rhs);
|
||||
mapping.rhs = string2axis(rhs);
|
||||
}
|
||||
|
||||
if (mapping->m_lhs == XBOX_AXIS_UNKNOWN ||
|
||||
mapping->m_rhs == XBOX_AXIS_UNKNOWN)
|
||||
if (mapping.lhs == XBOX_AXIS_UNKNOWN ||
|
||||
mapping.rhs == XBOX_AXIS_UNKNOWN)
|
||||
{
|
||||
throw std::runtime_error("Couldn't convert string \"" + lhs + "=" + rhs + "\" to axis mapping");
|
||||
}
|
||||
|
||||
return mapping.release();
|
||||
return mapping;
|
||||
}
|
||||
|
||||
AxismapModifier::AxismapModifier() :
|
||||
m_lhs(XBOX_AXIS_UNKNOWN),
|
||||
m_rhs(XBOX_AXIS_UNKNOWN),
|
||||
m_invert(false),
|
||||
m_filters()
|
||||
m_axismap()
|
||||
{
|
||||
}
|
||||
|
||||
void
|
||||
AxismapModifier::update(int msec_delta, XboxGenericMsg& msg)
|
||||
{
|
||||
// update all filters in all mappings
|
||||
for(std::vector<AxisFilterPtr>::iterator j = m_filters.begin(); j != m_filters.end(); ++j)
|
||||
{
|
||||
(*j)->update(msec_delta);
|
||||
}
|
||||
|
||||
XboxGenericMsg newmsg = msg;
|
||||
|
||||
// update all filters in all mappings
|
||||
for(std::vector<AxisMapping>::iterator i = m_axismap.begin(); i != m_axismap.end(); ++i)
|
||||
{
|
||||
for(std::vector<AxisFilterPtr>::iterator j = i->filters.begin(); j != i->filters.end(); ++j)
|
||||
{
|
||||
(*j)->update(msec_delta);
|
||||
}
|
||||
}
|
||||
|
||||
// clear all values in the new msg
|
||||
set_axis_float(newmsg, m_lhs, 0);
|
||||
|
||||
int min = get_axis_min(m_lhs);
|
||||
int max = get_axis_max(m_lhs);
|
||||
int value = get_axis(msg, m_lhs);
|
||||
|
||||
for(std::vector<AxisFilterPtr>::iterator j = m_filters.begin(); j != m_filters.end(); ++j)
|
||||
for(std::vector<AxisMapping>::iterator i = m_axismap.begin(); i != m_axismap.end(); ++i)
|
||||
{
|
||||
value = (*j)->filter(value, min, max);
|
||||
set_axis_float(newmsg, i->lhs, 0);
|
||||
}
|
||||
|
||||
float lhs = to_float(value, min, max);
|
||||
float nrhs = get_axis_float(newmsg, m_rhs);
|
||||
|
||||
if (m_invert)
|
||||
for(std::vector<AxisMapping>::iterator i = m_axismap.begin(); i != m_axismap.end(); ++i)
|
||||
{
|
||||
lhs = -lhs;
|
||||
int min = get_axis_min(i->lhs);
|
||||
int max = get_axis_max(i->lhs);
|
||||
int value = get_axis(msg, i->lhs);
|
||||
|
||||
for(std::vector<AxisFilterPtr>::iterator j = i->filters.begin(); j != i->filters.end(); ++j)
|
||||
{
|
||||
value = (*j)->filter(value, min, max);
|
||||
}
|
||||
|
||||
float lhs = to_float(value, min, max);
|
||||
float nrhs = get_axis_float(newmsg, i->rhs);
|
||||
|
||||
if (i->invert)
|
||||
{
|
||||
lhs = -lhs;
|
||||
}
|
||||
|
||||
set_axis_float(newmsg, i->rhs, std::max(std::min(nrhs + lhs, 1.0f), -1.0f));
|
||||
}
|
||||
msg = newmsg;
|
||||
}
|
||||
|
||||
set_axis_float(newmsg, m_rhs, std::max(std::min(nrhs + lhs, 1.0f), -1.0f));
|
||||
void
|
||||
AxismapModifier::add(const AxisMapping& mapping)
|
||||
{
|
||||
m_axismap.push_back(mapping);
|
||||
}
|
||||
|
||||
msg = newmsg;
|
||||
void
|
||||
AxismapModifier::add_filter(XboxAxis axis, AxisFilterPtr filter)
|
||||
{
|
||||
for(std::vector<AxisMapping>::iterator i = m_axismap.begin(); i != m_axismap.end(); ++i)
|
||||
{
|
||||
if (i->lhs == axis)
|
||||
{
|
||||
i->filters.push_back(filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* EOF */
|
||||
|
|
|
@ -23,11 +23,17 @@
|
|||
|
||||
#include "modifier.hpp"
|
||||
|
||||
struct AxisMapping {
|
||||
static AxisMapping from_string(const std::string& lhs, const std::string& rhs);
|
||||
|
||||
XboxAxis lhs;
|
||||
XboxAxis rhs;
|
||||
bool invert;
|
||||
std::vector<AxisFilterPtr> filters;
|
||||
};
|
||||
|
||||
class AxismapModifier : public Modifier
|
||||
{
|
||||
public:
|
||||
static AxismapModifier* from_string(const std::string& lhs, const std::string& rhs);
|
||||
|
||||
public:
|
||||
AxismapModifier();
|
||||
|
||||
|
@ -35,11 +41,11 @@ public:
|
|||
|
||||
Modifier::Priority get_priority() const { return Modifier::kAxismapPriority; };
|
||||
|
||||
void add(const AxisMapping& mapping);
|
||||
void add_filter(XboxAxis axis, AxisFilterPtr filter);
|
||||
|
||||
public:
|
||||
XboxAxis m_lhs;
|
||||
XboxAxis m_rhs;
|
||||
bool m_invert;
|
||||
std::vector<AxisFilterPtr> m_filters;
|
||||
std::vector<AxisMapping> m_axismap;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
@ -20,10 +20,13 @@
|
|||
|
||||
#include <boost/tokenizer.hpp>
|
||||
|
||||
ButtonmapModifier*
|
||||
ButtonmapModifier::from_string(const std::string& lhs, const std::string& rhs)
|
||||
ButtonMapping
|
||||
ButtonMapping::from_string(const std::string& lhs, const std::string& rhs)
|
||||
{
|
||||
std::auto_ptr<ButtonmapModifier> mapping(new ButtonmapModifier(XBOX_BTN_UNKNOWN, XBOX_BTN_UNKNOWN));
|
||||
ButtonMapping mapping;
|
||||
|
||||
mapping.lhs = XBOX_BTN_UNKNOWN;
|
||||
mapping.rhs = XBOX_BTN_UNKNOWN;
|
||||
|
||||
typedef boost::tokenizer<boost::char_separator<char> > tokenizer;
|
||||
tokenizer tokens(lhs, boost::char_separator<char>("^", "", boost::keep_empty_tokens));
|
||||
|
@ -32,57 +35,83 @@ ButtonmapModifier::from_string(const std::string& lhs, const std::string& rhs)
|
|||
{
|
||||
switch(idx)
|
||||
{
|
||||
case 0: mapping->m_lhs = string2btn(*t); break;
|
||||
default: mapping->m_filters.push_back(ButtonFilter::from_string(*t));
|
||||
case 0: mapping.lhs = string2btn(*t); break;
|
||||
default: mapping.filters.push_back(ButtonFilter::from_string(*t));
|
||||
}
|
||||
}
|
||||
|
||||
if (rhs.empty())
|
||||
{
|
||||
mapping->m_rhs = mapping->m_lhs;
|
||||
mapping.rhs = mapping.lhs;
|
||||
}
|
||||
else
|
||||
{
|
||||
mapping->m_rhs = string2btn(rhs);
|
||||
mapping.rhs = string2btn(rhs);
|
||||
}
|
||||
|
||||
return mapping.release();
|
||||
return mapping;
|
||||
}
|
||||
|
||||
ButtonmapModifier::ButtonmapModifier(XboxButton lhs,
|
||||
XboxButton rhs) :
|
||||
m_lhs(lhs),
|
||||
m_rhs(rhs)
|
||||
ButtonmapModifier::ButtonmapModifier() :
|
||||
m_buttonmap()
|
||||
{
|
||||
}
|
||||
|
||||
void
|
||||
ButtonmapModifier::update(int msec_delta, XboxGenericMsg& msg)
|
||||
{
|
||||
// update all filters in all mappings
|
||||
for(std::vector<ButtonFilterPtr>::iterator i = m_filters.begin(); i != m_filters.end(); ++i)
|
||||
{
|
||||
(*i)->update(msec_delta);
|
||||
}
|
||||
|
||||
XboxGenericMsg newmsg = msg;
|
||||
|
||||
// set all buttons to 0
|
||||
set_button(newmsg, m_lhs, 0);
|
||||
|
||||
bool value = get_button(msg, m_lhs);
|
||||
|
||||
// apply the button filter
|
||||
for(std::vector<ButtonFilterPtr>::iterator j = m_filters.begin(); j != m_filters.end(); ++j)
|
||||
// update all filters in all mappings
|
||||
for(std::vector<ButtonMapping>::iterator i = m_buttonmap.begin(); i != m_buttonmap.end(); ++i)
|
||||
{
|
||||
value = (*j)->filter(value);
|
||||
for(std::vector<ButtonFilterPtr>::iterator j = i->filters.begin(); j != i->filters.end(); ++j)
|
||||
{
|
||||
(*j)->update(msec_delta);
|
||||
}
|
||||
}
|
||||
|
||||
// Take both lhs and rhs into account to allow multiple buttons
|
||||
// mapping to the same button
|
||||
set_button(newmsg, m_rhs, value || get_button(newmsg, m_rhs));
|
||||
// set all buttons to 0
|
||||
for(std::vector<ButtonMapping>::iterator i = m_buttonmap.begin(); i != m_buttonmap.end(); ++i)
|
||||
{
|
||||
set_button(newmsg, i->lhs, 0);
|
||||
}
|
||||
|
||||
msg = newmsg;
|
||||
for(std::vector<ButtonMapping>::iterator i = m_buttonmap.begin(); i != m_buttonmap.end(); ++i)
|
||||
{
|
||||
// Take both lhs and rhs into account to allow multiple buttons
|
||||
// mapping to the same button
|
||||
bool value = get_button(msg, i->lhs);
|
||||
|
||||
// apply the button filter
|
||||
for(std::vector<ButtonFilterPtr>::iterator j = i->filters.begin(); j != i->filters.end(); ++j)
|
||||
{
|
||||
value = (*j)->filter(value);
|
||||
}
|
||||
|
||||
set_button(newmsg, i->rhs, value || get_button(newmsg, i->rhs));
|
||||
}
|
||||
|
||||
msg = newmsg;
|
||||
}
|
||||
|
||||
void
|
||||
ButtonmapModifier::add(const ButtonMapping& mapping)
|
||||
{
|
||||
m_buttonmap.push_back(mapping);
|
||||
}
|
||||
|
||||
void
|
||||
ButtonmapModifier::add_filter(XboxButton btn, ButtonFilterPtr filter)
|
||||
{
|
||||
for(std::vector<ButtonMapping>::iterator i = m_buttonmap.begin(); i != m_buttonmap.end(); ++i)
|
||||
{
|
||||
if (i->lhs == btn)
|
||||
{
|
||||
i->filters.push_back(filter);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* EOF */
|
||||
|
|
|
@ -21,23 +21,28 @@
|
|||
|
||||
#include "modifier.hpp"
|
||||
|
||||
struct ButtonMapping {
|
||||
static ButtonMapping from_string(const std::string& lhs, const std::string& rhs);
|
||||
|
||||
XboxButton lhs;
|
||||
XboxButton rhs;
|
||||
std::vector<ButtonFilterPtr> filters;
|
||||
};
|
||||
|
||||
class ButtonmapModifier : public Modifier
|
||||
{
|
||||
public:
|
||||
static ButtonmapModifier* from_string(const std::string& lhs, const std::string& rhs);
|
||||
|
||||
public:
|
||||
ButtonmapModifier(XboxButton lhs,
|
||||
XboxButton rhs);
|
||||
ButtonmapModifier();
|
||||
|
||||
void update(int msec_delta, XboxGenericMsg& msg);
|
||||
|
||||
Modifier::Priority get_priority() const { return Modifier::kButtonMapPriority; };
|
||||
|
||||
void add(const ButtonMapping& mapping);
|
||||
void add_filter(XboxButton btn, ButtonFilterPtr filter);
|
||||
|
||||
public:
|
||||
XboxButton m_lhs;
|
||||
XboxButton m_rhs;
|
||||
std::vector<ButtonFilterPtr> m_filters;
|
||||
std::vector<ButtonMapping> m_buttonmap;
|
||||
};
|
||||
|
||||
#endif
|
||||
|
|
|
@ -22,6 +22,8 @@ Options* g_options;
|
|||
|
||||
ControllerOptions::ControllerOptions() :
|
||||
uinput(),
|
||||
buttonmap(new ButtonmapModifier),
|
||||
axismap(new AxismapModifier),
|
||||
modifier()
|
||||
{
|
||||
}
|
||||
|
|
|
@ -37,6 +37,10 @@ public:
|
|||
ControllerOptions();
|
||||
|
||||
uInputCfg uinput;
|
||||
|
||||
boost::shared_ptr<ButtonmapModifier> buttonmap;
|
||||
boost::shared_ptr<AxismapModifier> axismap;
|
||||
|
||||
std::vector<ModifierPtr> modifier;
|
||||
};
|
||||
|
||||
|
|
|
@ -97,6 +97,10 @@ XboxdrvThread::controller_loop(GamepadType type, uInput* uinput, const Options&
|
|||
modifier.insert(modifier.end(), opts.controller.modifier.begin(), opts.controller.modifier.end());
|
||||
std::stable_sort(modifier.begin(), modifier.end(), SortModifierByPriority());
|
||||
|
||||
// axismap, buttonmap comes last, as otherwise they would mess up the button and axis names
|
||||
modifier.push_back(opts.controller.buttonmap);
|
||||
modifier.push_back(opts.controller.axismap);
|
||||
|
||||
// how long to wait for a controller event before taking care of autofire etc.
|
||||
timeout = 25; // FIXME: add an option for that
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue