Merge pull request #1488 from colobot/dev-refactor-fonts-cache
Refactor fonts reloadingfix-squashed-planets
commit
951db17d53
|
@ -38,31 +38,6 @@
|
|||
|
||||
namespace bp = boost::property_tree;
|
||||
|
||||
const std::map<Gfx::FontType, std::string> DEFAULT_FONT =
|
||||
{
|
||||
{ Gfx::FONT_COMMON, "dvu_sans.ttf" },
|
||||
{ Gfx::FONT_COMMON_BOLD, "dvu_sans_bold.ttf" },
|
||||
{ Gfx::FONT_COMMON_ITALIC, "dvu_sans_italic.ttf" },
|
||||
{ Gfx::FONT_STUDIO, "dvu_sans_mono.ttf" },
|
||||
{ Gfx::FONT_STUDIO_BOLD, "dvu_sans_mono_bold.ttf" },
|
||||
{ Gfx::FONT_STUDIO_ITALIC, "dvu_sans_mono.ttf" }, //placeholder for future use, DejaVu Sans Mono doesn't have italic variant
|
||||
{ Gfx::FONT_SATCOM, "dvu_sans.ttf" },
|
||||
{ Gfx::FONT_SATCOM_BOLD, "dvu_sans_bold.ttf" },
|
||||
{ Gfx::FONT_SATCOM_ITALIC, "dvu_sans_italic.ttf" },
|
||||
};
|
||||
|
||||
const std::map<Gfx::FontType, std::string> FONT_TYPE =
|
||||
{
|
||||
{ Gfx::FONT_COMMON, "FontCommon" },
|
||||
{ Gfx::FONT_COMMON_BOLD, "FontCommonBold" },
|
||||
{ Gfx::FONT_COMMON_ITALIC, "FontCommonItalic" },
|
||||
{ Gfx::FONT_STUDIO, "FontStudio" },
|
||||
{ Gfx::FONT_STUDIO_BOLD, "FontStudioBold" },
|
||||
{ Gfx::FONT_STUDIO_ITALIC, "FontStudioItalic" },
|
||||
{ Gfx::FONT_SATCOM, "FontSatCom" },
|
||||
{ Gfx::FONT_SATCOM_BOLD, "FontSatComBold" },
|
||||
{ Gfx::FONT_SATCOM_ITALIC, "FontSatComItalic" },
|
||||
};
|
||||
|
||||
CFontLoader::CFontLoader()
|
||||
{
|
||||
|
@ -99,17 +74,10 @@ bool CFontLoader::Init()
|
|||
return true;
|
||||
}
|
||||
|
||||
std::string CFontLoader::GetFont(Gfx::FontType type)
|
||||
std::optional<std::string> CFontLoader::GetFont(Gfx::FontType type) const
|
||||
{
|
||||
return std::string("/fonts/") + m_propertyTree.get<std::string>(GetFontType(type), GetDefaultFont(type));
|
||||
}
|
||||
|
||||
std::string CFontLoader::GetDefaultFont(Gfx::FontType type) const
|
||||
{
|
||||
return DEFAULT_FONT.at(type);
|
||||
}
|
||||
|
||||
std::string CFontLoader::GetFontType(Gfx::FontType type) const
|
||||
{
|
||||
return FONT_TYPE.at(type);
|
||||
auto font = m_propertyTree.get_optional<std::string>(ToString(type));
|
||||
if (font)
|
||||
return std::string("/fonts/") + *font;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
|
|
@ -31,6 +31,7 @@
|
|||
#include <boost/property_tree/ptree.hpp>
|
||||
|
||||
#include <string>
|
||||
#include <optional>
|
||||
|
||||
/**
|
||||
* \class CFontLoader
|
||||
|
@ -50,22 +51,10 @@ public:
|
|||
*/
|
||||
bool Init();
|
||||
|
||||
/** Reads given font from file
|
||||
* \return return path to font file
|
||||
/** Reads given font path from file
|
||||
* \return return path to font file if font type is configured
|
||||
*/
|
||||
std::string GetFont(Gfx::FontType type);
|
||||
|
||||
/** Const type method to read filenames of fonts from defaultFont map
|
||||
* used as a fallback if it wasn't possible to read font from fonts.ini
|
||||
* \return return filename of default path
|
||||
*/
|
||||
std::string GetDefaultFont(Gfx::FontType type) const;
|
||||
|
||||
/** Const type method converting Gfx::FontType to string
|
||||
* \return return id of font used in fonts.ini file
|
||||
*/
|
||||
|
||||
std::string GetFontType(Gfx::FontType type) const;
|
||||
std::optional<std::string> GetFont(Gfx::FontType type) const;
|
||||
|
||||
private:
|
||||
boost::property_tree::ptree m_propertyTree;
|
||||
|
|
|
@ -82,6 +82,24 @@ struct CachedFont
|
|||
font = TTF_OpenFontRW(this->fontFile->GetHandler(), 0, pointSize);
|
||||
}
|
||||
|
||||
CachedFont(CachedFont&& other) noexcept
|
||||
: fontFile{std::move(other.fontFile)},
|
||||
font{std::exchange(other.font, nullptr)},
|
||||
cache{std::move(other.cache)}
|
||||
{
|
||||
}
|
||||
|
||||
CachedFont& operator=(CachedFont&& other) noexcept
|
||||
{
|
||||
fontFile = std::move(other.fontFile);
|
||||
std::swap(font, other.font);
|
||||
cache = std::move(other.cache);
|
||||
return *this;
|
||||
}
|
||||
|
||||
CachedFont(const CachedFont& other) = delete;
|
||||
CachedFont& operator=(const CachedFont& other) = delete;
|
||||
|
||||
~CachedFont()
|
||||
{
|
||||
if (font != nullptr)
|
||||
|
@ -89,11 +107,38 @@ struct CachedFont
|
|||
}
|
||||
};
|
||||
|
||||
std::string ToString(FontType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case FontType::FONT_COMMON: return "FontCommon";
|
||||
case FontType::FONT_COMMON_BOLD: return "FontCommonBold";
|
||||
case FontType::FONT_COMMON_ITALIC: return "FontCommonItalic";
|
||||
case FontType::FONT_STUDIO: return "FontStudio";
|
||||
case FontType::FONT_STUDIO_BOLD: return "FontStudioBold";
|
||||
case FontType::FONT_STUDIO_ITALIC: return "FontStudioItalic";
|
||||
case FontType::FONT_SATCOM: return "FontSatCom";
|
||||
case FontType::FONT_SATCOM_BOLD: return "FontSatComBold";
|
||||
case FontType::FONT_SATCOM_ITALIC: return "FontSatComItalic";
|
||||
case FontType::FONT_BUTTON: return "FontButton";
|
||||
default: throw std::invalid_argument("Unsupported value for Gfx::FontType -> std::string conversion: " + std::to_string(type));
|
||||
}
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
const Math::IntPoint REFERENCE_SIZE(800, 600);
|
||||
const Math::IntPoint FONT_TEXTURE_SIZE(256, 256);
|
||||
|
||||
Gfx::FontType ToBoldFontType(Gfx::FontType type)
|
||||
{
|
||||
return static_cast<Gfx::FontType>(type | FONT_BOLD);
|
||||
}
|
||||
|
||||
Gfx::FontType ToItalicFontType(Gfx::FontType type)
|
||||
{
|
||||
return static_cast<Gfx::FontType>(type | FONT_ITALIC);
|
||||
}
|
||||
} // anonymous namespace
|
||||
|
||||
/// The QuadBatch is responsible for collecting as many quad (aka rectangle) draws as possible and
|
||||
|
@ -167,6 +212,166 @@ private:
|
|||
EngineRenderState m_renderState{};
|
||||
};
|
||||
|
||||
class FontsCache
|
||||
{
|
||||
public:
|
||||
using Fonts = std::map<FontType, std::unique_ptr<MultisizeFont>>;
|
||||
|
||||
FontsCache()
|
||||
{
|
||||
ClearLastCachedFont();
|
||||
}
|
||||
|
||||
bool Reload(const CFontLoader& fontLoader, int pointSize)
|
||||
{
|
||||
Flush();
|
||||
if (!PrepareCache(fontLoader)) { Flush(); return false; }
|
||||
if (!LoadDefaultFonts(fontLoader, pointSize)) { Flush(); return false; }
|
||||
return true;
|
||||
}
|
||||
|
||||
CachedFont* GetOrOpenFont(FontType type, int pointSize)
|
||||
{
|
||||
if (IsLastCachedFont(type, pointSize))
|
||||
return m_lastCachedFont;
|
||||
|
||||
auto multisizeFontIt = m_fonts.find(type);
|
||||
if (multisizeFontIt == m_fonts.end())
|
||||
{
|
||||
m_error = std::string("Font type not found in cache: ") + ToString(type);
|
||||
return nullptr;
|
||||
}
|
||||
MultisizeFont* multisizeFont = multisizeFontIt->second.get();
|
||||
|
||||
auto cachedFontIt = multisizeFont->fonts.find(pointSize);
|
||||
if (cachedFontIt != multisizeFont->fonts.end())
|
||||
{
|
||||
auto* cachedFont = cachedFontIt->second.get();
|
||||
SaveLastCachedFont(cachedFont, type, pointSize);
|
||||
return m_lastCachedFont;
|
||||
}
|
||||
|
||||
auto newFont = LoadFont(multisizeFont, pointSize);
|
||||
if (!newFont) return nullptr;
|
||||
|
||||
SaveLastCachedFont(newFont.get(), type, pointSize);
|
||||
multisizeFont->fonts[pointSize] = std::move(newFont);
|
||||
return m_lastCachedFont;
|
||||
}
|
||||
|
||||
void Flush()
|
||||
{
|
||||
Clear();
|
||||
ClearLastCachedFont();
|
||||
}
|
||||
|
||||
~FontsCache()
|
||||
{
|
||||
Flush();
|
||||
}
|
||||
|
||||
std::string GetError() const
|
||||
{
|
||||
return m_error;
|
||||
}
|
||||
|
||||
private:
|
||||
bool PrepareCache(const CFontLoader& fontLoader)
|
||||
{
|
||||
for (auto type : {FONT_COMMON, FONT_STUDIO, FONT_SATCOM})
|
||||
{
|
||||
if (!PrepareCacheForFontType(type, fontLoader)) return false;
|
||||
if (!PrepareCacheForFontType(ToBoldFontType(type), fontLoader)) return false;
|
||||
if (!PrepareCacheForFontType(ToItalicFontType(type), fontLoader)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool PrepareCacheForFontType(Gfx::FontType type, const CFontLoader& fontLoader)
|
||||
{
|
||||
if (auto font = fontLoader.GetFont(type))
|
||||
{
|
||||
m_fonts[type] = std::make_unique<MultisizeFont>(std::move(*font));
|
||||
return true;
|
||||
}
|
||||
m_error = "Error on loading fonts: font type " + ToString(type) + " is not configured";
|
||||
return false;
|
||||
}
|
||||
|
||||
bool LoadDefaultFonts(const CFontLoader& fontLoader, int pointSize)
|
||||
{
|
||||
for (auto& font : m_fonts)
|
||||
{
|
||||
auto type = font.first;
|
||||
auto* cachedFont = GetOrOpenFont(type, pointSize);
|
||||
if (cachedFont == nullptr || cachedFont->font == nullptr)
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
std::unique_ptr<CachedFont> LoadFont(MultisizeFont* multisizeFont, int pointSize)
|
||||
{
|
||||
auto file = CResourceManager::GetSDLMemoryHandler(multisizeFont->fileName);
|
||||
if (!file->IsOpen())
|
||||
{
|
||||
m_error = "Unable to open file '" + multisizeFont->fileName + "' (font size = " + std::to_string(pointSize) + ")";
|
||||
return nullptr;
|
||||
}
|
||||
GetLogger()->Debug("Loaded font file %s (font size = %d)\n", multisizeFont->fileName.c_str(), pointSize);
|
||||
auto newFont = std::make_unique<CachedFont>(std::move(file), pointSize);
|
||||
if (newFont->font == nullptr)
|
||||
{
|
||||
m_error = std::string("TTF_OpenFont error ") + std::string(TTF_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
return newFont;
|
||||
}
|
||||
|
||||
void SaveLastCachedFont(CachedFont* font, FontType type, int pointSize)
|
||||
{
|
||||
m_lastCachedFont = font;
|
||||
m_lastFontType = type;
|
||||
m_lastFontSize = pointSize;
|
||||
}
|
||||
|
||||
bool IsLastCachedFont(FontType font, int pointSize) const
|
||||
{
|
||||
return
|
||||
m_lastCachedFont != nullptr &&
|
||||
m_lastFontType == font &&
|
||||
m_lastFontSize == pointSize;
|
||||
}
|
||||
|
||||
void Clear()
|
||||
{
|
||||
for (auto& [fontType, multisizeFont] : m_fonts)
|
||||
{
|
||||
for (auto& cachedFont : multisizeFont->fonts)
|
||||
{
|
||||
cachedFont.second->cache.clear();
|
||||
}
|
||||
}
|
||||
m_fonts.clear();
|
||||
}
|
||||
|
||||
void ClearLastCachedFont()
|
||||
{
|
||||
m_lastCachedFont = nullptr;
|
||||
m_lastFontType = FONT_COMMON;
|
||||
m_lastFontSize = 0;
|
||||
}
|
||||
|
||||
private:
|
||||
Fonts m_fonts;
|
||||
|
||||
CachedFont* m_lastCachedFont;
|
||||
FontType m_lastFontType;
|
||||
int m_lastFontSize;
|
||||
|
||||
std::string m_error;
|
||||
};
|
||||
|
||||
|
||||
CText::CText(CEngine* engine)
|
||||
{
|
||||
|
@ -176,9 +381,7 @@ CText::CText(CEngine* engine)
|
|||
m_defaultSize = 12.0f;
|
||||
m_tabSize = 4;
|
||||
|
||||
m_lastFontType = FONT_COMMON;
|
||||
m_lastFontSize = 0;
|
||||
m_lastCachedFont = nullptr;
|
||||
m_fontsCache = std::make_unique<FontsCache>();
|
||||
|
||||
m_quadBatch = MakeUnique<CQuadBatch>(*engine);
|
||||
}
|
||||
|
@ -210,42 +413,24 @@ bool CText::ReloadFonts()
|
|||
CFontLoader fontLoader;
|
||||
if (!fontLoader.Init())
|
||||
{
|
||||
GetLogger()->Debug("Error on parsing fonts config file: failed to open file\n");
|
||||
m_error = "Error on parsing fonts config file: failed to open file";
|
||||
return false;
|
||||
}
|
||||
|
||||
// Backup previous fonts
|
||||
auto fonts = std::move(m_fonts);
|
||||
m_fonts.clear();
|
||||
|
||||
for (auto type : {FONT_COMMON, FONT_STUDIO, FONT_SATCOM})
|
||||
auto newCache = std::make_unique<FontsCache>();
|
||||
if (!newCache->Reload(fontLoader, GetFontPointSize(m_defaultSize)))
|
||||
{
|
||||
m_fonts[static_cast<Gfx::FontType>(type)] = MakeUnique<MultisizeFont>(fontLoader.GetFont(type));
|
||||
m_fonts[static_cast<Gfx::FontType>(type|FONT_BOLD)] = MakeUnique<MultisizeFont>(fontLoader.GetFont(static_cast<Gfx::FontType>(type|FONT_BOLD)));
|
||||
m_fonts[static_cast<Gfx::FontType>(type|FONT_ITALIC)] = MakeUnique<MultisizeFont>(fontLoader.GetFont(static_cast<Gfx::FontType>(type|FONT_ITALIC)));
|
||||
}
|
||||
|
||||
for (auto it = m_fonts.begin(); it != m_fonts.end(); ++it)
|
||||
{
|
||||
FontType type = (*it).first;
|
||||
CachedFont* cf = GetOrOpenFont(type, m_defaultSize);
|
||||
if (cf == nullptr || cf->font == nullptr)
|
||||
{
|
||||
m_fonts = std::move(fonts);
|
||||
return false;
|
||||
}
|
||||
m_error = newCache->GetError();
|
||||
return false;
|
||||
}
|
||||
|
||||
m_fontsCache = std::move(newCache);
|
||||
return true;
|
||||
}
|
||||
|
||||
void CText::Destroy()
|
||||
{
|
||||
m_fonts.clear();
|
||||
|
||||
m_lastCachedFont = nullptr;
|
||||
m_lastFontType = FONT_COMMON;
|
||||
m_lastFontSize = 0;
|
||||
|
||||
m_fontsCache->Flush();
|
||||
TTF_Quit();
|
||||
}
|
||||
|
||||
|
@ -269,17 +454,7 @@ void CText::FlushCache()
|
|||
}
|
||||
m_fontTextures.clear();
|
||||
|
||||
for (auto& multisizeFont : m_fonts)
|
||||
{
|
||||
for (auto& cachedFont : multisizeFont.second->fonts)
|
||||
{
|
||||
cachedFont.second->cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
m_lastCachedFont = nullptr;
|
||||
m_lastFontType = FONT_COMMON;
|
||||
m_lastFontSize = 0;
|
||||
m_fontsCache->Flush();
|
||||
}
|
||||
|
||||
int CText::GetTabSize()
|
||||
|
@ -1099,54 +1274,21 @@ void CText::DrawCharAndAdjustPos(UTF8Char ch, FontType font, float size, Math::I
|
|||
}
|
||||
}
|
||||
|
||||
CachedFont* CText::GetOrOpenFont(FontType font, float size)
|
||||
int CText::GetFontPointSize(float size) const
|
||||
{
|
||||
Math::IntPoint windowSize = m_engine->GetWindowSize();
|
||||
int pointSize = static_cast<int>(size * (windowSize.Length() / REFERENCE_SIZE.Length()));
|
||||
return static_cast<int>(size * (windowSize.Length() / REFERENCE_SIZE.Length()));
|
||||
}
|
||||
|
||||
if (m_lastCachedFont != nullptr &&
|
||||
m_lastFontType == font &&
|
||||
m_lastFontSize == pointSize)
|
||||
CachedFont* CText::GetOrOpenFont(FontType type, float size)
|
||||
{
|
||||
auto* cachedFont = m_fontsCache->GetOrOpenFont(type, GetFontPointSize(size));
|
||||
if (!cachedFont)
|
||||
{
|
||||
return m_lastCachedFont;
|
||||
}
|
||||
|
||||
auto it = m_fonts.find(font);
|
||||
if (it == m_fonts.end())
|
||||
{
|
||||
m_error = std::string("Invalid font type ") + StrUtils::ToString<int>(static_cast<int>(font));
|
||||
m_error = m_fontsCache->GetError();
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
MultisizeFont* mf = it->second.get();
|
||||
|
||||
auto jt = mf->fonts.find(pointSize);
|
||||
if (jt != mf->fonts.end())
|
||||
{
|
||||
m_lastCachedFont = jt->second.get();
|
||||
m_lastFontType = font;
|
||||
m_lastFontSize = pointSize;
|
||||
return m_lastCachedFont;
|
||||
}
|
||||
|
||||
auto file = CResourceManager::GetSDLMemoryHandler(mf->fileName);
|
||||
if (!file->IsOpen())
|
||||
{
|
||||
m_error = std::string("Unable to open file '") + mf->fileName + "' (font size = " + StrUtils::ToString<float>(size) + ")";
|
||||
return nullptr;
|
||||
}
|
||||
GetLogger()->Debug("Loaded font file %s (font size = %.1f)\n", mf->fileName.c_str(), size);
|
||||
|
||||
auto newFont = MakeUnique<CachedFont>(std::move(file), pointSize);
|
||||
if (newFont->font == nullptr)
|
||||
{
|
||||
m_error = std::string("TTF_OpenFont error ") + std::string(TTF_GetError());
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
m_lastCachedFont = newFont.get();
|
||||
mf->fonts[pointSize] = std::move(newFont);
|
||||
return m_lastCachedFont;
|
||||
return cachedFont;
|
||||
}
|
||||
|
||||
CharTexture CText::GetCharTexture(UTF8Char ch, FontType font, float size)
|
||||
|
|
|
@ -34,7 +34,6 @@
|
|||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
|
||||
// Graphics module namespace
|
||||
namespace Gfx
|
||||
{
|
||||
|
@ -69,38 +68,40 @@ typedef short FontMetaChar;
|
|||
*
|
||||
* Bitmask in lower 4 bits (mask 0x00f)
|
||||
*/
|
||||
enum FontType
|
||||
enum FontType : unsigned char
|
||||
{
|
||||
//! Flag for bold font subtype
|
||||
FONT_BOLD = 0x04,
|
||||
FONT_BOLD = 0b0000'01'00,
|
||||
//! Flag for italic font subtype
|
||||
FONT_ITALIC = 0x08,
|
||||
FONT_ITALIC = 0b0000'10'00,
|
||||
|
||||
//! Default colobot font used for interface
|
||||
FONT_COMMON = 0x00,
|
||||
FONT_COMMON = 0b0000'00'00,
|
||||
//! Alias for bold colobot font
|
||||
FONT_COMMON_BOLD = FONT_COMMON | FONT_BOLD,
|
||||
//! Alias for italic colobot font
|
||||
FONT_COMMON_ITALIC = FONT_COMMON | FONT_ITALIC,
|
||||
|
||||
//! Studio font used mainly in code editor
|
||||
FONT_STUDIO = 0x01,
|
||||
FONT_STUDIO = 0b0000'00'01,
|
||||
//! Alias for bold studio font
|
||||
FONT_STUDIO_BOLD = FONT_STUDIO | FONT_BOLD,
|
||||
//! Alias for italic studio font (at this point not used anywhere)
|
||||
FONT_STUDIO_ITALIC = FONT_STUDIO | FONT_ITALIC,
|
||||
|
||||
//! SatCom font used for interface (currently bold and italic wariants aren't used anywhere)
|
||||
FONT_SATCOM = 0x02,
|
||||
FONT_SATCOM = 0b0000'00'10,
|
||||
//! Alias for bold satcom font
|
||||
FONT_SATCOM_BOLD = FONT_SATCOM | FONT_BOLD,
|
||||
//! Alias for italic satcom font
|
||||
FONT_SATCOM_ITALIC = FONT_SATCOM | FONT_ITALIC,
|
||||
|
||||
//! Pseudo-font loaded from textures for buttons, icons, etc.
|
||||
FONT_BUTTON = 0x03,
|
||||
FONT_BUTTON = 0b0000'00'11,
|
||||
};
|
||||
|
||||
std::string ToString(FontType);
|
||||
|
||||
/**
|
||||
* \enum FontTitle
|
||||
* \brief Size of font title
|
||||
|
@ -204,6 +205,7 @@ struct CharTexture
|
|||
};
|
||||
|
||||
// Definition is private - in text.cpp
|
||||
class FontsCache;
|
||||
struct CachedFont;
|
||||
struct MultisizeFont;
|
||||
struct FontTexture;
|
||||
|
@ -323,7 +325,8 @@ public:
|
|||
Math::IntPoint GetFontTextureSize();
|
||||
|
||||
protected:
|
||||
CachedFont* GetOrOpenFont(FontType font, float size);
|
||||
int GetFontPointSize(float size) const;
|
||||
CachedFont* GetOrOpenFont(FontType type, float size);
|
||||
CharTexture CreateCharTexture(UTF8Char ch, CachedFont* font);
|
||||
FontTexture* GetOrCreateFontTexture(Math::IntPoint tileSize);
|
||||
FontTexture CreateFontTexture(Math::IntPoint tileSize);
|
||||
|
@ -349,13 +352,9 @@ protected:
|
|||
float m_defaultSize;
|
||||
int m_tabSize;
|
||||
|
||||
std::map<FontType, std::unique_ptr<MultisizeFont>> m_fonts;
|
||||
std::unique_ptr<FontsCache> m_fontsCache;
|
||||
std::vector<FontTexture> m_fontTextures;
|
||||
|
||||
FontType m_lastFontType;
|
||||
int m_lastFontSize;
|
||||
CachedFont* m_lastCachedFont;
|
||||
|
||||
class CQuadBatch;
|
||||
std::unique_ptr<CQuadBatch> m_quadBatch;
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue