C---Windows-编程-全-
C++ Windows 编程(全)
原文:
zh.annas-archive.org/md5/3a59df25bb403d0135e7ac53b1b1336c
译者:飞龙
前言
由于对各个领域产生了巨大影响,应用开发获得了巨大的流行度。在这个蓬勃发展的市场中,拥有正确的一套工具对于使开发者能够构建实用、用户友好且高效的应用程序至关重要。本书专注于 Small Windows 的使用和实现,这是一个简化交互式 Windows 应用程序开发的 C++面向对象类库。
本书涵盖的内容
第一章, 简介,介绍了 Small Windows,这是一个封装了 Win32 API 一部分的类库。
第二章, 你好,小世界!,首先构建了一个(非常)小的应用程序——Small Windows 版本的著名 Hello World 程序。然后,我们将继续构建一个(仍然相当小)的应用程序,该程序在窗口中处理圆形。用户可以添加和移动圆形,更改它们的颜色,并保存和加载圆形。
第三章, 构建俄罗斯方块应用,探索了经典俄罗斯方块游戏的版本。七种不同的图形正在屏幕上落下,用户的任务是移动或旋转它们,以便尽可能多的行能够完全填满并移除。
第四章, 处理形状和图形,教你如何构建一个绘图程序,这可以被视为圆形应用的更高级版本。你可以创建和删除图形,以及标记和拖动图形。
第五章, 图形层次结构,继续构建绘图程序。我们可以定义一个包含线条、箭头、矩形和椭圆的类层次结构。
第六章, 构建文字处理器,描述了一个能够格式化单个字符的文字处理器。
第七章, 键盘输入和字符计算,讨论了文字处理器如何处理许多键盘输入组合,并计算每个单独字符的大小和位置。
第八章, 构建电子表格应用,讨论了最终的应用程序,这是一个能够使用算术四则运算规则计算公式的电子表格程序。还可以剪切和粘贴单元格块。
第九章, 公式解释,解释了当用户输入一个公式时,我们需要对其进行解释。这个过程分为扫描和解析,我们将在本章中探讨。
第十章, 框架,描述了 Small Windows 的最核心部分。本章开始 Small Windows 的描述。Application 类处理应用程序的消息循环和 Windows 类的注册。Window 类处理基本的窗口功能。
第十一章, 文档,讨论了基于文档的窗口子类,即提供基本文档功能(如菜单和快捷键)的 Document 类和提供基于文档框架的 Standard Document 框架。
第十二章, 辅助类,探讨了处理点、大小、矩形、颜色和字体、动态列表和树结构的一组小型辅助类。
第十三章, 注册表、剪贴板、标准对话框和打印预览,解释了注册表和剪贴板的实现,用于保存和加载文件的常规对话框,选择颜色或字体,或打印文档的实现。本章还解释了用于打印预览的类的实现。
第十四章, 对话框、控件和打印设置,描述了使用按钮、复选框、单选按钮、列表框、组合框和文本字段等控件设计自定义对话框的可能性。文本字段的输入可以被转换为任何类型。最后,打印设置对话框是一个带有适当控件的定制对话框。
您需要这本书的内容
首先,您需要在您的计算机上下载 Visual Studio。我建议您下载并安装桌面 Express 版本,它是免费的,可以在www.visualstudio.com/en-us/products/visual-studio-express-vs.aspx
找到。
然后,有两种安装 Small Windows 的方法:
-
如果您想按照这本书的章节结构进行操作,您可以从
github.com/PacktPublishing/Cpp-Windows-Programming
下载。它由一组包含本书应用程序的 Visual Studio 项目组成。 -
如果您想要在一个 Visual Studio 解决方案中拥有所有代码,您可以从 Cpp Windows Programming 文件中下载 C++ Windows Programming 解决方案。
-
如果您想自己用 Small Windows 编写代码,您可以从 Empty Project 文件中下载 Empty 项目。它是一个只包含 Small Windows 源代码和非常简单的应用程序的应用程序。您可以更改项目的名称并添加您自己的特定于应用程序的代码。
这本书面向的对象
本书是为希望以头等舱方式进入 Windows 编程的应用程序开发者而编写的。它将教会您如何使用 C++开发面向对象的类库和在 Windows 中增强的应用程序。假设您具备 C++和面向对象框架的基本知识,以便充分利用本书。
规范
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“一个小型 Windows 应用程序的第一部分是MainWindow
函数。”
代码块设置如下:
void MainWindow(vector<String>argumentList,
SmallWindows::WindowShow windowShow);
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“例如,通常,文件菜单中的打开项被注释为文本Ctrl+O。”
注意
警告或重要注意事项如下所示。
小贴士
小贴士和技巧看起来像这样。
读者反馈
我们读者的反馈总是受欢迎的。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发您真正能从中获得最大收益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书的标题。如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com
的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
使用您的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持选项卡上。
-
点击代码下载与勘误表。
-
在搜索框中输入书的名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买本书的地方。
-
点击代码下载。
下载文件后,请确保您使用最新版本的以下软件解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Cpp-Windows-Programming
。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/
找到。查看它们吧!
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/CppWindowsProgramming_ColorImages.pdf
下载此文件。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误清单部分。
要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support
,并在搜索字段中输入本书的名称。所需信息将出现在错误清单部分。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您本书的任何方面有问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章。简介
本书的目的在于学习如何在 Windows 中开发应用程序。为了做到这一点,我开发了 Small Windows,这是一个用于 Windows 图形应用程序的 C++ 面向对象类库。
想法是通过介绍使用 Small Windows 编写的越来越高级的应用程序来引导你进入 Windows 编程,从而隐藏 Windows 32 位应用程序编程接口(Win32 API)的技术细节,这是 Windows 开发的基础库。采用这种方法,我们可以专注于业务逻辑,而不必与底层的技术细节纠缠。如果你对了解 Win32 API 的工作原理感兴趣,本书的第二部分详细描述了 Small Windows 的实现方式。
这本书由两部分组成,第一部分描述了使用 Small Windows 开发的应用程序。虽然有些书包含许多示例,但本书只包括六个示例,其中最后四个相对较复杂:俄罗斯方块游戏、绘图程序、文字处理程序和电子表格程序。请注意,本书不仅是一本关于 Windows 编程的教程,也是一本关于如何开发面向对象的图形应用程序的教程。
第二部分详细描述了 Small Windows 在 Win32 API 中的实现。请注意,Win32 API 直到第二部分才介绍。有些人可能对 Small Windows 的高级特性感到满意,只想研究特定应用程序的问题,而其他人可能想阅读第二部分,以了解 Small Windows 的类、方法和宏如何在 Win32 API 中实现。
自然,我意识到现代面向对象的类库也存在于 Windows 中。然而,那些库的目的是通过隐藏架构的细节来简化开发者的工作,这也阻止了开发者充分利用 Windows 架构。尽管 Win32 API 已经存在了一段时间,但我认为它是开发专业 Windows 应用程序和了解 Windows 架构的最佳方式。
本书提供了所有源代码;它也可以作为 Visual Studio 解决方案提供。
库
本节介绍了 Small Windows。Small Windows 应用程序的第一部分是 MainWindow
函数。它对应于常规 C++ 中的 main
函数。其任务是设置应用程序的名称并创建应用程序的主窗口。
在本书中,我们讨论了 定义 和 声明。声明只是对编译器的通知,而定义则是定义特性的内容。下面是 MainWindow
函数的声明。其定义留给 Small Windows 的用户。
void MainWindow(vector<String>argumentList,
SmallWindows::WindowShow windowShow);
简而言之,在 Windows 中,应用程序不会采取任何主动行动;相反,它等待消息,并在收到它们时做出反应。非正式地说,你不是调用 Windows,而是 Windows 调用你。
小窗口的核心部分是 Application
类。在 Windows 中,每个事件都会生成一个消息,该消息发送到当前具有输入焦点的窗口。Application
类实现了 RunMessageLoop
方法,确保每个消息都发送到正确的窗口。当发送特殊退出消息时,它还会关闭应用程序。
创建窗口分为两个步骤。在第一步中,RegisterWindowClasses
方法设置诸如样式、颜色和外观等特性。请注意,Windows 类不是 C++ 类:
class Application {
public:
static int RunMessageLoop();
static void RegisterWindowClasses(HINSTANCE instanceHandle);
};
下一步是创建一个单独的窗口,这是通过 Window
类完成的。所有 virtual
方法都是空的,并旨在由以下子类覆盖:
class Window {
public:
窗口可以是可见的或不可见的,可以是启用的或禁用的。当窗口被启用时,它接受鼠标、触摸和键盘输入:
void ShowWindow(bool visible);
void EnableWindow(bool enable);
当窗口移动或调整大小时,会调用 OnMove
和 OnSize
方法。当用户按下 F1 键或消息框中的 帮助 按钮时,会调用 OnHelp
方法:
virtual void OnMove(Point topLeft);
virtual void OnSize(Size windowSize);
virtual void OnHelp();
客户端区域是窗口中可以绘制的部分。非正式地说,客户端区域是窗口减去其框架。客户端区域的内容可以缩放。默认缩放因子是 1.0:
double GetZoom() const;
void SetZoom(double zoom);
计时器可以设置为毫秒间隔。每隔一段时间就会调用 OnTimer
方法。只要它们有不同的身份号码,就可以设置多个计时器:
void SetTimer(int timerId, unsigned int interval);
void DropTimer(int timerId);
virtual void OnTimer(int timerId);
当用户按下、释放或双击鼠标按钮时,会调用 OnMouseDown
、OnMouseUp
和 OnDoubleClick
方法。当用户至少按下鼠标按钮移动鼠标时,会调用 OnMouseMove
方法。当用户滚动鼠标滚轮时,会调用 OnMouseWheel
方法:
virtual void OnMouseDown(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed);
virtual void OnMouseUp(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed);
virtual void OnDoubleClick(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed);
virtual void OnMouseMove(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed);
virtual void OnMouseWheel(WheelDirection direction,
bool shiftPressed, bool controlPressed);
OnTouchDown
、OnTouchMove
和 OnTouchDown
方法的工作方式与鼠标方法相同。然而,由于用户可以同时触摸多个点,因此方法接受点的列表而不是单个点:
virtual void OnTouchDown(vector<Point> pointList);
virtual void OnTouchMove(vector<Point> pointList);
virtual void OnTouchUp(vector<Point> pointList);
当用户按下或释放一个键时,会调用 OnKeyDown
和 OnKeyUp
方法。如果用户按下图形键(ASCII 值在 32 到 127 之间的键),则在之间调用 OnChar
方法:
virtual bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
virtual void OnChar(TCHAR tChar);
virtual bool OnKeyUp(WORD key, bool shiftPressed,
bool controlPressed);
Invalidate
方法标记客户端区域的一部分(或整个客户端区域)需要重绘;该区域变为无效。如果 clear
为 true
,则在绘制之前清除该区域。UpdateWindow
方法强制重绘无效区域。它最终会调用 OnPaint
方法:
void Invalidate(Rect areaRect, bool clear = true) const;
void Invalidate(bool clear = true) const;
void UpdateWindow();
当客户端区域需要重新绘制时,会调用OnPaint
方法,当它被发送到打印机时,会调用OnPrint
方法。它们的默认行为是调用OnDraw
方法,其中drawMode
参数的值为Paint
或Print
:
virtual void OnPaint(Graphics& graphics) const;
virtual void OnPrint(Graphics& graphics, int page,
int copy, int totalPages) const;
virtual void OnDraw(Graphics& graphics, DrawMode drawMode)
const;
如果TryClose
返回true
,则OnClose
方法会关闭窗口。当窗口正在关闭时,会调用OnDestroy
方法:
virtual void OnClose();
virtual bool TryClose();
virtual void OnDestroy();
以下方法检查并修改窗口的大小和位置。请注意,我们无法直接设置客户端区域的大小;它只能通过调整窗口大小间接设置:
Size GetWindowSize() const;
void SetWindowSize(Size windowSize);
Point GetWindowPosition() const;
void SetWindowPosition(Point topLeft);
Size GetClientSize() const;
在本书中的文字处理程序和电子表格程序中,我们处理文本并需要计算单个字符的大小。以下方法使用给定的字体计算字符的宽度。它们还计算字体的行高、上升和平均字符宽度:
int GetCharacterWidth(Font font, TCHAR tChar) const;
int GetCharacterHeight(Font font) const;
int GetCharacterAscent(Font font) const;
int GetCharacterAverageWidth(Font font) const;
上升线分隔字母的上部和下部,如下所示:
最后,MessageBox
方法在窗口中显示一个简单的消息框:
Answer MessageBox(String message,
String caption = TEXT("Error"),
ButtonGroup buttonGroup = Ok,
Icon icon = NoIcon, bool help = false) const;
};
Window
类还使用负责在窗口中绘制文本和几何对象的Graphics
类。Graphics
对象的引用被发送到Window
类中的OnPaint
、OnPrint
和OnDraw
方法。它可以用来绘制线条、矩形和椭圆以及写入文本:
class Graphics {
public:
void DrawLine(Point startPoint, Point endPoint,
Color penColor, PenStyle penStyle = Solid);
void DrawRectangle(Rect rect, Color penColor,
PenStyle = Solid);
void FillRectangle(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle=Solid);
void DrawEllipse(Rect rect, Color penColor,
PenStyle = Solid);
void FillEllipse(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle=Solid);
void DrawText(Rect areaRect, String text, Font font,
Color textColor, Color backColor,
bool pointsToMeters = true);
};
Document
类通过一些功能扩展了Window
类,这些功能对基于文档的应用程序来说是通用的。滚动滑块会自动设置为反映文档的可视部分。鼠标滚轮每次点击都会移动垂直滚动条一行的高度。行高由构造函数设置。它的代码片段如下所示:
class Document : public Window {
public:
当用户在文档中进行了更改且需要保存时,脏标志会被设置为true
。在Document
中,脏标志是手动设置的,但在下面的StandardDocument
子类中,它是由框架处理的:
bool IsDirty() const;
void SetDirty(bool dirty);
光标是闪烁的标记,指示用户应在何处输入下一个字符。键盘可以通过(使用插入键)设置为插入或覆盖模式。在插入模式下,光标通常是一个细长的垂直条,在覆盖模式下是一个宽度为平均字符宽度的块。
光标可以被设置或清除。例如,在文字处理程序中,当用户写入文本时,光标是可见的,当用户标记文本时,光标是不可见的。当窗口获得焦点时,如果之前已经设置了光标,则光标变为可见。当窗口失去焦点时,无论之前是否已设置,光标都变为不可见:
void SetCaret(Rect caretRect);
void ClearCaret();
void OnGainFocus();
void OnLoseFocus();
文档可能包含一个菜单栏,该菜单栏是通过SetMenuBar
方法设置的:
void SetMenuBar(Menu& menuBar);
当用户在窗口中拖放一个或多个文件时,会调用OnDropFiles
方法。它们的路径存储在路径列表中:
virtual void OnDropFile(vector<String> pathList);
可以将文档的键盘模式设置为插入或覆盖,如下所示:
KeyboardMode GetKeyboardMode() const;
void SetKeyboardMode(KeyboardMode mode);
当用户通过点击滚动条箭头或滚动条字段,或拖动滚动滑块来滚动条时,会调用OnHorizontalScroll
和OnVerticalScroll
方法。相应的代码片段如下所示:
virtual void OnHorizontalScroll(WORD flags,WORD thumbPos=0);
virtual void OnVerticalScroll(WORD flags, WORD thumbPos =0);
存在大量用于检查或更改滚动条设置的方法。行或页的大小由构造函数设置:
void SetHorizontalScrollPosition(int scrollPos);
int GetHorizontalScrollPosition() const;
void SetVerticalScrollPosition(int scrollPos);
int GetVerticalScrollPosition() const;
void SetHorizontalScrollLineWidth(int lineWidth);
int GetHorizontalScrollLineHeight() const;
void SetVerticalScrollLineHeight(int lineHeight);
int GetVerticalScrollLineHeight() const;
void SetHorizontalScrollPageWidth(int pageWidth);
int GetHorizontalScrollPageWidth() const;
void SetVerticalScrollPageHeight(int pageHeight);
int GetVerticalScrollPageHeight() const;
void SetHorizontalScrollTotalWidth(int scrollWidth);
int GetHorizontalScrollTotalWidth() const;
void SetVerticalScrollTotalHeight(int scrollHeight);
int GetVerticalScrollTotalHeight() const;
};
Menu
类处理文档中的菜单栏、菜单、菜单项或菜单项分隔符(水平条)。当用户选择菜单项时,会调用selection
监听器。当项目即将可见时,会调用(除非它们为 null)enable
、check
和radio
监听器。如果它们返回true
,则项目被启用或带有复选框或单选按钮的标注:
class Menu {
public:
void AddMenu(Menu& menu);
void AddSeparator();
void AddItem(String text, VoidListener selection,
BoolListener enable = nullptr,
BoolListener check = nullptr,
BoolListener radio = nullptr);
};
加速器是一个快捷命令。例如,通常文件菜单中的打开项被标注为文本Ctrl+O。这意味着您可以同时按下Ctrl键和O键来获得相同的结果,就像选择了打开菜单项一样。在这两种情况下,都会显示打开对话框。
Accelerator
类只包含TextToAccelerator
方法。它解释菜单项文本,并将如果存在则添加加速器到加速器集合中:
class Accelerator {
public:
static void TextToAccelerator(String& text, int idemId,
list<ACCEL>& acceleratorSet);
};
StandardDocument
类扩展了Document
类,并设置了一个框架,该框架负责处理所有传统任务,例如加载和保存,以及剪切、复制和粘贴,在基于文档的应用程序中:
class StandardDocument : public Document {
public:
StandardDocument
类配备了常见的文件、编辑和帮助菜单。文件菜单可以可选地(如果print
参数为true
)配备打印和打印预览的菜单项:
Menu StandardFileMenu(bool print);
Menu StandardEditMenu();
Menu StandardHelpMenu();
当用户选择新建菜单项时,会调用ClearDocument
方法;其任务是清除文档。当用户选择保存或另存为菜单项时,会调用WriteDocumentToStream
方法;当用户选择打开菜单项时,会调用ReadDocumentFromStream
方法:
virtual void ClearDocument();
virtual bool WriteDocumentToStream(String name,
ostream& outStream)const;
virtual bool ReadDocumentFromStream(String name,
istream& inStream);
当用户选择剪切或复制菜单项并且相应的ready
方法返回true
时,会调用CopyAscii
、CopyUnicode
和CopyGeneric
方法。相应的代码片段如下所示:
virtual void CopyAscii(vector<String>& textList) const;
virtual bool IsCopyAsciiReady() const;
virtual void CopyUnicode(vector<String>& textList) const;
virtual bool IsCopyUnicodeReady() const;
virtual void CopyGeneric(int format, InfoList& infoList)
const;
virtual bool IsCopyGenericReady(int format) const;
同样,当用户选择粘贴菜单项并且相应的ready
方法返回true
时,会调用PasteAscii
、PasteUnicode
和PasteGeneric
方法:
virtual void PasteAscii(const vector<String>& textList);
virtual bool IsPasteAsciiReady
(const vector<String>& textList) const;
virtual void PasteUnicode(const vector<String>& textList);
virtual bool IsPasteUnicodeReady
(const vector<String>& textList) const;
virtual void PasteGeneric(int format, InfoList& infoList);
virtual bool IsPasteGenericReady(int format,
InfoList& infoList) const;
OnDropFile
方法检查路径列表,如果恰好有一个文件具有应用程序(由构造函数设置)的文档类型后缀,则接受拖放操作:
void OnDropFile(vector<String> pathList);
};
在小窗口中,我们不关心像素大小。相反,我们使用逻辑单位,这些单位保持不变,无论屏幕的物理分辨率如何。我们可以从以下三个坐标系中选择:
-
LogicalWithScroll
:逻辑单位是毫米的一百分之一,同时考虑当前滚动条设置。绘图程序和文字处理程序使用这个系统。 -
LogicalWithoutScroll
:在这种情况下,逻辑单位也是毫米的一百分之一,但当前滚动条设置被忽略。电子表格程序使用这个系统。 -
PreviewCoordinate
:当窗口创建时,窗口的客户区域被设置为固定的逻辑大小。这意味着当用户改变窗口大小时,逻辑单位的大小也会改变。俄罗斯方块游戏和PreviewDocument
类使用这个系统。
除了StandardDocument
类之外,还有一个PrintPreviewDocument
类,它也扩展了Document
类。它显示标准文档的一页。用户可以通过使用箭头键和向上翻页和向下翻页键或使用垂直滚动条来更改页面:
class PrintPreviewDocument : Document {
public:
PrintPreviewDocument(StandardDocument* parentDocument,
int page = 1, Size pageSize = USLetterPortrait);
bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
void OnVerticalScroll(WORD flags, WORD thumbPos = 0);
void OnPaint(Graphics& graphics) const;
};
此外,还有一些简单的辅助类:
-
Point
:它包含一个二维点(x 和 y) -
Size
:它包含二维的宽度和高度 -
Rect
:它包含矩形的四个角 -
DynamicList
:它包含一个动态列表 -
Tree
:它包含一个树形结构 -
InfoList
:它包含一个可以转换成内存块的通用信息列表
Registry
类包含对Windows 注册表的接口,这是 Windows 系统中我们可以用来在应用程序执行之间存储值的数据库。Clipboard
类包含对Windows 剪贴板的接口,这是 Windows 中用于短期数据存储的区域,我们可以用它来在应用程序之间存储剪切、复制和粘贴的信息。
Dialog
类是为自定义对话框设计的。Control
类是对话框控制的根类。CheckBox
、RadioButton
、PushButton
、ListBox
和ComboBox
类是特定控制的类。TextField
类包含一个可以被Converter
类转换成不同类型的文本字段。最后,PageSetupDialog
类扩展了Dialog
类,并实现了一个带有控制和转换器的对话框。
摘要
本章介绍了小型窗口。在第二章“你好,小型世界”中,我们将开始使用小型窗口开发应用程序。
第二章。Hello, Small World!
本章通过展示以下两个小型应用程序来介绍 Small Windows:
-
第一个应用程序在窗口中写入 "Hello, Small Windows!"
-
第二个应用程序在文档窗口中处理不同颜色的圆圈
Hello, Small Windows!
在 Brian Kernighan 和 Dennis Ritchie 的《C 程序设计语言》中,介绍了 hello-world 示例。它是一个小程序,在屏幕上写入 "hello, world"。在本节中,我们将为 Small Windows 编写一个类似的程序。
在常规 C++ 中,应用程序的执行从 main
函数开始。然而,在 Small Windows 中,main
被框架隐藏,并被 MainWindow
替换,其任务是定义应用程序名称并创建主窗口对象。以下 argumentList
参数对应于 main
中的 argc
和 argv
。commandShow
参数将系统对窗口外观的请求传递:
MainWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "HelloWindow.h"
void MainWindow(vector<String> /* argumentList */, WindowShow windowShow) {
Application::ApplicationName() = TEXT("Hello");
Application::MainWindowPtr() =
new HelloWindow(windowShow);
}
在 C++ 中,有两种字符类型,char
和 wchar_t
,其中 char
存储一个 1 字节的常规字符,而 wchar_t
存储一个较大尺寸的宽字符,通常为 2 字节。还有一个 string
类,它存储 char
值的字符串,以及 wstring
类,它存储 wchar_t
值的字符串。
然而,在 Windows 中,还有一个通用的字符类型 TCHAR
,它取决于系统设置,可以是 char
或 wchar_t
。还有一个 String
类,它存储 TCHAR
值的字符串。此外,TEXT
是一个宏,它将字符值转换为 TCHAR
,将文本值转换为 TCHAR
值的数组。
总结一下,以下表格显示了字符类型和字符串类:
常规字符 | 宽字符 | 通用字符 |
---|---|---|
char | wchar_t | TCHAR |
字符串 | w 字符串 | 字符串 |
在本书的应用程序中,我们始终使用 TCHAR
类型、String
类和 TEXT
宏。唯一例外是 第十三章 中的剪贴板处理,注册表、剪贴板、标准对话框和打印预览。
我们版本的 hello-world 程序在客户区域的中心写入 "Hello, Small Windows!"。窗口的客户区域是窗口中可以绘制图形对象的部分。在以下窗口中,客户区域是白色区域:
HelloWindow
类扩展了小型窗口 Window
类。它包含一个构造函数和 Draw
方法。构造函数使用有关窗口外观的合适信息调用 Window
构造函数。每当窗口的客户区域需要重绘时,都会调用 Draw
方法:
HelloWindow.h
class HelloWindow : public Window {
public:
HelloWindow(WindowShow windowShow);
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
};
HelloWindow
的构造函数使用以下参数调用 Window
的构造函数:
-
HelloWindow
构造函数的第一个参数是坐标系。LogicalWithScroll
表示每个逻辑单位是一百分之一毫米,无论屏幕的物理分辨率如何。当前滚动条设置将被考虑。 -
Window
构造函数的第二个参数是窗口的首选大小。它表示应使用默认大小。 -
第三个参数是指向父窗口的指针。由于窗口没有父窗口,因此它是 null。
-
第四和第五个参数设置窗口的样式,在这种情况下是重叠窗口。
-
最后一个参数是
windowShow
,由周围系统提供给MainWindow
,它决定了窗口的初始外观(最小化、正常或最大化)。 -
最后,构造函数通过调用
Window
类的SetHeader
方法来设置窗口的标题。
HelloWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "HelloWindow.h"
HelloWindow::HelloWindow(WindowShow windowShow)
:Window(LogicalWithScroll, ZeroSize, nullptr,
OverlappedWindow, NoStyle, windowShow) {
SetHeader(TEXT("Hello Window"));
}
每当窗口的客户区域需要重绘时,都会调用OnDraw
方法。它获取客户区域的大小,并在白色背景上以黑色文本在其中心绘制文本。SystemFont
参数将使文本以默认系统字体显示。
小窗口的Color
类包含常量Black
和White
。Point
类包含一个二维点。Size
类包含width
和height
。Rect
类包含一个矩形;更具体地说,它包含矩形的四个角:
void HelloWindow::OnDraw(Graphics& graphics,
DrawMode /* drawMode */) const {
Size clientSize = GetClientSize();
Rect clientRect(Point(0, 0), clientSize);
graphics.DrawText(clientRect, TEXT("Hello, Small Windows!"),
SystemFont, Black, White);
}
圆的应用程序
在本节中,我们将探讨一个简单的圆应用。正如其名所示,它允许用户在图形应用程序中处理圆。用户可以通过按下鼠标左键来添加一个新的圆。用户还可以通过拖动来移动现有的圆。此外,用户还可以更改圆的颜色,以及保存和打开文档:
主窗口
正如我们将在整本书中看到的那样,MainWindow
函数始终执行相同的事情:它设置应用程序名称并创建应用程序的主窗口。该名称被保存和打开标准对话框、关于菜单项和注册表使用。
主窗口与其他应用程序窗口之间的区别在于,当用户关闭主窗口时,应用程序退出。此外,当用户选择退出菜单项时,主窗口被关闭,并调用其析构函数:
MainWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Circle.h"
#include "CircleDocument.h"
void MainWindow(vector<String> /* argumentList */,
WindowShow windowShow) {
Application::ApplicationName() = TEXT("Circle");
Application::MainWindowPtr() =
new CircleDocument(windowShow);
}
CircleDocument
类
CircleDocument
类扩展了 Small Windows 的StandardDocument
类,而StandardDocument
类又反过来扩展了Document
和Window
类。实际上,StandardDocument
类构成了一个框架,即一个具有一组虚拟方法的功能基类,我们可以覆盖并进一步指定这些方法。
OnMouseDown
和 OnMouseUp
方法是从 Window
类继承的,当用户按下或释放鼠标按钮时会被调用。OnMouseMove
方法在用户移动鼠标时被调用。OnDraw
方法也是从 Window
类继承的,每次窗口需要重绘时都会被调用。
ClearDocument
、ReadDocumentFromStream
和 WriteDocumentToStream
方法是从 StandardDocument
类继承的,当用户创建新文件、打开文件或保存文件时会被调用:
CircleDocument.h
class CircleDocument : public StandardDocument {
public:
CircleDocument(WindowShow windowShow);
~CircleDocument();
void OnMouseDown(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed);
void OnMouseUp(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed);
void OnMouseMove(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed);
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
bool ReadDocumentFromStream(String name,
istream& inStream);
bool WriteDocumentToStream(String name,
ostream& outStream) const;
void ClearDocument();
DEFINE_BOOL_LISTENER
和 DEFINE_VOID_LISTENER
宏定义了监听器,这些是没有参数的方法,当用户选择菜单项时会被调用。这两个宏之间的唯一区别是定义的方法的返回类型:bool
或 void
。
在本书的应用中,我们使用常见的标准,即响应用户操作而调用的监听器以 On
为前缀,例如,OnRed
,如下面的代码片段所示。决定菜单项是否应该启用的方法以 Enable
为后缀,而决定菜单项是否应该用勾选标记或单选按钮标记的方法以 Check
或 Radio
为后缀。
在以下应用程序中,我们为红色、绿色和蓝色定义了菜单项。我们还定义了一个颜色标准对话框的菜单项:
DEFINE_VOID_LISTENER(CircleDocument,OnRed);
DEFINE_VOID_LISTENER(CircleDocument,OnGreen);
DEFINE_VOID_LISTENER(CircleDocument,OnBlue);
DEFINE_VOID_LISTENER(CircleDocument,OnColorDialog);
当用户选择了一种颜色,红色、绿色或蓝色时,相应的菜单项会用单选按钮进行勾选。RedRadio
、GreenRadio
和 BlueRadio
参数在菜单项变得可见之前被调用,并返回一个布尔值,指示菜单项是否应该用单选按钮标记:
DEFINE_BOOL_LISTENER(CircleDocument, RedRadio);
DEFINE_BOOL_LISTENER(CircleDocument, GreenRadio);
DEFINE_BOOL_LISTENER(CircleDocument, BlueRadio);
圆的半径始终为 500 单位,相当于 5 毫米:
static const int CircleRadius = 500;
circleList
字段存储圆,其中最顶部的圆位于列表的开头。nextColor
字段存储用户将要添加的下一个圆的颜色。它被初始化为负一,以表示开始时没有圆在移动。moveIndex
和 movePoint
字段被 OnMouseDown
和 OnMouseMove
方法用来跟踪用户正在移动的圆:
private:
vector<Circle> circleList;
Color nextColor;
int moveIndex = -1;
Point movePoint;
};
在 StandardDocument
构造函数调用中,前两个参数是 LogicalWithScroll
和 USLetterPortrait
。它们表示逻辑大小是毫米的百分之一,客户端区域包含一个 US 信封的逻辑大小:215.9279.4 毫米(8.511 英寸)。如果窗口被调整大小,使得客户端区域小于一个 US 信封,则会在窗口中添加滚动条。
第三个参数设置了标准保存和打开对话框使用的文件信息;文本描述设置为Circle Files
,文件后缀设置为cle
。nullptr
参数表示窗口没有父窗口。OverlappedWindow
常量参数表示窗口应与其他窗口重叠,而windowShow
参数是通过MainWindow
类从周围系统传递给窗口的初始外观:
CircleDocument.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Circle.h"
#include "CircleDocument.h"
CircleDocument::CircleDocument(WindowShow windowShow)
:StandardDocument(LogicalWithScroll, USLetterPortrait,
TEXT("Circle Files, cle"), nullptr,
OverlappedWindow, windowShow) {
StandardDocument
类将标准的文件、编辑和帮助菜单添加到窗口菜单栏。文件菜单包含新建、打开、保存、另存为、页面设置、打印预览和退出项。页面设置和打印预览是可选的。StandardDocument
构造函数的第七个参数(默认值为false
)表示它们的存在。编辑菜单包含剪切、复制、粘贴和删除项。它们默认是禁用的;我们不会在这个应用程序中使用它们。帮助菜单包含关于项,MainWindow
中设置的应用程序名称用于显示包含标准消息Circle, version 1.0的消息框。
我们将标准的文件和编辑菜单添加到菜单栏。然后我们添加了颜色菜单,这是该应用程序的应用特定菜单。最后,我们添加了标准的帮助菜单并设置了文档的菜单栏。
颜色菜单包含用于设置圆颜色的菜单项。当用户选择菜单项时,会调用OnRed
、OnGreen
和OnBlue
方法,而在用户选择颜色菜单之前,会调用RedRadio
、GreenRadio
和BlueRadio
方法以决定是否用单选按钮标记项。OnColorDialog
方法打开一个标准颜色对话框。
在以下代码片段中的&Red\tCtrl+R
文本中,和号(&)表示菜单项有一个快捷键;即字母 R 将被下划线标记,并且可以在菜单打开后通过按 R 来选择菜单项。制表符字符(\t)表示文本的第二部分定义了一个快捷键;即文本Ctrl+R
将在菜单项中右对齐,并且可以通过按 Ctrl+R 来选择该项:
Menu menuBar(this);
StandardFileMenu
的false
参数表示我们不希望包含文件菜单项。
menuBar.AddMenu(StandardFileMenu(false));
Menu
类中的AddItem
方法还接受两个额外的参数,用于启用菜单项和设置复选框。然而,我们在这个应用程序中不使用它们。因此,我们发送空指针:
Menu colorMenu(this, TEXT("&Color"));
colorMenu.AddItem(TEXT("&Red\tCtrl+R"), OnRed,
nullptr, nullptr, RedRadio);
colorMenu.AddItem(TEXT("&Green\tCtrl+G"), OnGreen,
nullptr, nullptr, GreenRadio);
colorMenu.AddItem(TEXT("&Blue\tCtrl+B"), OnBlue,
nullptr, nullptr, BlueRadio);
colorMenu.AddSeparator();
colorMenu.AddItem(TEXT("&Dialog ..."), OnColorDialog);
menuBar.AddMenu(colorMenu);
menuBar.AddMenu(StandardHelpMenu());
SetMenuBar(menuBar);
最后,我们从注册表中读取当前颜色(即将添加的下一个圆的颜色);如果没有在注册表中存储颜色,则默认颜色为红色:
nextColor.ReadColorFromRegistry(TEXT("NextColor"), Red);
}
析构函数将当前颜色保存到注册表中。在这个应用程序中,我们不需要执行析构函数的正常任务,例如释放内存或关闭文件:
CircleDocument::~CircleDocument() {
nextColor.WriteColorToRegistry(TEXT("NextColor"));
}
当用户选择新建菜单项时,会调用ClearDocument
方法。在这种情况下,我们只需清除圆列表。其他任何操作,如重绘窗口或更改其标题,都由StandardDocument
类处理:
void CircleDocument::ClearDocument() {
circleList.clear();
}
当用户通过选择保存或另存为来保存文件时,StandardDocument
类会调用WriteDocumentToStream
方法。它将圆的数量(圆列表的大小)写入输出流,并为每个圆调用WriteCircle
方法,以便将它们的状态写入流:
bool CircleDocument::WriteDocumentToStream(String name,
ostream& outStream) const {
int size = circleList.size();
outStream.write((char*) &size, sizeof size);
for (Circle circle : circleList) {
circle.WriteCircle(outStream);
}
return ((bool) outStream);
}
当用户通过选择打开菜单项打开文件时,StandardDocument
方法会调用ReadDocumentFromStream
方法。它读取圆的数量(圆列表的大小),并为每个圆创建一个新的Circle
类对象,调用ReadCircle
方法以读取圆的状态,并将圆对象添加到circleList
方法:
bool CircleDocument::ReadDocumentFromStream(String name,
istream& inStream) {
int size;
inStream.read((char*) &size, sizeof size);
for (int count = 0; count < size; ++count) {
Circle circle;
circle.ReadCircle(inStream);
circleList.push_back(circle);
}
return ((bool) inStream);
}
当用户按下鼠标按钮时,会调用OnMouseDown
方法。首先我们需要检查他们是否按下了左键。如果按下了,我们就遍历圆列表,并为列表中的每个圆调用IsClick
方法,以确定他们是否点击了圆。请注意,最上面的圆位于列表的开头;因此,我们从列表的开头开始循环。如果我们找到一个被点击的圆,我们就退出循环。
如果用户点击了一个圆,我们将其索引moveIndex
和当前鼠标位置存储在movePoint
中。这两个值都需要在用户移动鼠标时调用的OnMouseMove
方法中使用:
void CircleDocument::OnMouseDown
(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed /* = false */,
bool controlPressed /* = false */) {
if (mouseButtons == LeftButton) {
moveIndex = -1;
int size = circleList.size();
for (int index = 0; index < size; ++index) {
if (circleList[index].IsClick(mousePoint)) {
moveIndex = index;
movePoint = mousePoint;
break;
}
}
然而,如果用户没有点击圆,我们会添加一个新的圆。圆由其中心位置(mousePoint
)、半径(CircleRadius
)和颜色(nextColor
)定义。
无效区域是需要重绘的客户区域的一部分。记住,在 Windows 中,我们通常不会直接绘制图形。相反,我们调用Invalidate
方法来告诉系统某个区域需要重绘,并通过调用UpdateWindow
方法强制实际重绘,这最终会导致调用OnDraw
方法。无效区域始终是矩形。Invalidate
方法有一个第二个参数(默认值为true
),表示无效区域应该被清除。
技术上,它被涂在窗口的客户颜色上,在这种情况下是白色。这样,圆的先前位置就会被清除,并在新的位置绘制圆。
SetDirty
方法告诉框架文档已被修改(文档已变为脏的),这会导致保存菜单项被启用,并且如果用户尝试在不保存的情况下关闭窗口,会警告用户:
if (moveIndex == -1) {
Circle newCircle(mousePoint, CircleRadius,
nextColor);
circleList.push_back(newCircle);
Invalidate(newCircle.Area());
UpdateWindow();
SetDirty(true);
}
}
}
每当用户按下至少一个鼠标按钮移动鼠标时,都会调用OnMouseMove
方法。我们首先需要检查用户是否按下了左鼠标按钮并且点击了一个圆(即moveIndex
方法不等于-1
)。如果是,我们通过使用mousePoint
方法比较前一个鼠标事件(OnMouseDown
或OnMouseMove
)的先前和当前鼠标位置来计算与前一个鼠标事件之间的距离。我们更新圆的位置,使旧的和新的区域无效,并使用UpdateWindow
方法强制重绘无效区域,并设置脏标志:
void CircleDocument::OnMouseMove
(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed /* = false */,
bool controlPressed /* = false */) {
if ((mouseButtons == LeftButton)&&(moveIndex != -1)) {
Size distanceSize = mousePoint - movePoint;
movePoint = mousePoint;
Circle& movedCircle = circleList[moveIndex];
Invalidate(movedCircle.Area());
movedCircle.Center() += distanceSize;
Invalidate(movedCircle.Area());
UpdateWindow();
SetDirty(true);
}
}
严格来说,OnMouseUp
方法可以被排除,因为moveIndex
方法在OnMouseDown
方法中被设置为负一,而OnMouseDown
方法总是在OnMouseMove
方法之前被调用。然而,它已经被包含在内,为了完整性:
void CircleDocument::OnMouseUp
(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed /* = false */,
bool controlPressed /* = false */) {
moveIndex = -1;
}
每当窗口需要(部分或全部)重绘时,都会调用OnDraw
方法。这个调用可以由系统初始化,作为对事件的响应(例如,窗口已调整大小)或者由对UpdateWindow
方法的早期调用。Graphics
引用参数是由框架创建的,可以被视为绘制线条、填充区域和写入文本的工具箱。然而,在这个应用程序中,我们不写入文本。
我们遍历圆列表,并对每个圆调用Draw
方法。请注意,我们并不关心哪些圆需要实际重绘。我们只是重绘所有圆。然而,只有位于之前调用Invalidate
方法使区域无效的圆才会实际重绘。
Draw
方法有一个表示绘制模式的第二个参数,可以是Paint
或Print
。Paint
方法表示OnDraw
方法是由Window
类中的OnPaint
方法调用的,并且绘制是在窗口的客户区域中执行的。Print
方法表示OnDraw
方法是由OnPrint
方法调用的,并且绘制被发送到打印机。然而,在这个应用程序中,我们不使用该参数:
void CircleDocument::OnDraw(Graphics& graphics,
DrawMode /* drawMode */) const {
for (Circle circle : circleList) {
circle.Draw(graphics);
}
}
在显示菜单项之前,会调用RedRadio
、GreenRadio
和BlueRadio
方法,如果它们返回true
,则项目将带有单选按钮。Red
、Green
和Blue
常量在Color
类中定义:
bool CircleDocument::RedRadio() const {
return (nextColor == Red);
}
bool CircleDocument::GreenRadio() const {
return (nextColor == Green);
}
bool CircleDocument::BlueRadio() const {
return (nextColor == Blue);
}
当用户选择相应的菜单项时,会调用OnRed
、OnGreen
和OnBlue
方法。它们都将nextColor
字段设置为一个适当的值:
void CircleDocument::OnRed() {
nextColor = Red;
}
void CircleDocument::OnGreen() {
nextColor = Green;
}
void CircleDocument::OnBlue() {
nextColor = Blue;
}
当用户选择颜色对话框菜单项时,会调用OnColorDialog
方法,并显示标准颜色对话框。如果用户选择了一种新颜色,nextcolor
方法将获得所选的颜色值:
void CircleDocument::OnColorDialog() {
StandardDialog(this, nextColor);
}
Circle 类
Circle
是一个包含单个圆信息的类。当从文件中读取圆时使用默认构造函数。当创建一个新的圆时使用第二个构造函数。IsClick
方法如果给定的点位于圆内(检查用户是否点击了圆),则返回true
,Area
方法返回圆的周围矩形(用于无效化),并且调用Draw
方法来重新绘制圆:
Circle.h
class Circle {
public:
Circle();
Circle(Point center, int radius, Color color);
bool WriteCircle(ostream& outStream) const;
bool ReadCircle(istream& inStream);
bool IsClick(Point point) const;
Rect Area() const;
void Draw(Graphics& graphics) const;
Point Center() const {return center;}
Point& Center() {return center;}
Color GetColor() {return color;}
如前所述,圆由其中心位置(center
)、半径(radius
)和颜色(color
)定义:
private:
Point center;
int radius;
Color color;
};
默认构造函数不需要初始化字段,因为它是在用户打开文件时调用的,值是从文件中读取的。然而,第二个构造函数初始化圆的中心点、半径和颜色:
Circle.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Circle.h"
Circle::Circle() {
// Empty.
}
Circle::Circle(Point center, int radius, Color color)
:color(color),
center(center),
radius(radius) {
// Empty.
}
WriteCircle
方法将颜色、中心点和半径写入流。由于radius
是一个常规整数,我们简单地使用 C 标准函数write
,而Color
和Point
有自己的方法将它们的值写入流。在ReadCircle
方法中,我们以类似的方式从流中读取颜色、中心点和半径:
bool Circle::WriteCircle(ostream& outStream) const {
color.WriteColorToStream(outStream);
center.WritePointToStream(outStream);
outStream.write((char*) &radius, sizeof radius);
return ((bool) outStream);
}
bool Circle::ReadCircle(istream& inStream) {
color.ReadColorFromStream(inStream);
center.ReadPointFromStream(inStream);
inStream.read((char*) &radius, sizeof radius);
return ((bool) inStream);
}
IsClick
方法使用毕达哥拉斯定理计算给定点与圆的中心点之间的距离,如果点位于圆内(如果距离小于或等于圆半径),则返回true
:
Circle::IsClick(Point point) const {
int width = point.X() - center.X(),
height = point.Y() - center.Y();
int distance = (int) sqrt((width * width) +
(height * height));
return (distance <= radius);
}
结果矩形的左上角是中心点减去半径,而右下角是中心点加上半径:
Rect Circle::Area() const {
Point topLeft = center - radius,
bottomRight = center + radius;
return Rect(topLeft, bottomRight);
}
我们使用 Small Windows Graphics
类的FillEllipse
方法(没有FillCircle
方法)来绘制圆。圆的边框总是黑色,而其内部颜色由color
字段给出:
void Circle::Draw(Graphics& graphics) const {
Point topLeft = center - radius,
bottomRight = center + radius;
Rect circleRect(topLeft, bottomRight);
graphics.FillEllipse(circleRect, Black, color);
}
摘要
在本章中,你探讨了 Small Windows 中的两个应用:一个简单的 hello-world 应用和一个稍微复杂一点的圆应用,该应用介绍了框架。你还了解了菜单、圆的绘制和鼠标处理。
在第三章中,我们将开发一个经典的俄罗斯方块游戏。
第三章. 构建俄罗斯方块应用程序
在本章中,我们开发了一个经典的俄罗斯方块游戏。我们进一步探讨了Window
类,包括文本写入和绘制更复杂的图形。我们还探讨了计时、随机数和图形更新,如下落图形和闪光效果。其示意图如下:
MainWindow 函数
MainWindow
函数与第二章中的小小世界!中的方法类似。它设置应用程序名称并返回主窗口的指针,在这种情况下,是一个TetrisWindow
类的实例。正如第二章中所述,小小世界!应用程序名称在访问注册表、打开或保存文件以及关于菜单项时使用。然而,在这个应用程序中,没有使用任何这些功能:
MainWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "RedFigure.h"
#include "BrownFigure.h"
#include "TurquoiseFigure.h"
#include "GreenFigure.h"
#include "YellowFigure.h"
#include "BlueFigure.h"
#include "PurpleFigure.h"
#include "TetrisWindow.h"
void MainWindow(vector<String> /* argumentList */,
WindowShow windowShow) {
Application::ApplicationName() = TEXT("Tetris");
Application::MainWindowPtr() = new TetrisWindow(windowShow);
}
俄罗斯方块窗口
在这个应用程序中,我们不使用第二章中的StandardDocument
框架,即小小世界!。相反,TetrisWindow
类直接扩展了 Small Windows 根类Window
。原因很简单,我们不需要StandardDocument
框架或其基类Document
的功能。我们不使用菜单或快捷键,也不保存或加载文件:
TetrisWindow.h
class TetrisWindow : public Window {
public:
TetrisWindow(WindowShow windowShow);
~TetrisWindow();
在这个应用程序中,我们忽略了鼠标。相反,我们关注键盘处理。当用户按下或释放一个键时,会调用OnKeyDown
方法:
bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
与圆形应用程序类似,每当窗口的客户区域需要重绘时,都会调用OnDraw
方法:
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
当窗口获得或失去输入焦点时,会分别调用OnGainFocus
和OnLoseFocus
方法。当窗口失去输入焦点时,它将不会接收任何键盘输入,计时器将被关闭,防止下落图形移动:
void OnGainFocus();
void OnLoseFocus();
OnTimer
方法在窗口具有焦点时每秒被调用一次。它尝试将下落图形向下移动一步。如果无法将图形向下移动,它会调用NewFigure
方法。NewFigure
方法尝试在游戏板上引入一个新的图形。如果失败,则调用GameOver
方法,询问用户是否想要新游戏。如果用户想要新游戏,则调用NewGame
方法。如果用户不想要新游戏,则退出应用程序:
void OnTimer(int timerId);
void EndOfFigure();
void GameOver();
void NewGame();
DeleteFullRows
通过调用IsRowFull
方法检查每一行,并对每一行满的行调用FlashRow
和DeleteRow
方法:
void DeleteFullRows();
bool IsRowFull(int row);
void FlashRow(int row);
void DeleteRow(int markedRow);
如果用户通过点击窗口右上角的叉号尝试关闭窗口,则会调用TryClose
方法。它显示一个消息框,询问用户是否真的想要退出:
bool TryClose();
gameGrid
字段持有显示图形的网格(见下一节)。下落的图形(fallingFigure
)正在网格上下降,下一个将要下降的图形(nextFigure
)显示在右上角。每次玩家填满一行,分数(currScore
)就会增加。计时器标识符(TimerId
)用于跟踪计时器,并赋予任意值1000
。最后,图形列表(figureList
)将被填充七个图形,每种颜色一个。每次需要新的图形时,将从列表中随机选择一个图形并复制:
private:
GameGrid gameGrid;
TetrisFigure fallingFigure, nextFigure;
int currScore = 0;
bool timerActive = true, inverse = false;
static const int TimerId = 1000;
vector<TetrisFigure> figureList;
};
在Window
构造函数调用中的PreviewCoordinate
参数表示窗口的大小是固定的,第二个参数表示大小为 100 * 100 个单位。这意味着与圆应用不同,图形和游戏板的大小会随着用户改变窗口大小而改变:
TetrisWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "RedFigure.h"
#include "BrownFigure.h"
#include "TurquoiseFigure.h"
#include "GreenFigure.h"
#include "YellowFigure.h"
#include "BlueFigure.h"
#include "PurpleFigure.h"
#include "TetrisWindow.h"
TetrisWindow::TetrisWindow(WindowShow windowShow)
:Window(PreviewCoordinate, Rect(0, 0, 100, 100),
nullptr, OverlappedWindow, NoStyle, Normal),
客户端区域的顶部 20%被保留用于分数和下一个图形。游戏网格覆盖客户端区域的底部 80%(从高度单位 20 到 100):
gameGrid(Rect(0, 20, 100, 100)) {
由于我们扩展了Window
类,我们需要手动设置窗口标题:
SetHeader(TEXT("Tetris"));
计时器间隔设置为1000
毫秒,这意味着OnTimer
每秒会被调用一次。随机生成器通过调用 C 标准函数srand
和time
进行初始化:
SetTimer(TimerId, 1000);
srand((unsigned int) time(nullptr));
图形列表初始化时包含每种颜色的一个图形;下落和下一个图形从这个列表中随机选择。每次我们需要一个新的图形时,列表中的一个图形将被复制:
figureList.push_back(RedFigure(this, &gameGrid));
figureList.push_back(BrownFigure(this, &gameGrid));
figureList.push_back(TurquoiseFigure(this, &gameGrid));
figureList.push_back(GreenFigure(this, &gameGrid));
figureList.push_back(YellowFigure(this, &gameGrid));
figureList.push_back(BlueFigure(this, &gameGrid));
figureList.push_back(PurpleFigure(this, &gameGrid));
fallingFigure = figureList[rand() % figureList.size()];
nextFigure = figureList[rand() % figureList.size()];
}
严格来说,在关闭俄罗斯方块窗口时,没有必要丢弃计时器。析构函数仅为了完整性而包含:
TetrisWindow::~TetrisWindow() {
DropTimer(TimerId);
}
键盘输入
OnKeyDown
方法覆盖了Window
类中的方法,并在用户按下每个键时被调用。我们尝试根据按下的键移动下落的图形。我们不在乎用户是否按下了Shift或Ctrl键:
bool TetrisWindow::OnKeyDown(WORD key, bool /* shiftPressed */,
bool /* controlPressed */) {
switch (key) {
case KeyLeft:
fallingFigure.TryMoveLeft();
break;
case KeyRight:
fallingFigure.TryMoveRight();
break;
case KeyUp:
fallingFigure.TryRotateAnticlockwise();
break;
case KeyDown:
fallingFigure.TryRotateAnticlockwise();
break;
当用户按下空格键时,下落的图形以可见的速度下落,以产生下落的错觉。我们尝试通过调用 Win32 API 函数Sleep
,每 10 毫秒将下落的图形下移一步:
case KeySpace:
while (fallingFigure.TryMoveDown()) {
::Sleep(10);
}
break;
}
return true;
}
绘图
OnDraw
方法首先绘制游戏网格和两条将客户端区域分为三部分的线。左上角显示当前分数,右上角显示下一个图形,而下半部分显示实际的游戏网格:
void TetrisWindow::OnDraw(Graphics& graphics,
DrawMode /* drawMode */) const {
gameGrid.DrawGameGrid(graphics, inverse);
graphics.FillRectangle(Rect(Point(0, 0), Point(100,20)),
White, White);
graphics.DrawLine(Point(40, 0), Point(40, 20), Black);
graphics.DrawLine(Point(0, 20), Point(100, 20), Black);
注意,我们在绘制下一个图形时添加了一个偏移量,以便从游戏网格移动到右上角。值25
将图形从网格中间移动到其右半部分的中间,而值-18
则从网格向上移动到网格前的区域:
fallingFigure.DrawFigure(graphics);
nextFigure.DrawFigure(graphics, Size(25, -18));
分数字体设置为Times New Roman
,大小10
。在这里,大小不是指排版点数,而是指逻辑单位。由于调用Window
构造函数时声明我们提供了PreviewCoordinate
坐标系和大小 100 * 100,文本的高度将是 10 个单位,这是文本客户端区域高度的十分之一。它也是客户端区域中记分部分高度的一半:
Font scoreFont(TEXT("Times New Roman"), 10);
调用DrawText
方法时的最终false
参数表示文本的大小不会重新计算。在接下来的章节中,我们将显示保持相同大小的文本,无论窗口大小和屏幕分辨率如何。然而,在本章中,当用户更改窗口大小时,文本的大小将会改变:
graphics.DrawText(Rect(0, 0, 40, 20), to_String(currScore),
scoreFont, Black, White, false);
}
输入焦点
OnGainFocus
和OnLoseFocus
方法分别开始和停止计时器,这样当窗口失去焦点时,下落图形不会下落:
void TetrisWindow::OnGainFocus() {
SetTimer(TimerId, 1000);
}
void TetrisWindow::OnLoseFocus() {
DropTimer(TimerId);
}
计时器
当计时器具有输入焦点时,计时器处于活动状态。当活动时,每次调用OnTimer
方法(每秒一次)时都会调用TryMoveDown
方法。当图形无法再下落(TryMoveDown
方法返回false
)时,将调用EndOfFigure
方法:
void TetrisWindow::OnTimer(int /* timerId */) {
if (timerActive) {
if (!fallingFigure.TryMoveDown()) {
EndOfFigure();
}
}
}
新图形
当下落图形无法向下移动时,OnTimer
方法调用NewFigure
方法。首先,我们需要通过调用AddToGrid
方法将下落图形存储到游戏网格中。然后,我们将下一个图形变为新的下落图形,并从图形列表中随机选择新的下一个图形。我们使新的下落图形区域和绘制下一个图形的右上角区域无效:
void TetrisWindow::NewFigure() {
fallingFigure.AddToGrid();
fallingFigure = nextFigure;
fallingFigure.InvalidateFigure();
nextFigure = figureList[rand() % figureList.size()];
Rect nextArea(40, 0, 100, 20);
Invalidate(nextArea);
UpdateWindow();
我们删除可能的完整行并更新窗口:
DeleteFullRows();
UpdateWindow();
如果新的下落图形从一开始就不有效,则游戏结束并调用GameOver
:
if (!fallingFigure.IsFigureValid()) {
GameOver();
}
}
游戏结束
GameOver
方法显示分数并让用户决定他们是否想要新游戏。如果他们想要新游戏,它将通过NewGame
调用进行初始化。如果用户不想玩新游戏,调用 Win32 API 函数PostQuitMessage
将终止应用程序的执行。
注意,我们调用另一个版本的Invalidate
方法,没有参数。它使整个客户端区域无效:
void TetrisWindow::GameOver() {
Invalidate();
UpdateWindow();
当显示消息时,计时器处于非活动状态:
timerActive = false;
String message = TEXT("Game Over.\nYou scored ") +
to_String(currScore) +
TEXT(" points.\nAnother game?");
if (MessageBox(message, TEXT("Tetris"), YesNo, Question)==Yes) {
NewGame();
}
else {
::PostQuitMessage(0);
}
}
新游戏
NewGame
方法初始化随机选择的新下落和下一个图形,重置分数,并在激活计时器之前清除游戏网格,以及使窗口无效并更新,这使得新的下落图形开始下落,新的游戏开始:
void TetrisWindow::NewGame() {
fallingFigure = figureList[rand() % figureList.size()];
nextFigure = figureList[rand() % figureList.size()];
currScore = 0;
gameGrid.ClearGameGrid();
timerActive = true;
Invalidate();
UpdateWindow();
}
删除和闪烁行
在删除完整行时,我们遍历行,闪烁并删除每一行。我们增加分数并更新行区域。请注意,行从网格的顶部开始。这意味着我们必须从最高行到最低行进行循环,以便按正确顺序删除行。
注意,如果行被闪烁并删除,我们不会更新row
变量,因为被删除的行将被上面的行替换,这也需要被检查:
void TetrisWindow::DeleteFullRows() {
int row = Rows - 1;
while (row >= 0) {
if (IsRowFull(row)) {
FlashRow(row);
DeleteRow(row);
++currScore;
Rect scoreArea(0, 0, 40, 20);
Invalidate(scoreArea);
UpdateWindow();
}
else {
--row;
}
}
}
如果一行不包含白色正方形,则认为该行是满的:
bool TetrisWindow::IsRowFull(int row) {
for (int col = 0; col < Cols; ++col) {
if (gameGrid[row][col] == White) {
return false;
}
}
return true;
}
闪烁效果是通过三次以 50 毫秒的间隔重新绘制行,在正常和反转颜色(inverse
方法被设置)下实现的。在这个过程中,特别重要的是我们只使所选行的区域无效。否则,整个窗口客户端区域都会闪烁:
void TetrisWindow::FlashRow(int row) {
Rect gridArea = gameGrid.GridArea();
int colWidth = gridArea.Width() / Cols,
rowHeight = gridArea.Height() / Rows;
Rect rowArea(0, row * rowHeight, Cols * colWidth,
(row + 1) * rowHeight);
for (int count = 0; count < 3; ++count) {
inverse = true;
Invalidate(rowArea + gridArea.Top()Left());
UpdateWindow();
::Sleep(50);
inverse = false;
Invalidate(rowArea + gridArea.Top()Left());
UpdateWindow();
::Sleep(50);
}
}
当删除一行时,我们实际上并没有删除它。相反,我们将被删除行上面的每一行向下移动一步,并用白色正方形填充顶部行。一个复杂的问题是,我们是从顶部开始计数行的。这使得屏幕上最低的行是具有最高索引的行。这给人一种我们从底部开始,直到达到顶部,移除每一行完整行的外观:
void TetrisWindow::DeleteRow(int markedRow) {
for (int row = markedRow; row > 0; --row) {
for (int col = 0; col < Cols; ++col) {
gameGrid[row][col] = gameGrid[row - 1][col];
}
}
for (int col = 0; col < Cols; ++col) {
gameGrid[0][col] = White;
}
Invalidate(gameGrid.GridArea());
Invalidate(g);
UpdateWindow();
}
关闭窗口
最后,当用户通过点击右上角的交叉点来关闭窗口时,我们需要确认他们确实想要退出。如果TryClose
方法返回true
,则窗口将被关闭:
bool TetrisWindow::TryClose() {
timerActive = false;
if (MessageBox(TEXT("Quit?"), TEXT("Tetris"),
YesNo, Question) == Yes) {
return true;
}
timerActive = true;
return false;
}
TetrisFigure 类
在这个应用程序中,有一个根figure
类和每个下落图形类型的一个子类。所有图形都可以在用户请求时向侧面移动或旋转。它们也由计时器向下移动。
有七个图形,每个颜色一个:红色、棕色、青色、绿色、黄色、蓝色和紫色。每个图形都有独特的形状。然而,它们都包含四个正方形。根据它们旋转的能力,它们可以进一步分为三组。红色图形是最简单的。它是一个正方形,根本不会旋转。棕色、青色和绿色图形可以在垂直和水平方向旋转,而黄色、蓝色和紫色图形可以在北、东、南、西方向旋转。对于红色图形来说,这并不重要,因为它根本不会旋转。
TetrisFigure
类的row
和col
字段持有图形的中心,这在本节插图中的图形上用十字标记。color
字段持有图形的颜色,direction
持有图形的当前方向。
最后,direction
数组持有围绕标记正方形的三个正方形的相对位置。最多有四个方向。每个方向持有三个正方形,它们是除了图形中心之外剩下的三个正方形。每个正方形持有两个整数:中心行的相对位置和列的相对位置。
默认构造函数用于初始化TetrisWindow
类中的fallingFigure
和nextFigure
方法。第二个构造函数是受保护的,因为它只被它的子类调用。每个图形都有自己的TetrisFigure
子类。它们的构造函数接受指向颜色网格的指针,并定义其颜色、起始位置和图形模式:
TetrisFigure.h
class TetrisFigure {
public:
TetrisFigure();
protected:
TetrisFigure(Window* windowPtr, GameGrid* colorGridPtr,
Color color, int row, int col, Direction direction,
IntPair* northList, IntPair* eastList,
IntPair* southList, IntPair* westList);
public:
TetrisFigure& operator=(const TetrisFigure& figure);
TryMoveLeft
、TryMoveRight
、TryRotateClockwise
、TryRotateClockwise
、TryRotateAnticlockwise
和 TryMoveDown
方法都试图移动图形。它们调用 IsFigureValid
方法,该方法检查新位置是否有效,即它不在游戏网格外部或位于已被占用的位置。IsFigureValid
方法反过来又为它的四个方块调用 IsSquareValid
方法:
void TryMoveLeft();
void TryMoveRight();
void TryRotateClockwise();
void TryRotateAnticlockwise();
bool TryMoveDown();
IsFigureValid
方法有两种版本,其中第一个版本由 TetrisWindow
方法调用,另一个版本由前面的 try
方法调用,以测试下落图形的新位置是否有效:
bool IsFigureValid();
static bool IsFigureValid(int direction, int row, int col,
GameGrid* gameGridPtr, IntPair* figureInfo[]);
static bool IsSquareValid(int row, int col,
GameGrid* gameGridPtr);
AddToGrid
方法将图形的四个方块添加到游戏网格:
void AddToGrid();
InvalidateFigure
方法使图形占据的区域无效,DrawFigure
方法绘制图形:
void InvalidateFigure(Size offsetSize = ZeroSize);
void DrawFigure(Graphics& graphics,
Size offsetSize = ZeroSize) const;
gameGridPtr
字段是指向游戏网格的指针,我们在尝试移动图形时访问它,以决定其新位置是否有效。color
字段是图形的颜色(红色、棕色、青绿色、绿色、黄色、蓝色或紫色)。row
、col
和 direction
字段持有图形的当前位置和方向。
figureInfo
字段持有图形的形状。图形可以持有最多四个方向:北、东、南、西。记住,row
和 col
持有图形的位置。更具体地说,它们持有构成图形的四个方块的中心方块的位置(以下插图中的十字标记)。其他三个方块由整数对定义,表示它们相对于中心方块的位置。
技术上,figureInfo
是一个包含四个指针的数组(每个方向一个),每个指针指向一个包含三个整数对的数组,持有三个方块相对于中心方块的位置:
protected:
Window* windowPtr;
GameGrid* gameGridPtr;
Color color;
int row, col;
Direction direction;
IntPair* figureInfo[4];
};
默认构造函数是必要的,因为 fallingFigure
和 nextFigure
是 TetrisWindow
类的成员对象。然而,它们不需要初始化,因为它们的值被分配给 figureList
数组中的七个图形之一:
TetrisFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "TetrisWindow.h"
TetrisFigure::TetrisFigure() {
// Empty
}
第二个构造函数由彩色图形子类构造函数调用,以初始化图形。它接受指向主窗口和游戏网格的指针、图形的颜色、其起始位置和方向,以及北、东、南、西方向的位置列表。每个列表都包含三个整数对,表示方块相对于中心方块的位置:
TetrisFigure::TetrisFigure(Window*windowPtr, GameGrid*gameGridPtr,
Color color, int row, int col,
Direction direction,
IntPair* northList, IntPair* eastList,
IntPair* southList, IntPair* westList)
:windowPtr(windowPtr),
gameGridPtr(gameGridPtr),
color(color),
row(row),
col(col),
direction(direction) {
figureInfo[North] = northList;
figureInfo[East] = eastList;
figureInfo[South] = southList;
figureInfo[West] = westList;
}
赋值运算符是必要的,因为 TetrisWindow
类中的 fallingFigure
和 nextFigure
方法是从图形列表复制的:
TetrisFigure& TetrisFigure::operator=(const TetrisFigure& figure) {
if (this != &figure) {
windowPtr = figure.windowPtr;
gameGridPtr = figure.gameGridPtr;
color = figure.color;
row = figure.row;
col = figure.col;
direction = figure.direction;
figureInfo[North] = figure.figureInfo[North];
figureInfo[East] = figure.figureInfo[East];
figureInfo[South] = figure.figureInfo[South];
figureInfo[West] = figure.figureInfo[West];
}
return *this;
}
当用户按下箭头键时,会调用TryMoveLeft
、TryMoveRight
、TryRotateClockwise
和TryRotateAnticlockwise
方法。如果成功,它们会尝试移动图形并使其之前和当前区域无效:
void TetrisFigure::TryMoveLeft() {
if (IsFigureValid(direction, row, col - 1
gameGridPtr, figureInfo)) {
windowPtr->Invalidate(Area());
--col;
windowPtr->Invalidate(Area());
windowPtr->UpdateWindow();
}
}
void TetrisFigure::TryMoveRight() {
if (IsFigureValid(direction, row, col + 1
gameGridPtr, figureInfo)) {
windowPtr->Invalidate(Area());
++col;
windowPtr->Invalidate(Area());
windowPtr->UpdateWindow();
}
}
void TetrisFigure::TryRotateClockwise() {
Direction newDirection = (direction == West) ? North :
((Direction) (direction + 1));
if (IsFigureValid(newDirection, row, col,
gameGridPtr, figureInfo)) {
InvalidateFigure();
direction = newDirection;
InvalidateFigure();
windowPtr->UpdateWindow();
}
}
void TetrisFigure::TryRotateAnticlockwise() {
Direction newDirection = (this->direction == North) ? West :
((Direction) (direction - 1));
if (IsFigureValid(newDirection, row, col,
gameGridPtr, figureInfo)) {
InvalidateFigure();
direction = newDirection;
InvalidateFigure();
windowPtr->UpdateWindow();
}
}
当玩家按下空格键时,计时器会调用TryMoveDown
方法。它也会在TetrisWindow
类的OnTimer
方法中被调用;它返回一个Boolean
值,表示移动是否成功:
bool TetrisFigure::TryMoveDown() {
if (IsFigureValid(direction, row + 1, col
gameGridPtr, figureInfo)) {
windowPtr->Invalidate(Area());
++row;
windowPtr->Invalidate(Area());
windowPtr->UpdateWindow();
return true;
}
return false;
}
第一版本的IsFigureValid
方法由TetrisWindow
类调用,并调用第二个静态版本,带有图形的当前位置和方向:
bool TetrisFigure::IsFigureValid() {
return IsFigureValid(direction, row, col
gameGridPtr, figureInfo);
}
第二版本的IsFigureValid
方法由前面的try
方法调用,并通过为图形中的每个方块调用IsSquareValid
方法来检查图形是否有效。为了做到这一点,它需要在figureInfo
方法中查找包含方块的相对位置。整数对的第一个值是行,第二个值是列:
bool TetrisFigure::IsFigureValid(int direction, int row, int col,
GameGrid* gameGridPtr,
IntPair* figureInfo[]) {
int relRow0 = row + figureInfo[direction][0].first,
relCol0 = col + figureInfo[direction][0].second,
relRow1 = row + figureInfo[direction][1].first,
relCol1 = col + figureInfo[direction][1].second,
relRow2 = row + figureInfo[direction][2].first,
relCol2 = col + figureInfo[direction][2].second;
return IsSquareValid(row, col, gameGridPtr) &&
IsSquareValid(relRow0, relCol0, gameGridPtr) &&
IsSquareValid(relRow1, relCol1, gameGridPtr) &&
IsSquareValid(relRow2, relCol2, gameGridPtr);
}
如果给定的方块位于游戏网格内且未被占用,则IsSquareValid
方法返回true
。如果方块是白色的,则认为它未被占用:
bool TetrisFigure::IsSquareValid(int row, int col,
GameGrid* gameGridPtr) {
return (row >= 0) && (row < Rows) &&
(col >= 0) && (col < Cols) &&
((*gameGridPtr)[row][col] == White);
}
当下落的图形达到其最终位置时,它将被添加到游戏网格中。这是通过将图形的颜色设置为游戏网格中当前位置的方块来完成的。当一个下落图形无法再下落而不会与之前的图形碰撞,或者已经达到游戏网格的底部边界时,它就达到了最终位置:
void TetrisFigure::AddToGrid() {
(*gameGridPtr)[row][col] = color;
{ int relRow = row + figureInfo[direction][0].first,
relCol = col + figureInfo[direction][0].second;
(*gameGridPtr)[relRow][relCol] = color;
}
{ int relRow = row + figureInfo[direction][1].first,
relCol = col + figureInfo[direction][1].second;
(*gameGridPtr)[relRow][relCol] = color;
}
{ int relRow = row + figureInfo[direction][2].first,
relCol = col + figureInfo[direction][2].second;
(*gameGridPtr)[relRow][relCol] = color;
}
}
当图形被移动后,我们需要重新绘制它。为了避免炫目,我们只想使其区域无效,这是通过InvalidateFigure
方法完成的。我们查找图形四个方块的行和列,并在游戏网格中对每个方块调用InvalidateSquare
方法:
void TetrisFigure::InvalidateFigure(Size offsetSize/*=ZeroSize*/){
gameGridPtr->InvalidateSquare(windowPtr, row, col, offsetSize);
{ int relRow = row + figureInfo[direction][0].first,
relCol = col + figureInfo[direction][0].second;
gameGridPtr->InvalidateSquare(windowPtr, relRow,
relCol, offsetSize);
}
{ int relRow = row + figureInfo[direction][1].first,
relCol = col + figureInfo[direction][1].second;
gameGridPtr->InvalidateSquare(windowPtr, relRow,
relCol, offsetSize);
}
{ int relRow = row + figureInfo[direction][2].first,
relCol = col + figureInfo[direction][2].second;
gameGridPtr->InvalidateSquare(windowPtr, relRow,
relCol, offsetSize);
}
}
在绘制图形之前,我们需要查找图形方块的定位,以类似于InvalidateFigure
方法的方式绘制它们:
void TetrisFigure::DrawFigure(Graphics& graphics,Size offsetSize)
const {
gameGridPtr->DrawSquare(graphics, row, col,
Black, color, offsetSize);
{ int relRow = row + figureInfo[direction][0].first,
relCol = col + figureInfo[direction][0].second;
gameGridPtr->DrawSquare(graphics, relRow, relCol,
Black, color, offsetSize);
}
{ int relRow = row + figureInfo[direction][1].first,
relCol = col + figureInfo[direction][1].second;
gameGridPtr->DrawSquare(graphics, relRow, relCol,
Black, color, offsetSize);
}
{ int relRow = row + figureInfo[direction][2].first,
relCol = col + figureInfo[direction][2].second;
gameGridPtr->DrawSquare(graphics, relRow, relCol,
Black, color, offsetSize);
}
}
红色图形
红色图形是一个大正方形,由四个较小的规则正方形组成。它是游戏中最简单的图形,因为它在旋转时不会改变形状。这意味着我们只需要查看一个图形,如下所示:
这也意味着只需要定义一个方向的方块就足够了,这样就可以定义图形在所有四个方向上的形状:
RedFigure.h
class RedFigure : public TetrisFigure {
public:
static IntPair GenericList[];
RedFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
RedFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "RedFigure.h"
IntPair RedFigure::GenericList[] =
{IntPair(0,1), IntPair(1,0), IntPair(1,1)};
RedFigure::RedFigure(Window* windowPtr, GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Red, 1, (Cols / 2) - 1,
North, GenericList, GenericList, GenericList,
GenericList) {
// Empty.
}
通用列表中的第一个整数对(rel row 0
,rel col 1
)表示标记方块的右侧方块,第二个整数对(rel row 1
,rel col 0
)表示标记方块的下方方块,第三个整数对(rel row 1
,rel col 1
)表示标记方块的下方和右侧方块。请注意,行向下增加,列向右增加。
棕色图形
棕色图形可以水平或垂直定向。它被初始化为垂直模式,因为它只能旋转到两个方向。北和南数组被初始化为垂直数组,而东和西数组被初始化为水平数组,如下图所示:
由于行号向下增加,列号向右增加,因此在垂直方向上最顶部的方块(以及水平方向上最左边的方块)由负值表示:
BrownFigure.h
class BrownFigure : public TetrisFigure {
public:
static IntPair HorizontalList[], VerticalList[];
BrownFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
BrownFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "BrownFigure.h"
IntPair BrownFigure::HorizontalList[] =
{IntPair(-1,0), IntPair(1,0), IntPair(2,0)},
BrownFigure::VerticalList[] =
{IntPair(0,-1), IntPair(0,1), IntPair(0,2)};
BrownFigure::BrownFigure(Window* windowPtr, GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Brown, 1, (Cols / 2) - 1,
North, HorizontalList, VerticalList,
HorizontalList, VerticalList) {
// Empty.
}
湖蓝色图形
与棕色图形类似,湖蓝色图形可以在垂直和水平方向旋转,如下图所示:
TurquoiseFigure.h
class TurquoiseFigure : public TetrisFigure {
public:
static IntPair HorizontalList[], VerticalList[];
TurquoiseFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
TurquoiseFigure cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "TurquoiseFigure.h"
IntPair TurquoiseFigure::HorizontalList[] =
{IntPair(-1,0), IntPair(0,1), IntPair(1,1)},
TurquoiseFigure::VerticalList[] =
{IntPair(1,-1), IntPair(1,0), IntPair(0,1)};
TurquoiseFigure::TurquoiseFigure(Window* windowPtr,
GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Turquoise, 1, (Cols/2) - 1,
North, HorizontalList, VerticalList,
HorizontalList, VerticalList) {
// Empty.
}
绿色图形
绿色图形相对于湖蓝色图形是镜像的,如下所示:
GreenFigure.h
class GreenFigure : public TetrisFigure {
public:
static IntPair HorizontalList[], VerticalList[];
GreenFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
GreenFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "GreenFigure.h"
IntPair GreenFigure::HorizontalList[] =
{IntPair(1,-1), IntPair(0,-1), IntPair(-1,0)}, GreenFigure::VerticalList[] =
{IntPair(0,-1), IntPair(1,0), IntPair(1,1)};
GreenFigure::GreenFigure(Window* windowPtr, GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Green, 1, Cols / 2,
North, HorizontalList, VerticalList,
HorizontalList, VerticalList) {
// Empty.
}
黄色图形
黄色图形可以在北、东、南、西方向旋转。它被初始化为南方,如下图所示:
YellowFigure.h
class YellowFigure : public TetrisFigure {
public:
static IntPair NorthList[], EastList[],
SouthList[], WestList[];
YellowFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
YellowFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "YellowFigure.h"
IntPair YellowFigure::NorthList[] =
{IntPair(0,-1), IntPair(-1,0), IntPair(0,1)},
YellowFigure::EastList[] =
{IntPair(-1,0),IntPair(0,1),IntPair(1,0)},
YellowFigure::SouthList[] =
{IntPair(0,-1),IntPair(1,0),IntPair(0,1)},
YellowFigure::WestList[] =
{IntPair(-1,0),IntPair(0,-1),IntPair(1,0)};
YellowFigure::YellowFigure(Window* windowPtr,
GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Yellow, 1, (Cols / 2) - 1,
South, NorthList, EastList, SouthList, WestList) {
// Empty.
}
黄色图形
蓝色图形也可以在四个方向上定向。它被初始化为南方,如下图所示:
BlueFigure.h
class BlueFigure : public TetrisFigure {
public:
static IntPair NorthList[], EastList[],
SouthList[], WestList[];
BlueFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
BlueFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "BlueFigure.h"
IntPair BlueFigure::NorthList[] =
{IntPair(0,-2),IntPair(0,-1),IntPair(-1,0)},
BlueFigure::EastList[] =
{IntPair(-2,0), IntPair(-1,0), IntPair(0,1)},
BlueFigure::SouthList[] =
{IntPair(1,0), IntPair(0,1), IntPair(0,2)},
BlueFigure::WestList[] =
{IntPair(0,-1), IntPair(1,0), IntPair(2,0)};
BlueFigure::BlueFigure(Window* windowPtr, GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Blue, 1, (Cols / 2) - 1,
South, NorthList, EastList, SouthList, WestList) {
// Empty.
}
紫色图形
最后,紫色图形相对于蓝色图形是镜像的,并且也初始化为南方,如下图所示:
PurpleFigure.h
class PurpleFigure : public TetrisFigure {
public:
static IntPair NorthList[], EastList[],
SouthList[], WestList[];
PurpleFigure(Window* windowPtr, GameGrid* gameGridPtr);
};
PurpleFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
#include "TetrisFigure.h"
#include "PurpleFigure.h"
IntPair PurpleFigure::NorthList[] =
{IntPair(-1,0),IntPair(0,1),IntPair(0,2)},
PurpleFigure::EastList[] =
{IntPair(1,0), IntPair(2,0), IntPair(0,1)},
PurpleFigure::SouthList[] =
{IntPair(0,-2),IntPair(0,-1),IntPair(1,0)},
PurpleFigure::WestList[] =
{IntPair(0,-1),IntPair(-2,0),IntPair(-1,0)};
PurpleFigure::PurpleFigure(Window* windowPtr,
GameGrid* gameGridPtr)
:TetrisFigure(windowPtr, gameGridPtr, Purple, 1, Cols / 2, South,
NorthList, EastList, SouthList, WestList) {
// Empty.
}
GameGrid 类
最后,GameGrid
类相当简单。它跟踪游戏板上的方块。gridArea
字段是占据总客户端区域的部分:
GameGrid.h
const int Rows = 20, Cols = 10;
class GameGrid {
public:
GameGrid(Rect gridArea);
void ClearGameGrid();
Color* operator[](int row) {return gameGrid[row];}
void InvalidateSquare(Window* windowPtr, int row,
int col, Size offsetSize);
void DrawGameGrid(Graphics& graphics, bool inverse) const;
void DrawSquare(Graphics& graphics, int row, int col,
Color penColor, Color brushColor,
Size offsetSize = ZeroSize) const;
Rect GridArea() const {return gridArea;}
private:
Rect gridArea;
Color gameGrid[Rows][Cols];
};
当由TetrisWindow
构造函数调用时,网格区域将被设置为(0, 20, 100, 100)单位,将其放置在窗口客户端区域的底部 80%:
GameGrid.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "GameGrid.h"
GameGrid::GameGrid(Rect gridArea)
:gridArea(gridArea) {
ClearGameGrid();
}
当清除网格时,我们实际上将每个正方形设置为白色:
void GameGrid::ClearGameGrid () {
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
gameGrid[row][col] = White;
}
}
}
无效化和绘制正方形
DrawGameGrid
遍历网格中的方块。白色方块被白色边框包围,而其他颜色的方块被黑色边框包围。如果inverseColor
参数为真,则在绘制之前反转方块颜色。这在闪烁行时很有用:
void GameGrid::DrawGameGrid(Graphics& graphics, bool inverse)
const {
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
Color squareColor = gameGrid[row][col];
Color penColor = (squareColor == White) ? White : Black;
Color brushColor = inverse ? squareColor.Inverse()
: squareColor;
DrawSquare(graphics, row, col, penColor, brushColor);
}
}
}
注意,InvalidateSquare
和 DrawSquare
方法添加了一个偏移量。在所有情况下都是零,除非在TetrisWindow
类中无效化或绘制下一个图形。两种方法都计算网格的行和列的大小,并定义无效化或绘制的正方形区域:
void GameGrid::InvalidateSquare(Window* windowPtr, int row,
int col, Size offsetSize) {
int colWidth = gridArea.Width() / Cols,
rowHeight = gridArea.Height() / Rows;
Rect squareArea(col * colWidth, row * rowHeight,
(col + 1) * colWidth, (row + 1) * rowHeight);
windowPtr->Invalidate(gridArea.TopLeft() + squareArea +
offsetSize);
}
void GameGrid::DrawSquare(Graphics& graphics, int row, int col,
Color penColor, Color brushColor,
Size offsetSize /* = ZeroSize */) const{
int colWidth = gridArea.Width() / Cols,
rowHeight = gridArea.Height() / Rows;
Rect squareArea (col * colWidth, row * rowHeight,
(col + 1) * colWidth, (row + 1) * rowHeight);
graphics.FillRectangle(gridArea.TopLeft() + squareArea +
offsetSize, penColor, brushColor);
}
摘要
在本章中,我们开发了一个俄罗斯方块游戏。你了解了时间控制和随机化,以及一个新的坐标系,更高级的绘图方法,如何捕捉键盘事件,以及如何编写文本。
在第四章,处理形状和图形中,我们将开发一个能够绘制线条、箭头、矩形和椭圆的绘图程序。
第四章。与形状和图形一起工作
在本章中,我们开发了一个能够绘制线条、箭头、矩形和椭圆的程序。该应用可以被视为圆应用的更高级版本。类似于圆应用,我们有一个图形列表,并捕获用户的鼠标动作。然而,这里有四种不同的图形:线条、箭头、矩形和椭圆。它们定义在一个类似于但比俄罗斯方块游戏中的层次结构更高级的类层次结构中。此外,我们还引入了剪切、复制、粘贴、光标控制和注册处理:
用户可以添加新的图形,移动一个或多个图形,通过抓取图形的端点修改图形,通过按鼠标按钮和 Ctrl 键标记和取消标记图形,并通过矩形包围多个图形来标记多个图形。当一个图形被标记时,它会被小黑方块标注。用户可以通过抓取其中一个方块来修改图形的形状。用户还可以通过抓取图形的其他部分来移动图形。
MainWindow 函数
本应用中的 MainWindow
函数与 第三章 中的相似,构建俄罗斯方块应用;它设置应用程序名称并创建主文档窗口:
#include "..\\SmallWindows\\SmallWindows.h"
#include "DrawFigure.h"
#include "LineFigure.h"
#include "ArrowFigure.h"
#include "RectangleFigure.h"
#include "EllipseFigure.h"
#include "TextFigure.h"
#include "DrawDocument.h"
void MainWindow(vector<String> /* argumentList */,
WindowShow windowShow) {
Application::ApplicationName() = TEXT("DrawFigure");
Application::MainWindowPtr() = new DrawDocument(windowShow);
}
DrawDocument 类
DrawDocument
类扩展了 StandardDocument
框架,类似于圆应用。它捕获鼠标事件,重写文件方法,实现剪切、复制和粘贴,以及光标处理:
DrawDocument.h
class DrawDocument : public StandardDocument {
public:
DrawDocument(WindowShow windowShow);
~DrawDocument();
与圆应用类似,我们使用 OnMouseDown
、OnMouseMove
和 OnMouseUp
方法捕获鼠标动作。然而,在这个应用中,我们还使用 OnDoubleClick
方法捕获双击。当用户双击一个图形时,它将执行单独的操作:
void OnMouseDown(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnMouseMove(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnDoubleClick(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnMouseUp(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
当窗口的客户区域需要重绘时调用 OnDraw
方法。它绘制图形,以及如果用户正在用矩形标记图形,则绘制包围图形的矩形:
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
当用户选择 新建 菜单项时调用 ClearDocument
方法,当用户选择 打开 菜单项时调用 ReadDocumentFromStream
方法,当用户选择 保存 或 另存为 菜单项时调用 WriteDocumentToStream
方法:
void ClearDocument();
bool WriteDocumentToStream(String name, ostream& outstream)
const;
每个图形都有一个整数标识值,该值由 WriteDocumentToStream
方法写入并由 ReadDocumentFromStream
方法读取,以决定哪个图形需要被创建。给定标识值,CreateFigure
方法创建新的图形:
bool ReadDocumentFromStream(String name, istream& instream);
DrawFigure* CreateFigure(FigureId figureId) const;
在这个应用程序中,我们引入了剪切、复制和粘贴的功能。当用户在 编辑 菜单中选择 剪切 或 复制 菜单项时,调用 CopyGeneric
方法;当用户选择 粘贴 菜单项时,调用 PasteGeneric
方法。在 StandardDocument
框架中,还有用于剪切、复制和粘贴 ASCII 和 Unicode 文本的方法。然而,我们在这个应用程序中没有使用它们:
bool IsCopyGenericReady(int /* format */) const;
void CopyGeneric(int format, InfoList& infoList) const;
void PasteGeneric(int format, InfoList& infoList);
CopyEnable
方法返回 true
如果信息已准备好复制。在这种情况下,剪切、复制和删除菜单项被启用。在这个应用程序中,我们没有重写 PasteEnable
方法,因为 StandardDocument
框架会查找全局剪贴板中是否有适合粘贴的内存缓冲区。当用户选择 删除 菜单项时,调用 OnDelete
方法:
bool CopyEnable() const;
void OnDelete();
与圆形应用程序类似,我们有一组监听器,尽管在这个情况下集合更大。每个监听器都在构造函数中添加到菜单中。与圆形应用程序不同,我们还使用了启用方法:在菜单项变得可见之前被调用的方法。如果方法返回 false
,菜单项将变为禁用并变灰。如果菜单项连接到加速器,加速器也将变为禁用。我们将 修改、颜色和填充项放在 修改 菜单中,将 线、箭头、矩形和椭圆项放在 添加 菜单中:
DEFINE_BOOL_LISTENER(DrawDocument, ModifyEnable)
DEFINE_BOOL_LISTENER(DrawDocument, ModifyRadio)
DEFINE_VOID_LISTENER(DrawDocument, OnModify)
DEFINE_BOOL_LISTENER(DrawDocument, ColorEnable)
DEFINE_VOID_LISTENER(DrawDocument, OnColor)
DEFINE_BOOL_LISTENER(DrawDocument, FillEnable)
DEFINE_BOOL_LISTENER(DrawDocument, FillCheck)
DEFINE_VOID_LISTENER(DrawDocument, OnFill)
DEFINE_BOOL_LISTENER(DrawDocument, LineEnable);
DEFINE_BOOL_LISTENER(DrawDocument, ArrowEnable);
DEFINE_BOOL_LISTENER(DrawDocument, RectangleEnable);
DEFINE_BOOL_LISTENER(DrawDocument, EllipseEnable);
DEFINE_BOOL_LISTENER(DrawDocument, LineRadio);
DEFINE_BOOL_LISTENER(DrawDocument, ArrowRadio);
DEFINE_BOOL_LISTENER(DrawDocument, RectangleRadio);
DEFINE_BOOL_LISTENER(DrawDocument, EllipseRadio);
DEFINE_VOID_LISTENER(DrawDocument, OnLine);
DEFINE_VOID_LISTENER(DrawDocument, OnArrow);
DEFINE_VOID_LISTENER(DrawDocument, OnRectangle);
DEFINE_VOID_LISTENER(DrawDocument, OnEllipse);
在这个应用程序中,我们还引入了光标控制。UpdateCursor
方法根据用户是在创建、修改还是移动图形来设置光标的外观:
void UpdateCursor();
这个应用程序的一个中心点是它的模式:applicationMode
方法跟踪用户按下左鼠标按钮时的动作。它保持以下模式:
-
Idle
: 应用程序等待用户的输入。只要用户没有按下左鼠标按钮,这始终是模式。然而,当用户按下鼠标按钮,直到他们释放它,applicationMode
方法保持一个值。用户按下 Ctrl 键并点击一个已经标记的图形。图形变为未标记,没有其他操作发生。 -
ModifySingle
: 用户抓取一个正在修改的单一图形(如果用户点击其端点之一)或移动的图形(如果用户点击图形的任何其他部分)。 -
ModifyRectangle
: 用户在客户端区域点击而没有击中任何图形,导致绘制了一个矩形。当用户释放鼠标按钮时,矩形完全包围的每个图形都会被标记。 -
MoveMultiple
: 用户按下 Ctrl 键并点击一个未标记的图形。同时修改多个图形是不可能的。
注意,applicationMode
方法仅在用户按下左鼠标按钮时相关。一旦他们释放鼠标按钮,applicationMode
方法始终是 Idle
:
private:
enum ApplicationMode {Idle, ModifySingle,
MoveMultiple, ModifyRectangle};
ApplicationMode applicationMode = Idle;
当applicationMode
方法保持Idle
模式时,应用程序等待用户进一步的输入。actionMode
字段定义下一个动作,它可以持有以下值:
-
Modify
:当用户按下鼠标按钮时,如果他们点击一个图形,则applicationMode
方法设置为ModifySingle
模式;如果他们在按下Ctrl键的同时点击一个未标记的图形,则设置为MoveMultiple
模式;如果图形已经被标记,则设置为Idle
模式;如果他们点击客户端区域而没有击中图形,则设置为ModifyRectangle
模式。 -
Add
:当用户按下鼠标左键时,无论该位置是否已有图形,都会在该位置创建一个新的图形。addFigureId
方法的值决定应该添加哪种类型的图形;它可以持有LineId
、ArrowId
、RectangleId
或EllipseId
中的任何值。
enum ActionMode {Modify, Add};
ActionMode actionMode = Add;
FigureId addFigureId = LineId;
在本章的后面部分,我们将遇到在修改模式和在添加模式之类的表达式,它们指的是actionMode
变量的值:Modify
或Add
。
nextColor
和nextFill
字段分别存储下一个图形的颜色和填充状态(在矩形或椭圆的情况下):
Color nextColor;
bool nextFill;
与圆的应用类似,当用户添加或修改一个图形时,我们需要在prevMousePoint
方法中存储之前的鼠标位置,以便跟踪鼠标自上次鼠标操作以来移动的距离:
Point prevMousePoint;
当applicationMode
方法保持ModifySingle
值时,正在修改的图形始终放置在图形指针列表的起始位置(figurePtrList[0]
),以便它出现在图形之上。当applicationMode
方法保持ModifyRectangle
模式时,insideRectangle
方法跟踪包围图形的矩形:
Rect insideRectangle;
static DrawFormat
常量用于标识要在全局剪贴板中剪切、复制或粘贴的数据。它被任意设置为 1000:
static const unsigned int DrawFormat = 1000;
随着用户从绘图添加和删除图形,图形会动态创建和删除;它们的地址存储在figurePtrList
列表中。DynamicList
类是一个 Small Windows 类,它是 C++标准类list
和vector
的更高级版本。
图形列表的值是指向DrawFigure
类的指针,这是在本应用中使用的图形层次结构的根类(在第五章中描述,图形层次结构)。与前面章节中的圆和俄罗斯方块应用不同,我们不是直接在列表中存储图形对象,而是它们的指针。这是必要的,因为我们使用具有纯虚方法的类层次结构,这使得DrawWindow
类成为抽象的,不能直接存储在列表中。这也为了利用类层次结构的动态绑定:
DynamicList<DrawFigure*> figurePtrList;
};
应用程序模式
本节进一步描述了applicationMode
字段。它与鼠标输入周期紧密相关。当用户没有按下左鼠标按钮时,applicationMode
方法始终处于Idle
模式。当用户在修改模式下按下左鼠标按钮时,他们可以选择同时按下Ctrl键:
-
如果他们没有按下Ctrl键,当点击图形时,
applicationMode
方法会被设置为ModifySingle
模式。该图形被标记,其他图形变为未标记。 -
如果他们按下Ctrl键,当点击一个未标记的图形时,
applicationMode
方法会被设置为MoveMultiple
模式;如果点击的是一个已标记的图形,则设置为Idle
模式。图形在被标记时变为未标记,反之亦然。其他图形不受影响。 -
如果他们没有点击图形,无论是否按下Ctrl键,
applicationMode
方法都会设置为ModifyRectangle
模式,并且内部矩形(insideRectangle
)正在初始化。所有图形都变为未标记。当用户释放左鼠标按钮时,所有完全被矩形包围的图形都会被标记。
当用户在修改模式下按下左鼠标按钮并移动鼠标时,需要考虑applicationMode
方法的四种可能值:
-
Idle
:我们不进行任何操作。 -
ModifySingle
:我们对单个图形调用Modify
方法。这可能导致用户点击的图形被修改或移动,具体取决于用户点击图形的位置。 -
MoveMultiple
:我们对所有标记的图形调用Move
方法。这总是导致标记的图形被移动,而不是被修改。 -
ModifyRectangle
:我们修改内部矩形。
最后,当用户释放左鼠标按钮时,我们再次查看applicationMode
方法的四种模式:
-
Idle
、ModifySingle
或MoveMultiple
:由于用户移动鼠标时已经完成了一切,所以我们不做任何操作。标记的图形已经被移动或修改。 -
ModifyRectangle
:我们标记所有完全被矩形包围的图形。
动态列表类
在本章中,我们使用了辅助DynamicList
类的方法子集。它包含了一组接受回调函数的方法,即作为参数传递给方法并由方法调用的函数:
template <class Type>
class DynamicList {
public:
IfFuncPtr
和DoFuncPtr
是指向回调函数的指针。它们之间的区别在于,IfFuncPtr
指针旨在用于仅检查列表值的函数。因此,value
参数是常量。DoFuncPtr
指针旨在用于修改值的函数。因此,value
参数不是常量:
typedef bool (*IfFuncPtr) (const Type& value, void* voidPtr);
typedef void (*DoFuncPtr) (Type& value, void* voidPtr);
AnyOf
方法接受 ifFuncPtr
指针并将其应用于数组的每个值。如果至少有一个值满足 ifFunctPtr
指针(如果 ifFuncPtr
指针对值返回 true
),则方法返回 true
。ifVoidPtr
参数作为 ifFuncPtr
指针的第二个参数发送:
bool AnyOf(IfFuncPtr ifFuncPtr, void* ifVoidPtr = nullptr)
const;
FirstOf
方法如果至少有一个值满足 ifFuncPtr
指针,也会返回 true
。在这种情况下,满足条件的第一个值被复制到 value
参数:
bool FirstOf(IfFuncPtr ifFuncPtr,Type& value,
void* ifVoidPtr = nullptr) const;
Apply
方法调用 doFunctPtr
指针到列表中的每个值。ApplyIf
方法调用 doFunctPtr
指针到所有满足 ifFuncPtr
指针的值:
void Apply(DoFuncPtr doFuncPtr, void* ifVoidPtr = nullptr);
void ApplyIf(IfFuncPtr ifFuncPtr, DoFuncPtr doFuncPtr,
void* ifVoidPtr = nullptr,
void* doVoidPtr = nullptr);
CopyIf
方法将满足 ifFuncPtr
指针的值复制到 copyArray
方法中。RemoveIf
方法移除满足 ifFuncPtr
指针的每个值:
void CopyIf(IfFuncPtr ifFuncPtr, DynamicList& copyArray,
void* ifVoidPtr = nullptr) const;
void RemoveIf(IfFuncPtr ifFuncPtr, void* ifVoidPtr = nullptr);
ApplyRemoveIf
方法调用 doFuncPtr
指针,然后移除满足 ifFuncPtr
指针的每个值,这在我们需要从列表中释放和移除指针时非常有用:
void ApplyRemoveIf(IfFuncPtr ifFuncPtr, DoFuncPtr doFuncPtr,
void* ifVoidPtr = nullptr, void* doVoidPtr=nullptr);
};
初始化
DrawDocument
类的构造函数与 CircleDocument
类的构造函数类似。我们使用 US 字号大小的 LogicalWithScroll
坐标系。文件描述 Draw Files
和后缀 drw
用于在打开和保存对话框中过滤绘图文件。空指针表示文档没有父窗口,而 false
参数表示在 文件 菜单中省略了 打印 和 打印预览 项。最后,包含 DrawFormat
参数的初始化列表表示用于标识要复制和粘贴的数据的格式。在这种情况下,我们为复制和粘贴使用相同的格式:
DrawDocument.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "DrawFigure.h"
#include "LineFigure.h"
#include "ArrowFigure.h"
#include "RectangleFigure.h"
#include "EllipseFigure.h"
#include "TextFigure.h"
#include "DrawDocument.h"
DrawDocument::DrawDocument(WindowShow windowShow)
:StandardDocument(LogicalWithScroll, USLetterPortrait,
TEXT("Draw Files, drw"), nullptr,
OverlappedWindow, windowShow,
{DrawFormat}, {DrawFormat}) {
由于我们扩展了 StandardDocument
框架,窗口具有标准菜单栏,其中 文件 菜单包含 新建、打开、保存、另存为 和 退出(由于构造函数调用中的 false
参数,省略了 打印 和 打印预览 项),编辑 菜单包含 剪切、复制、粘贴 和 删除,以及 帮助 项和 关于。
我们还添加了两个特定于应用程序的菜单:格式 和 添加。格式 菜单包含 修改、颜色 和 填充 菜单项。类似于圆形应用程序,我们使用助记符和快捷键标记菜单项。然而,我们还将启用参数;在菜单项可见之前调用 ModifyEnable
、ColorEnable
和 FillEnable
方法。如果它们返回 false
,则菜单项被禁用并变灰:
Menu menuBar(this);
menuBar.AddMenu(StandardFileMenu(false));
menuBar.AddMenu(StandardEditMenu());
Menu formatMenu(this, TEXT("F&ormat"));
formatMenu.AddItem(TEXT("&Modify\tCtrl+M"), OnModify,
ModifyEnable, nullptr, ModifyRadio);
formatMenu.AddItem(TEXT("&Color\tAlt+C"), OnColor, ColorEnable);
formatMenu.AddItem(TEXT("F&ill\tCtrl+I"), OnFill, FillEnable
FillCheck, nullptr);
menuBar.AddMenu(formatMenu);
添加 菜单为要添加的每种图形类型包含一个项:
Menu addMenu(this, TEXT("&Add"));
addMenu.AddItem(TEXT("&Line\tCtrl+L"), OnLine,
LineEnable, nullptr, LineRadio);
addMenu.AddItem(TEXT("&Arrow\tAlt+A"), OnArrow,
ArrowEnable, nullptr, ArrowRadio);
addMenu.AddItem(TEXT("&Rectangle\tCtrl+R"), OnRectangle,
RectangleEnable, nullptr, RectangleRadio);
addMenu.AddItem(TEXT("&Ellipse\tCtrl+E"), OnEllipse,
EllipseEnable, nullptr, EllipseRadio);
menuBar.AddMenu(addMenu);
menuBar.AddMenu(StandardHelpMenu());
SetMenuBar(menuBar);
最后,我们从Windows 注册表中读取值,这是 Windows 系统中我们可以用来在应用程序执行之间存储值的数据库。Small Windows 辅助类Color
、Font
、Point
、Size
和Rect
都有自己的注册方法。Small Windows 的Registry
类包含用于读取和写入文本以及数值和整数的静态方法:
actionMode = (ActionMode)
Registry::ReadInteger(TEXT("actionMode"), Modify);
addFigureId = (FigureId)
Registry::ReadInteger(TEXT("addFigureId"), LineId);
nextColor.ReadColorFromRegistry(TEXT("nextColor"));
nextFill = Registry::ReadBoolean(TEXT("nextFill"), false);
}
析构函数将值写入注册表。在这个应用程序中,不需要提供任何常见的析构函数操作,例如释放内存或关闭文件:
DrawDocument::~DrawDocument() {
Registry::WriteInteger(TEXT("actionMode"), actionMode);
Registry::WriteInteger(TEXT("addFigureId "), addFigureId);
nextColor.WriteColorToRegistry(TEXT("nextColor"));
Registry::WriteBoolean(TEXT("nextFill"), nextFill);
}
鼠标输入
IsFigureMarked
、IsFigureClicked
和UnmarkFigure
是DynamicList
方法AnyOf
、FirstOf
、CopyIf
、ApplyIf
和ApplyRemoveIf
调用的回调函数。这些方法接受图形的指针和一个可选的 void 指针,该指针包含附加信息。
IsFigureMarked
函数如果图形被标记则返回true
,IsFigureClicked
函数如果给定的voidPtr
指针中的鼠标点击图形则返回true
,如果图形被标记,IsFigureClicked
函数会取消标记图形。如您所见,IsFigureMarked
函数被定义为 lambda 函数,而IsFigureClicked
函数被定义为常规函数。
这没有合理的理由,除了我想展示定义函数的两种方式:
auto IsFigureMarked = [](DrawFigure* const& figurePtr,
void* /* voidPtr */) {
return figurePtr->IsMarked();
};
bool IsFigureClicked(DrawFigure* const& figurePtr, void* voidPtr) {
Point* mousePointPtr = (Point*) voidPtr;
return figurePtr->IsClick(*mousePointPtr);
}
void UnmarkFigure(DrawFigure*& figurePtr, void* /* voidPtr */) {
if (figurePtr->IsMarked()) {
figurePtr->Mark(false);
}
}
在OnMouseDown
方法中,我们首先检查用户是否按下鼠标左键。如果是这样,我们将鼠标位置保存到prevMousePoint
字段中,以便我们可以在后续调用OnMouseMove
方法时计算图形移动的距离:
void DrawDocument::OnMouseDown(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
prevMousePoint = mousePoint;
如前所述,鼠标点击的结果取决于actionMode
方法值的差异。在Modify
方法的情况下,我们在图形指针列表上调用FirstOf
参数以提取第一个点击的图形。图形可以重叠,点击可能击中多个图形。在这种情况下,我们希望列表开头的最上面的图形。如果至少有一个点击的图形,FirstOf
方法返回true
,并将其复制到topClickedFigurePtr
引用参数中。mousePoint
方法的地址作为FirstOf
方法的第二个参数给出,并将其作为第二个参数传递给IsFigureClicked
函数:
switch (actionMode) {
case Modify: {
DrawFigure* topClickedFigurePtr;
if (figurePtrList.FirstOf(IsFigureClicked,
topClickedFigurePtr, &mousePoint)) {
我们需要考虑两种情况,这取决于用户是否按下Ctrl键。如果这样做,如果图形未被标记,则将其标记,反之亦然,并且其他标记的图形将保持标记。
然而,在另一种情况下,当用户没有按下Ctrl键时,无论图形是否已经标记,图形都会被标记,所有其他标记的图形都会取消标记,并且应用程序设置为ModifySingle
模式。图形从列表中移除并插入到列表的开始(前端),以便出现在绘图的最上面:
if (!controlPressed) {
figurePtrList.ApplyIf(IsFigureMarked, UnmarkFigure);
topClickedFigurePtr->Mark(true);
applicationMode = ModifySingle;
int topFigureIndex =
figurePtrList.IndexOf(topClickedFigurePtr);
figurePtrList.Erase(topFigureIndex);
figurePtrList.PushFront(topClickedFigurePtr);
}
如果用户按下 Ctrl 键,我们还有另外两种情况。如果点击的图形已经被标记,我们将取消标记它并将 applicationMode
方法设置为 Idle
模式。如果点击的图形尚未标记,我们将标记它并将 applicationMode
方法设置为 MoveMultiple
模式。这样,在用户移动鼠标时,OnMouseMove
方法中至少有一个标记的图形要移动。请注意,如果用户按下 Ctrl 键,一个或多个图形可以移动但不能修改。同时修改多个图形是不合逻辑的:
else {
if (topClickedFigurePtr->IsMarked()) {
applicationMode = Idle;
topClickedFigurePtr->Mark(false);
}
else {
applicationMode = MoveMultiple;
topClickedFigurePtr->Mark(true);
}
}
}
如果用户到达一个没有图形的位置(figurePtrList.FirstOf
方法返回 false
),我们将取消所有标记的图形,初始化 insideRectangle
方法,并将 applicationMode
方法设置为 ModifyRectangle
模式。
else {
figurePtrList.ApplyIf(IsFigureMarked, UnmarkFigure);
insideRectangle = Rect(mousePoint, mousePoint);
applicationMode = ModifyRectangle;
}
}
break;
在此方法中提到的所有上述情况都发生在 actionMode
方法为 Modify
时。然而,它也可以是 Add
,在这种情况下,将在绘图中新添加一个图形。我们使用 addFigureId
方法在调用 CreateFigure
方法时决定添加哪种类型的图形。我们设置脏标志,因为我们已经添加了一个图形,文档已经被修改。最后,我们将新图形的地址添加到图形列表的开头(这样它就会出现在顶部),并将 applicationMode
方法设置为 ModifySingle
模式:
case Add: {
DrawFigure* newFigurePtr = CreateFigure(addFigureId);
newFigurePtr->SetColor(nextColor);
newFigurePtr->Fill(nextFill);
newFigurePtr->SetFirstPoint(mousePoint);
SetDirty(true);
figurePtrList.PushFront(newFigurePtr);
applicationMode = ModifySingle;
}
break;
}
根据操作和模式,窗口和光标可能需要更新:
UpdateWindow();
UpdateCursor();
}
}
MoveMarkFigure
方法是一个回调函数,它在 OnMouseMove
方法中由 figurePtrList
的 Apply
方法调用。它移动标记的图形。移动距离的地址在 voidPtr
参数中给出:
void MoveMarkedFigure(DrawFigure*& figurePtr, void* voidPtr) {
if (figurePtr->IsMarked()) {
figurePtr->Invalidate();
Size* distanzeSizePtr = (Size*) voidPtr;
figurePtr->Move(*distanzeSizePtr);
figurePtr->Invalidate();
}
}
在 OnMouseMove
方法中,我们首先计算自上次调用 OnMouseDown
或 OnMouseMove
方法以来的距离。我们还设置 prevMousePoint
方法为鼠标位置:
void DrawDocument::OnMouseMove(MouseButton mouseButtons,
Point mousePoint,bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
Size distanceSize = mousePoint - prevMousePoint;
prevMousePoint = mousePoint;
根据 applicationMode
方法的不同,我们执行不同的任务。在单个图形的 Modify
方法的情况下,我们在该图形上调用 MoveOrModify
方法。由于我们在 OnMouseDown
方法中将其放置在那里,该图形位于图形指针列表的开头(figurePtrList[0]
)。想法是图形本身,根据用户点击的位置,决定它是移动还是修改。图形的状态在用户点击时设置,并取决于他们是否点击了图形的任何端点:
switch (applicationMode) {
case ModifySingle:
figurePtrList[0]->Modify(distanceSize);
SetDirty(true);
break;
在多个移动的情况下,我们将每个标记的图形移动到上次鼠标消息的距离。请注意,我们不会像在单个情况下那样在多个情况下修改图形:
case MoveMultiple:
figurePtrList.Apply(MoveMarkedFigure, &distanceSize);
SetDirty(true);
break;
在矩形的情况下,我们设置其右下角并重新绘制它:
case ModifyRectangle:
Invalidate(insideRectangle);
insideRectangle.SetBottomRight(mousePoint);
Invalidate(insideRectangle);
UpdateWindow();
break;
}
UpdateWindow();
UpdateCursor();
}
}
IsFigureInside
和MarkFigure
方法是回调函数,在OnMouseUp
方法中由DynamicList
方法CopyIf
、RemoveIf
和Apply
在figurePtrList
上调用。如果图形位于给定的矩形内部,IsFigureInside
方法返回true
,而MarkFigure
方法只是标记图形:
bool IsFigureInside(DrawFigure* const& figurePtr, void* voidPtr) {
Rect* insideRectanglePtr = (Rect*) voidPtr;
return figurePtr->IsInside(*insideRectanglePtr);
}
void MarkFigure(DrawFigure*& figurePtr, void* /* voidPtr */) {
figurePtr->Mark(true);
}
在OnMouseUp
方法中,我们只需要考虑ModifyRectangle
情况。我们需要决定哪些图形完全被矩形包围。为了让它们出现在绘图的最上层,我们首先在figurePtrList
列表上调用CopyIf
方法,将完全位于矩形内部的图形临时复制到insideList
列表中。
然后我们从figurePtrList
列表中删除图形,并将它们从insideList
列表中插入到figurePtrList
列表的开头。这使得它们出现在绘图的最上层。最后,我们通过在insideList
列表上调用Apply
来标记矩形内的图形:
void DrawDocument::OnMouseUp(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
switch (applicationMode) {
case ModifyRectangle: {
insideRectangle.Normalize();
DynamicList<DrawFigure*> insideList;
figurePtrList.CopyIf(IsFigureInside, insideList,
&insideRectangle);
figurePtrList.RemoveIf(IsFigureInside,
&insideRectangle);
figurePtrList.PushFront(insideList);
insideList.Apply(MarkFigure);
Invalidate(insideRectangle);
insideRectangle.Clear();
UpdateWindow();
}
break;
}
在用户释放左鼠标按钮后,应用程序保持Idle
模式,只要用户不按下左鼠标按钮,它就会一直保持这种模式:
applicationMode = Idle;
}
}
当用户双击鼠标按钮时,会调用OnDoubleClick
方法。双击和连续两次点击之间的区别由 Windows 系统决定,可以在 Windows 控制面板中调整。在双击的情况下,在OnDoubleClick
方法之前会调用OnMouseDown
和OnMouseUp
方法。如果有的话,我们提取最顶部的点击图形,并调用DoubleClick
方法。结果取决于图形的类型:箭头的头部会反转,如果矩形或椭圆未被填充,则填充它们,反之亦然,而直线则不受影响:
void DrawDocument::OnDoubleClick(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if ((mouseButtons == LeftButton) && !controlPressed) {
DrawFigure* topClickedFigurePtr;
if (figurePtrList.FirstOf(IsFigureClicked,topClickedFigurePtr,
&mousePoint)) {
topClickedFigurePtr->DoubleClick(mousePoint);
}
}
}
绘制
在小窗口中,有三种常见的绘图方法:OnPaint
、OnPrint
和OnDraw
。Windows 系统分别间接调用OnPaint
和OnPrint
方法来绘制窗口或打印纸张,它们的默认行为是调用OnDraw
方法。记住,我们不会主动绘制窗口,我们只是等待正确的消息。这个想法是,在需要区分绘制和打印的情况下,我们重写OnPaint
和OnPrint
方法,而在不需要这种区分的情况下,我们重写OnDraw
方法。
在本书稍后讨论的文字处理器中,我们将探讨绘制和打印之间的区别。然而,在这个应用程序中,我们只是重写了OnDraw
方法。如第三章中所述,构建俄罗斯方块应用程序,框架创建了Graphics
类引用,可以被认为是一个配备了笔刷的工具箱。在这种情况下,我们只需使用Graphics
引用作为参数调用每个图的DrawFigure
方法。在ModifyRectangle
模式下,我们也绘制矩形:
void DrawDocument::OnDraw(Graphics& graphics,
DrawMode /* drawMode */) const {
int size = figurePtrList.Size();
for (int index = (size - 1); index >= 0; --index) {
DrawFigure* figurePtr := figurePtrList) {[index];
figurePtr->Draw(graphics);
}
if (applicationMode == ModifyRectangle) {
graphics.DrawRectangle(insideRectangle, Gray);
}
}
文件菜单
感谢StandardDocument
类中的框架,文件管理变得相当简单。当用户选择新建菜单项时,会调用ClearDocument
方法,我们只需删除图并清空图列表:
void DrawDocument::ClearDocument() {
for (DrawFigure* figurePtr : figurePtrList) {
delete figurePtr;
}
figurePtrList.Clear();
}
当用户选择保存或另存为菜单项时,会调用WriteDocumentToStream
方法。它首先写入图列表的大小,然后对于每个图,它写入其标识号(这在读取ReadDocumentFromStream
方法中显示的图时是必要的),然后通过调用其WriteFigureToStream
方法来写入图本身:
bool DrawDocument::WriteDocumentToStream(String name,
ostream& outStream)const{
int listSize = figurePtrList.Size();
outStream.write((char*) &listSize, sizeof listSize);
for (DrawFigure* figurePtr : figurePtrList) {
FigureId figureId = figurePtr->GetId();
outStream.write((char*) &figureId, sizeof figureId);
figurePtr->WriteFigureToStream(outStream);
}
return ((bool) outStream);
}
当用户选择打开菜单项时,会调用ReadDocumentFromStream
方法。它首先读取图列表中的图数量。我们需要读取下一个图的标识号,并调用CreateFigure
方法以获取创建的图的指针。然后,我们只需调用ReadFigureFromStream
方法来读取图,并将图的地址添加到图指针列表中:
bool DrawDocument::ReadDocumentFromStream(String name,
istream& inStream) {
int listSize;
inStream.read((char*) &listSize, sizeof listSize);
for (int index = 0; index < listSize; ++index) {
FigureId figureId;
inStream.read((char*) &figureId, sizeof figureId);
DrawFigure* figurePtr = CreateFigure(figureId);
figurePtr->ReadFigureFromStream(inStream);
figurePtrList.PushBack(figurePtr);
}
return ((bool) inStream);
}
ReadFigureFromStream
和ReadFigureFromClipboard
方法会调用CreateFigure
方法,并创建给定类型的图:
DrawFigure* DrawDocument::CreateFigure(FigureId figureId) const {
switch (figureId) {
case LineId:
return (new LineFigure(this));
case ArrowId:
return (new ArrowFigure(this));
case RectangleId:
return (new RectangleFigure(this));
case EllipseId:
return (new EllipseFigure(this));
}
return nullptr;
}
剪切、复制和粘贴
与上述文件管理案例类似,框架也负责剪切、复制和粘贴的细节。首先,我们需要决定何时启用剪切和复制菜单项和快捷键。在Modify
模式下,至少有一个图被标记就足够了。我们使用DynamicList
方法的AnyOf
来决定是否至少有一个图被标记。在Add
模式下,剪切或复制是不允许的。我们不需要重写CutEnable
方法,因为在StandardDocument
框架中,它的默认行为是调用CopyEnable
方法:
bool DrawDocument::CopyEnable() const {
if (applicationMode == Idle) {
switch (actionMode) {
case Modify:
return figurePtrList.AnyOf(IsFigureMarked);
case Add:
return false;
}
}
return false;
}
在StandardDocument
框架中有一个PasteEnable
方法。然而,在这个应用程序中我们不需要重写它,因为框架决定何时启用粘贴,或者更具体地说,当全局剪贴板上有在StandardDocument
构造函数中给出的格式代码的数据时,在这种情况下是DrawFormat
字段。全局剪贴板是一个 Windows 资源,用于存储已复制信息的短期存储。
CopyGeneric
方法接受一个字符列表,这些字符打算用应用程序特定的信息填充。我们保存标记图形的数量,并为每个标记的图形,我们写入其身份编号并调用WriteFigureToClipboard
方法,该方法将图形特定信息写入infoList
参数:
bool DrawDocument::IsCopyGenericReady(int /* format */) const {
return true;
}
void DrawDocument::CopyGeneric(int format, InfoList& infoList)
const {
DynamicList<DrawFigure*> markedList;
figurePtrList.CopyIf(IsFigureMarked, markedList);
infoList.AddValue<int>(markedList.Size());
for (DrawFigure* figurePtr : markedList) {
infoList.AddValue<FigureId>(figurePtr->GetId());
figurePtr->WriteFigureToClipboard(infoList);
}
}
PasteGeneric
方法以类似于前面提到的ReadDocumentFromStream
方法的方式粘贴图形:
void DrawDocument::PasteGeneric(int format, InfoList& infoList) {
figurePtrList.ApplyIf(IsFigureMarked, UnmarkFigure);
int pasteSize;
infoList.GetValue<int>(pasteSize);
for (int count = 0; count < pasteSize; ++count) {
FigureId figureId;
infoList.GetValue<FigureId>(figureId);
DrawFigure* figurePtr = CreateFigure(figureId);
figurePtr->ReadFigureFromClipboard(infoList);
figurePtr->Move(Size(1000, 1000));
figurePtrList.PushBack(figurePtr);
figurePtr->Mark(true);
}
UpdateWindow();
}
在StandardDocument
框架中有一个DeleteEnable
方法,我们不需要重写它,因为其默认行为是调用CopyEnable
方法。OnDelete
方法遍历图形列表,使标记的图形无效并删除它们。我们使用DynamicList
方法的ApplyRemoveIf
来删除和删除标记的图形。
我们不能简单地使用ApplyIf
和RemoveIf
方法来释放和删除图形,因为这会导致内存错误(悬挂指针):
void DeleteFigure(DrawFigure*& figurePtr, void* /* voidPtr */) {
figurePtr->Invalidate();
delete figurePtr;
}
void DrawDocument::OnDelete() {
figurePtrList.ApplyRemoveIf(IsFigureMarked, DeleteFigure,
nullptr, this);
UpdateWindow();
SetDirty(true);
}
修改菜单
修改菜单项操作起来非常简单。当应用程序处于空闲
模式时,它会启用,此时用户没有按下鼠标左键。如果actionMode
方法设置为修改
,则也会出现单选按钮,菜单项监听器只需将actionMode
方法设置为修改
:
bool DrawDocument::ModifyEnable() const {
return (applicationMode == Idle);
}
bool DrawDocument::ModifyRadio() const {
return ((applicationMode == Idle) && (actionMode == Modify));
}
void DrawDocument::OnModify() {
actionMode = Modify;
}
对于颜色和填充菜单项,有简单的启用方法,监听器则稍微复杂一些。在修改
模式下,如果至少有一个图形被标记,则可以更改颜色。在添加
模式下,始终可以更改颜色:
bool DrawDocument::ColorEnable() const {
if (applicationMode == Idle) {
switch (actionMode) {
case Modify:
return figurePtrList.AnyOf(IsFigureMarked);
case Add:
return true;
}
}
return false;
}
SetFigureColor
方法是一个回调函数,它在OnColor
方法中由figurePtrList
列表上的ApplyIf
方法调用:
void SetFigureColor(DrawFigure*& figurePtr, void* voidPtr) {
Color* colorPtr = (Color*) voidPtr;
if (figurePtr->IsMarked() &&
(figurePtr->GetColor() != *colorPtr)) {
figurePtr->SetColor(*colorPtr);
}
}
当用户选择颜色菜单项时,会调用OnColor
方法。在修改
模式下,我们提取标记的图形并选择其最上面的颜色。我们知道至少有一个图形被标记,否则前面的ColorEnable
方法会返回false
,颜色菜单项将被禁用。如果ColorDialog
调用返回true
,我们通过在figurePtrList
列表上调用ApplyIf
方法来设置所有标记图形的新颜色:
void DrawDocument::OnColor() {
switch (actionMode) {
case Modify: {
DynamicList<DrawFigure*> markedList;
figurePtrList.CopyIf(IsFigureMarked, markedList);
DrawFigure* topFigurePtr = markedList[0];
Color topColor = topFigurePtr->GetColor();
if (StandardDialog::ColorDialog(this, topColor)) {
nextColor = topColor;
figurePtrList.ApplyIf(IsFigureMarked, SetFigureColor,
nullptr, &topColor);
UpdateWindow();
SetDirty(true);
}
}
break;
如果actionMode
方法设置为添加
,我们只需显示一个颜色对话框来设置下一个颜色:
case Add:
StandardDialog::ColorDialog(this, nextColor);
break;
}
}
IsFigureMarkedAndFilled
方法是一个回调函数,它在FillCheck
方法中由figurePtrList
列表上的AnyOf
方法调用。如果至少有一个图形被标记并填充,则填充菜单项会通过单选标记进行检查:
bool IsFigureMarkedAndFilled(DrawFigure* const& figurePtr,
void* /* voidPtr */) {
return (figurePtr->IsMarked() && figurePtr->IsFilled());
}
bool DrawDocument::FillCheck() const {
if (applicationMode == Idle) {
switch (actionMode) {
case Modify:
return figurePtrList.AnyOf(IsFigureMarkedAndFilled);
case Add:
return nextFill;
}
}
return false;
}
IsFigureMarkedAndFillable
方法是一个回调函数,它在FillEnable
方法中由figurePtrList
列表上的AnyOf
方法调用。如果至少有一个可填充的图形(矩形或椭圆)被标记,或者如果用户即将添加矩形或椭圆,则填充菜单项将被启用:
bool IsFigureMarkedAndFillable(DrawFigure* const& figurePtr,
void* /* voidPtr */){
return (figurePtr->IsMarked() && figurePtr->IsFillable());
}
bool DrawDocument::FillEnable() const {
if (applicationMode == Idle) {
switch (actionMode) {
case Modify:
return figurePtrList.AnyOf(IsFigureMarkedAndFillable);
为了测试下一个要添加的图形类型是否可填充,我们创建并删除这样的图形:
case Add: {
DrawFigure* addFigurePtr = CreateFigure(addFigureId);
bool fillable = addFigurePtr->IsFillable();
delete addFigurePtr;
return fillable;
}
}
}
return false;
}
InverseFill
方法是一个回调函数,它在用户选择 填充 菜单项时,由 figurePtrList
列表中的 OnFill
方法调用,该方法在 Modify
模式下反转所有标记图形的填充状态。在 Add
模式下,它仅反转 nextFill
的值,表示下一个要添加的图形将具有反转的填充状态:
void InverseFill(DrawFigure*& figurePtr, void* /* voidPtr */) {
if (figurePtr->IsMarked()) {
figurePtr->Fill(!figurePtr->IsFilled());
}
}
void DrawDocument::OnFill() {
switch (actionMode) {
case Modify:
figurePtrList.ApplyIf(IsFigureMarked, InverseFill);
UpdateWindow();
break;
case Add:
nextFill = !nextFill;
break;
}
}
添加菜单
Add
菜单项的监听器相当直接。启用方法很简单,要使菜单项启用,只需 applicationMode
方法处于 Idle
模式即可:
bool DrawDocument::LineEnable() const {
return (applicationMode == Idle);
}
bool DrawDocument::ArrowEnable() const {
return (applicationMode == Idle);
}
bool DrawDocument::RectangleEnable() const {
return (applicationMode == Idle);
}
bool DrawDocument::EllipseEnable() const {
return (applicationMode == Idle);
}
在 Add
模式下,单选方法返回 true
如果要添加的图形与单选方法的图形匹配:
bool DrawDocument::LineRadio() const {
return ((actionMode == Add) && (addFigureId == LineId));
}
bool DrawDocument::ArrowRadio() const {
return ((actionMode == Add) && (addFigureId == ArrowId));
}
bool DrawDocument::RectangleRadio() const {
return ((actionMode == Add) && (addFigureId == RectangleId));
}
bool DrawDocument::EllipseRadio() const {
return ((actionMode == Add) && (addFigureId == EllipseId));
}
最后,响应菜单项和快捷键选择的方法将 actionMode
设置为 Add
并设置要添加的图形:
void DrawDocument::OnLine() {
actionMode = Add;
addFigureId = LineId;
}
void DrawDocument::OnArrow() {
actionMode = Add;
addFigureId = ArrowId;
}
void DrawDocument::OnRectangle() {
actionMode = Add;
addFigureId = RectangleId;
}
void DrawDocument::OnEllipse() {
actionMode = Add;
addFigureId = EllipseId;
}
光标
Cursor
类中的 Set
方法将光标设置为适当的值。如果应用程序模式是 Idle
模式,我们等待用户按下鼠标按钮。在这种情况下,我们使用众所周知的箭头光标图像。如果用户正在用矩形包围图形,我们使用十字准线。如果用户正在移动多个图形,我们使用带有四个箭头的光标(大小全部)。最后,如果他们正在修改单个图形,则该图形(其地址位于 figurePtrList[0]
列表中)本身决定使用哪个光标:
void DrawDocument::UpdateCursor() {
switch (applicationMode) {
case Idle:
Cursor::Set(Cursor::Arrow);
break;
case ModifyRectangle:
Cursor::Set(Cursor::Crosshair);
break;
case MoveMultiple:
Cursor::Set(Cursor::SizeAll);
break;
case ModifySingle:
Cursor::Set(figurePtrList[0]->GetCursor());
break;
}
}
概述
在本章中,你开始开发一个能够绘制线条、箭头、矩形和椭圆的绘图程序。在第五章,图形层次结构中,我们将探讨图形层次结构。
第五章. 图形层次结构
本章介绍了绘图程序的图形类。每个图形负责决定它是否被鼠标点击击中,或者它是否被矩形包围。它还负责移动或修改,以及绘制以及与文件流和剪贴板进行通信。
绘图图形层次结构由Draw
、LineFigure
、ArrowFigure
、RectangleFigure
和EllipseFigure
类组成,如下图所示:
DrawFigure 类
Draw
类是层次结构的根类,主要由虚拟方法和纯虚拟方法组成,旨在由子类重写。
虚拟方法和纯虚拟方法之间的区别是,虚拟方法有一个主体,并且可以被子类重写。如果子类重写了该方法,则调用其版本的方法。
如果子类没有重写方法,则调用基类的方法。纯虚拟方法通常没有主体,包含至少一个纯虚拟方法的类成为抽象类。子类可以选择重写其基类的所有纯虚拟方法或自己成为抽象类:
Draw.h
enum FigureId {LineId, ArrowId, RectangleId, EllipseId};
class DrawDocument;
class Draw {
public:
Draw(const Window* windowPtr);
每个图形都有自己的唯一编号,由GetId
方法返回:
virtual FigureId GetId() const = 0;
virtual void SetFirstPoint(Point point) = 0;
IsClick
方法如果鼠标点击图形则返回True
,如果图形完全被区域包围则IsInside
方法返回True
。DoubleClick
方法给图形一个执行特定于图形的操作的机会:
virtual bool IsClick(Point mousePoint) = 0;
virtual bool IsInside(Rect area) = 0;
virtual void DoubleClick(Point mousePoint) = 0;
Modify
和Move
方法只是简单地移动图形。然而,Modify
方法执行由IsClick
方法定义的特定于图形的操作。如果用户点击了图形的一个端点,它将被修改;如果他们点击了图形的任何其他部分,它将被移动:
virtual void Modify(Size distanceSize) = 0;
virtual void Move(Size distanceSize) = 0;
Invalidate
方法通过调用返回图形占据区域的Area
方法来使图形无效。Draw
方法使用给定的Graphics
类引用绘制图形:
virtual Rect Area() const = 0;
virtual void Draw(Graphics& graphics) const = 0;
void Invalidate() const {windowPtr->Invalidate(Area());}
IsFillable
、IsFilled
和Fill
方法仅由Rectangle
和Ellipse
方法重写:
virtual bool IsFillable() const {return false;}
virtual bool IsFilled() const {return false;}
virtual void Fill(bool fill) {/* Empty. */}
当用户打开或保存文档时调用WriteFigureToStream
和ReadFigureFromStream
方法。它们将图形的信息写入和从流中读取:
virtual bool WriteFigureToStream(ostream& outStream) const;
virtual bool ReadFigureFromStream(istream& inStream);
当用户复制或粘贴图形时调用WriteFigureToClipboard
和ReadFigureFromClipboard
方法。它们将信息写入字符列表并从字符缓冲区读取信息:
virtual void WriteFigureToClipboard(InfoList& infoList) const;
virtual void ReadFigureFromClipboard(InfoList& infoList);
color
和marked
字段有自己的获取和设置方法:
bool IsMarked() const {return marked;}
void Mark(bool mark);
Color GetColor() const {return color;}
void SetColor(Color color);
GetCursor
方法返回图形的正确光标:
virtual CursoTyper GetCursor() const = 0;
MarkRadius
方法表示显示图形被标记的小正方形的大小:
static const Size MarkRadius;
当无效化图形时使用windowPtr
指针:
private:
const Window* windowPtr;
每个图形,无论其类型如何,都有一个颜色,并且被标记或未标记:
Color color;
bool marked = false;
};
Draw.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Draw.h"
MarkRadius
参数设置为 100 * 100 单位,即 1 * 1 毫米:
const Size DrawFigure::MarkRadius(100, 100);
当创建图形时,它总是未标记的。
DrawFigure::Draw(const Window* windowPtr)
:windowPtr(windowPtr) {
// Empty.
}
当用户切换图形的标记状态时,我们会重新绘制。你可能注意到了if...else
语句中的不同顺序。原因是当我们标记一个图形时,它会变得更大;这就是为什么我们首先将marked
参数设置为True
,然后使图形无效以捕获包括标记在内的区域。另一方面,当我们取消标记图形时,它会变得更小;这就是为什么我们首先使图形无效以捕获包括标记在内的区域,然后设置marked
参数为False
。
void DrawFigure::Mark(bool mark) {
if (!marked && mark) {
marked = true;
Invalidate();
}
else if (marked && !mark) {
Invalidate();
marked = false;
}
}
颜色是文件处理和与剪贴板通信中唯一写入或读取的字段。DrawFigure
类的子类调用这些方法,然后写入和读取特定于图形的信息。WriteFigureToStream
和ReadFigureFromStream
方法返回流的布尔值,以指示文件操作是否成功。
bool DrawFigure::WriteFigureToStream(ostream& outStream) const {
color.WriteColorToStream(outStream);
return ((bool) outStream);
}
bool DrawFigure::ReadFigureFromStream(istream& inStream) {
color.ReadColorFromStream(inStream);
return ((bool) inStream);
}
void DrawFigure::WriteFigureToClipboard(InfoList& infoList) const{
color.WriteColorToClipboard(charList);
}
void DrawFigure::ReadFigureFromClipboard(InfoList& infoList) {
color.ReadColorFromClipboard(infoList);
}
LineFigure 类
两个点之间绘制线条,这些点由LineFigure
类中的firstPoint
字段到lastPoint
字段表示,如下面的图像所示:
header
文件覆盖了其DrawFigure
基类的一些方法。DoubleClick
方法不做任何事情。在我看来,对于线条的双击并没有真正有意义的响应。然而,我们仍然需要覆盖DoubleClick
方法,因为它是DrawFigure
基类中的一个纯虚方法。如果我们不覆盖它,LineFigure
类将变成抽象的。
LineFigure.h
class LineFigure : public DrawFigure {
public:
LineFigure(const Window* windowPtr);
virtual FigureId GetId() const {return LineId;}
virtual void SetFirstPoint(Point point);
virtual bool IsClick(Point mousePoint);
virtual bool IsInside(Rect rectangleArea);
virtual void DoubleClick(Point mousePoint) {/* Empty. */}
virtual void Modify(Size distanceSize);
virtual void Move(Size distanceSize);
virtual Rect Area() const;
virtual void Draw(Graphics& graphics) const;
virtual CursorType GetCursor() const;
virtual bool WriteFigureToStream(ostream& outStream) const;
virtual bool ReadFigureFromStream(istream& inStream);
virtual void WriteFigureToClipboard(InfoList& infoList) const;
virtual void ReadFigureFromClipboard(InfoList& infoList);
protected:
enum {CreateLine, FirstPoint, LastPoint, MoveLine} lineMode;
Point firstPoint, lastPoint;
static bool IsPointInLine(Point firstPoint, Point lastPoint,
Point point);
};
LineFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Draw.h"
#include "LineFigure.h"
LineFigure::LineFigure(const Window* windowPtr)
:Draw(windowPtr), lineMode(CreateLine) {
// Empty.
}
当创建线条时,会调用SetFirstPoint
方法,并设置第一个和最后一个点。
void LineFigure::SetFirstPoint(Point point) {
firstPoint = point;
lastPoint = point;
}
IsClick
方法有两个情况:用户必须击中端点之一或线条本身。我们定义了两个覆盖端点的正方形(firstSquare
和lastSquare
),并测试鼠标是否击中其中之一。如果没有,我们通过调用IsPointInLine
方法测试鼠标是否击中线条本身。
bool LineFigure::IsClick(Point mousePoint) {
Rect firstSquare(firstPoint - MarkRadius,
firstPoint + MarkRadius);
firstSquare.Normalize();
if (firstSquare.PointInside(mousePoint)) {
lineMode = FirstPoint;
return true;
}
Rect lastSquare(lastPoint - MarkRadius, lastPoint + MarkRadius);
lastSquare.Normalize();
if (lastSquare.PointInside(mousePoint)) {
lineMode = LastPoint;
return true;
}
if (IsPointInLine(firstPoint, lastPoint, mousePoint)) {
lineMode = MoveLine;
return true;
}
return false;
}
IsPointInLine
方法检查点是否位于线上,并有一定的容差。我们使用三角函数来计算点相对于线的位置。然而,如果线完全垂直且点的 x 坐标相同,我们有一个特殊情况。
应用三角函数会导致除以零。相反,我们创建一个围绕线的矩形,并检查点是否位于矩形内,如下面的图像所示:
bool LineFigure::IsPointInLine(Point firstPoint, Point lastPoint,
Point point) {
if (firstPoint.X() == lastPoint.X()) {
Rect lineRect(firstPoint - MarkRadius,
lastPoint + MarkRadius);
lineRect.Normalize();
return lineRect.PointInside(point);
}
如果线条不是垂直的,我们首先创建一个包围矩形并测试鼠标点是否在其中。如果是,我们将 firstPoint
和 lastPoint
字段的左端点等于 minPoint
字段,右端点等于 maxPoint
字段。然后我们计算包围矩形的宽度(lineWidth
)和高度(lineHeight
),以及 minPoint
和 mousePoint
字段在 x 和 y 方向上的距离(diffWidth
和 diffHeight
),如下面的图像所示:
由于一致性,如果鼠标点击中线条,以下方程是正确的:
这意味着:
这也意味着:
让我们允许有一点容差;让我们说用户被允许错过线条 1 毫米(100 单位)。这改变了最后一个方程到以下方程:
else {
Point minPoint = Min(firstPoint, lastPoint),
maxPoint = Max(firstPoint, lastPoint);
if ((minPoint.X() <= point.X()) &&
(point.X() <= maxPoint.X())) {
int lineWidth = maxPoint.X() - minPoint.X(),
lineHeight = maxPoint.Y() - minPoint.Y();
int diffWidth = point.X() - minPoint.X(),
diffHeight = point.Y() - minPoint.Y();
double delta = fabs(diffHeight - (diffWidth *
((double) lineHeight) / lineWidth));
return (delta <= 100);
}
return false;
}
}
IsInside
方法比 IsClick
方法简单。我们只需检查两个端点是否都被给定的矩形包围。
bool LineFigure::IsInside(Rect rect) {
return (rect.PointInside(firstPoint) &&
rect.PointInside(lastPoint));
}
在 Modify
模式下,我们根据 IsClick
方法设置的 lineMode
参数的值移动一个端点或线条。如果用户击中了第一个点,我们就移动它。如果他们击中了最后一个点,或者如果线条正在创建过程中,我们就移动最后一个点。如果他们击中了线条,我们就移动线条。也就是说,我们移动第一个和最后一个点。
void LineFigure::Modify(Size distanceSize) {
Invalidate();
switch (lineMode) {
case FirstPoint:
firstPoint += distanceSize;
break;
case CreateLine:
case LastPoint:
lastPoint += distanceSize;
break;
case MoveLine:
Move(distanceSize);
break;
}
Invalidate();
}
Move
方法也很简单;我们只需移动两个端点。
void LineFigure::Move(Size distanceSize) {
Invalidate();
firstPoint += distanceSize;
lastPoint += distanceSize;
Invalidate();
}
在 Draw
方法中,我们绘制线条,如果线条被标记,则其两个端点始终是黑色。
void LineFigure::Draw(Graphics& graphics) const {
graphics.DrawLine(firstPoint, lastPoint, GetColor());
if (IsMarked()) {
graphics.FillRectangle(Rect(firstPoint - MarkRadius,
firstPoint + MarkRadius), Black,Black);
graphics.FillRectangle(Rect(lastPoint - MarkRadius,
lastPoint + MarkRadius), Black, Black);
}
}
线条占据的区域是一个以端点为顶点的矩形。如果线条被标记,则标记半径被添加。
Rect LineFigure::Area() const {
Rect lineArea(firstPoint.X(), firstPoint.Y(),
lastPoint.X(), lastPoint.Y());
lineArea.Normalize();
if (IsMarked()) {
lineArea -= MarkRadius;
lineArea += MarkRadius;
}
return lineArea;
}
如果正在修改线条,则返回 Crosshair
光标。如果正在移动,则返回全选光标(四个指向方位的箭头)。如果没有这些情况,则我们只返回正常的箭头光标。
CursorType LineFigure::GetCursor() const {
switch (lineMode) {
case CreateLine:
case FirstPoint:
case LastPoint:
return Cursor::Crosshair;
case MoveLine:
return Cursor::SizeAll;
default:
return Cursor::Normal;
}
}
WriteFigureToStream
、ReadFigureFromStream
、WriteFigureToClipboard
和 ReadFigureFromClipboard
方法在调用 DrawFigure
类中的相应方法后,写入和读取线的第一个和最后一个端点。
bool LineFigure::WriteFigureToStream(ostream& outStream) const {
DrawFigure::WriteFigureToStream(outStream);
firstPoint.WritePointToStream(outStream);
lastPoint.WritePointToStream(outStream);
return ((bool) outStream);
}
bool LineFigure::ReadFigureFromStream (istream& inStream) {
DrawFigure::ReadFigureFromStream(inStream);
firstPoint.ReadPointFromStream(inStream);
lastPoint.ReadPointFromStream(inStream);
return ((bool) inStream);
}
void LineFigure::WriteFigureToClipboard(InfoList& infoList) const{
DrawFigure::WriteFigureToClipboard(charList);
firstPoint.WritePointToClipboard(charList);
lastPoint.WritePointToClipboard(charList);
}
void LineFigure::ReadFigureFromClipboard(InfoList& infoList) {
DrawFigure::ReadFigureFromClipboard(infoList);
firstPoint.ReadPointFromClipboard(infoList);
lastPoint.ReadPointFromClipboard(infoList);
}
ArrowFigure 类
ArrowFigure
是 LineFigure
类的子类,并重用了 firstPoint
和 lastPoint
字段以及一些功能。箭头端点存储在 leftPoint
和 rightPoint
字段中,如下面的图像所示。边的长度由 ArrowLength
常量定义为 500 单位,即 5 毫米。
ArrowFigure
类覆盖了 LineFigure
类的一些方法。主要的是,它调用 LineFigure
类的方法,然后添加自己的功能。
ArrowFigure.h
class ArrowFigure : public LineFigure {
public:
ArrowFigure(const Window* windowPtr);
FigureId GetId() const {return ArrowId;};
bool IsClick(Point mousePoint);
bool IsInside(Rect area);
void DoubleClick(Point mousePoint);
void Modify(Size distanceSize);
void Move(Size distanceSize);
Rect Area() const;
void Draw(Graphics& graphics) const;
bool WriteFigureToStream(ostream& outStream) const;
bool ReadFigureFromStream(istream& inStream);
void WriteFigureToClipboard(InfoList& infoList) const;
void ReadFigureFromClipboard(InfoList& infoList);
private:
static const int ArrowLength = 500;
Point leftPoint, rightPoint;
void CalculateArrowHead();
};
构造函数允许 LineFigure
构造函数初始化箭头的端点,然后调用 CalculateArrowHead
方法来计算箭头端点。
ArrowFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Draw.h"
#include "LineFigure.h"
#include "ArrowFigure.h"
ArrowFigure::ArrowFigure(const Window* windowPtr)
:LineFigure(windowPtr) {
CalculateArrowHead();
}
IsClick
方法如果用户点击线或箭头的任何部分,则返回 True
。
bool ArrowFigure::IsClick(Point mousePoint) {
return LineFigure::IsClick(mousePoint) ||
IsPointInLine(firstPoint, leftPoint, mousePoint) ||
IsPointInLine(firstPoint, rightPoint, mousePoint) ||
IsPointInLine(leftPoint, rightPoint, mousePoint);
}
IsInside
方法如果线的所有端点和箭头都在区域内,则返回 True
。
bool ArrowFigure::IsInside(Rect area) {
return area.PointInside(firstPoint) &&
area.PointInside(lastPoint) &&
area.PointInside(leftPoint) &&
area.PointInside(rightPoint);
}
Modify
方法修改线并重新计算箭头。
void ArrowFigure::Modify(Size distanceSize) {
LineFigure::Modify(distanceSize);
CalculateArrowHead();
}
Move
方法移动线和箭头。
void ArrowFigure::Move(Size distanceSize) {
LineFigure::Move(distanceSize);
leftPoint += distanceSize;
rightPoint += distanceSize;
}
当用户双击箭头时,其头部和尾部会交换。
void ArrowFigure::DoubleClick(Point mousePoint) {
if (IsClick(mousePoint)) {
Invalidate();
Point tempPoint = firstPoint;
firstPoint = lastPoint;
lastPoint = tempPoint;
CalculateArrowHead();
Invalidate();
}
}
Area
方法计算线和箭头端点的最小和最大值,并返回一个包含其左上角和右下角的区域。如果箭头被标记,则将标记半径添加到区域中。
Rect ArrowFigure::Area() const {
Point topLeft(min(firstPoint.X(), min(lastPoint.X(),
min(leftPoint.X(), rightPoint.X()))),
min(firstPoint.Y(), min(lastPoint.Y(),
min(leftPoint.Y(), rightPoint.Y())))),
bottomRight(max(firstPoint.X(), max(lastPoint.X(),
max(leftPoint.X(), rightPoint.X()))),
max(firstPoint.Y(), max(lastPoint.Y(),
max(leftPoint.Y(), rightPoint.Y()))));
if (IsMarked()) {
topLeft -= MarkRadius;
bottomRight += MarkRadius;
}
return Rect(topLeft, bottomRight);
}
Draw
方法绘制线和箭头。如果箭头被标记,箭头的端点也会用方块标记。
void ArrowFigure::Draw(Graphics& graphics) const {
LineFigure::Draw(graphics);
graphics.DrawLine(lastPoint, leftPoint, GetColor());
graphics.DrawLine(lastPoint, rightPoint, GetColor());
graphics.DrawLine(leftPoint, rightPoint, GetColor());
if (IsMarked()) {
graphics.FillRectangle(Rect(leftPoint - MarkRadius,
leftPoint + MarkRadius), Black, Black);
graphics.FillRectangle(Rect(rightPoint - MarkRadius,
rightPoint + MarkRadius), Black,Black);
}
}
WriteFigureToStream
、ReadFigureFromStream
、WriteFigureToClipboard
和 ReadFigureFromClipboard
方法允许 LineFigure
类写入和读取线的端点。然后它写入和读取箭头端点。
bool ArrowFigure::WriteFigureToStream(ostream& outStream) const {
LineFigure::WriteFigureToStream(outStream);
leftPoint.WritePointToStream(outStream);
rightPoint.WritePointToStream(outStream);
return ((bool) outStream);
}
bool ArrowFigure::ReadFigureFromStream(istream& inStream) {
LineFigure::ReadFigureFromStream(inStream);
leftPoint.ReadPointFromStream(inStream);
rightPoint.ReadPointFromStream(inStream);
return ((bool) inStream);
}
void ArrowFigure::WriteFigureToClipboard(InfoList& infoList)const{
LineFigure::WriteFigureToClipboard(charList);
leftPoint.WritePointToClipboard(charList);
rightPoint.WritePointToClipboard(charList);
}
void ArrowFigure::ReadFigureFromClipboard(InfoList& infoList) {
LineFigure::ReadFigureFromClipboard(infoList);
leftPoint.ReadPointFromClipboard(infoList);
rightPoint.ReadPointFromClipboard(infoList);
}
CalculateArrowHead
方法是一个私有辅助方法,用于计算箭头端点。我们将使用以下关系来计算 leftPoint
和 rightPoint
字段。
计算分为三个步骤;首先我们计算 alpha
和 beta
。请参见以下插图以了解角度的定义:
然后我们计算 leftAngle
和 rightAngle
,并使用它们的值来计算 leftPoint
和 rightPoint
的值。线和箭头部分的夹角是 45 度,相当于 Π/4 弧度。因此,为了确定箭头部分的角,我们只需从 beta
中减去 Π/4 并将 Π/4 加到 beta
上:
然后我们使用以下公式最终确定 leftPoint
和 rightPoint
:
三角函数在 C 标准库中可用。然而,我们需要定义我们的 Π 值。atan2
函数计算 height
和 width
的比例的切线值,并考虑 width
可能为零的可能性。
void ArrowFigure::CalculateArrowHead() {
int height = lastPoint.Y() - firstPoint.Y();
int width = lastPoint.X() - firstPoint.X();
const double Pi = 3.14159265;
double alpha = atan2((double) height, (double) width);
double beta = alpha + Pi;
double leftAngle = beta - (Pi / 4);
double rightAngle = beta + (Pi / 4);
leftPoint.X() = lastPoint.X() +
(int) (ArrowLength * cos(leftAngle));
leftPoint.Y() = lastPoint.Y() +
(int) (ArrowLength * sin(leftAngle));
rightPoint.X() = lastPoint.X() +
(int) (ArrowLength * cos(rightAngle));
rightPoint.Y() = lastPoint.Y() +
(int) (ArrowLength * sin(rightAngle));
}
RectangleFigure 类
RectangleFigure
类包含一个矩形,可以是填充的或不填充的。用户可以通过抓住其四个角之一来修改它。DrawRectangle
类覆盖了 DrawFigure
类的大部分方法。
与线和箭头的情况相比,一个区别是矩形是二维的,可以是填充的或未填充的。Fillable
方法返回 True
,IsFilled
和 Fill
方法被覆盖。当用户双击矩形时,它将在填充和未填充状态之间切换。
RectangleFigure.h
class RectangleFigure : public DrawFigure {
public:
RectangleFigure(const Window* windowPtr);
virtual void SetFirstPoint(Point point);
virtual FigureId GetId() const {return RectangleId;}
virtual bool IsClick(Point mousePoint);
virtual bool IsInside(Rect rectangleArea);
virtual void DoubleClick(Point mousePoint);
virtual void Modify(Size distanceSize);
virtual void Move(Size distanceSize);
virtual Rect Area() const;
virtual void Draw(Graphics& graphics) const;
virtual CursorType GetCursor() const;
bool IsFillable() const {return true;}
bool IsFilled() const {return filled;}
void Fill(bool fill) {filled = fill; Invalidate();}
virtual bool WriteFigureToStream(ostream& outStream) const;
virtual bool ReadFigureFromStream(istream& inStream);
virtual void WriteFigureToClipboard(InfoList& infoList) const;
virtual void ReadFigureFromClipboard(InfoList& infoList);
private:
enum {CreateRectangle, TopLeftPoint, TopRightPoint,
BottomRightPoint, BottomLeftPoint, MoveRectangle}
rectangleMode;
protected:
bool filled = false;
Point topLeft, bottomRight;
};
RectangleFigure.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Draw.h"
#include "RectangleFigure.h"
RectangleFigure::RectangleFigure(const Window* windowPtr)
:Draw(windowPtr), rectangleMode(CreateRectangle) { /* Empty. */ }
void RectangleFigure::SetFirstPoint(Point point) { topLeft = point; bottomRight = point; }
当用户点击矩形时,他们可能会点击其四个角落之一、矩形的边缘,或者(如果它被填充)其内部。首先,我们检查角落,然后是矩形本身。如果它被填充,我们只需测试鼠标点是否在矩形内。如果矩形未被填充,我们通过构建一个稍微小一点的矩形和一个稍微大一点的矩形来测试是否点击了其任何四个边缘。如果鼠标位置包含在较大的矩形内,但不包含在较小的矩形内,则用户点击了矩形的边缘。
bool RectangleFigure::IsClick(Point mousePoint) {
Rect topLeftRect(topLeft - MarkRadius, topLeft + MarkRadius);
if (topLeftRect.PointInside(mousePoint)) {
rectangleMode = TopLeftPoint;
return true;
}
Point topRightPoint(bottomRight.X(), topLeft.Y());
Rect topRectRight(topRightPoint - MarkRadius,
topRightPoint + MarkRadius);
if (topRectRight.PointInside(mousePoint)) {
rectangleMode = TopRightPoint;
return true;
}
Rect bottomRightRect(bottomRight - MarkRadius,
bottomRight + MarkRadius);
if (bottomRightRect.PointInside(mousePoint)) {
rectangleMode = BottomRightPoint;
return true;
}
Point bottomLeftPoint(topLeft.X(), bottomRight.Y());
Rect bottomLeftRect(bottomLeftPoint - MarkRadius,
bottomLeftPoint + MarkRadius);
if (bottomLeftRect.PointInside(mousePoint)) {
rectangleMode = BottomLeftPoint;
return true;
}
Rect areaRect(topLeft, bottomRight);
areaRect.Normalize();
if (IsFilled()) {
areaRect.PointInside(mousePoint);
if (areaRect.PointInside(mousePoint)) {
rectangleMode = MoveRectangle;
return true;
}
}
else {
Rect largeAreaRect(areaRect.TopLeft() - MarkRadius,
areaRect.BottomRight() + MarkRadius),
smallAreaRect(areaRect.TopLeft() + MarkRadius,
areaRect.BottomRight() - MarkRadius);
if (largeAreaRect.PointInside(mousePoint) &&
!smallAreaRect.PointInside(mousePoint)) {
rectangleMode = MoveRectangle;
return true;
}
}
return false;
}
IsInside
方法如果矩形的左上角和右下角被矩形区域包围,则返回 true
。
bool RectangleFigure::IsInside(Rect area) {
return area.PointInside(topLeft) &&
area.PointInside(bottomRight);
}
DoubleClick
方法如果矩形未被填充则填充它,反之亦然。
void RectangleFigure::DoubleClick(Point mousePoint) {
if (IsClick(mousePoint)) {
filled = !filled;
Invalidate();
}
}
Modify
方法根据 IsClick
方法中 rectangleMode
参数的设置修改或移动矩形。
void RectangleFigure::Modify(Size distanceSize) {
Invalidate();
switch (rectangleMode) {
case TopLeftPoint:
topLeft += distanceSize;
break;
case TopRightPoint:
topLeft.Y() += distanceSize.Height();
bottomRight.X() += distanceSize.Width();
break;
case CreateRectangle:
case BottomRightPoint:
bottomRight += distanceSize;
break;
case BottomLeftPoint:
topLeft.X() += distanceSize.Width();
bottomRight.Y() += distanceSize.Height();
break;
case MoveRectangle:
Move(distanceSize);
break;
}
Invalidate();
}
Move
方法移动矩形的角落。
void RectangleFigure::Move(Size distanceSize) {
Invalidate();
topLeft += distanceSize;
bottomRight += distanceSize;
Invalidate();
}
矩形的面积简单地说就是矩形的面积。然而,如果它被标记,我们会增加它以包括角落的正方形。
Rect RectangleFigure::Area() const {
Rect areaRect(topLeft, bottomRight);
areaRect.Normalize();
if (IsMarked()) {
areaRect -= MarkRadius;
areaRect += MarkRadius;
}
return areaRect;
}
Draw
方法绘制或填充矩形。如果它被标记,它还会填充正方形。
void RectangleFigure::Draw(Graphics& graphics) const {
if (filled) {
graphics.FillRectangle(Rect(topLeft, bottomRight),
GetColor(), GetColor());
}
else {
graphics.DrawRectangle(Rect(topLeft, bottomRight),
GetColor());
}
if (IsMarked()) {
graphics.FillRectangle(Rect(topLeft - MarkRadius,
topLeft + MarkRadius), Black, Black);
Point topRight(bottomRight.X(), topLeft.Y());
graphics.FillRectangle(Rect(topRight - MarkRadius,
topRight + MarkRadius), Black, Black);
graphics.FillRectangle(Rect(bottomRight - MarkRadius,
bottomRight + MarkRadius),Black,Black);
Point bottomLeft(topLeft.X(), bottomRight.Y());
graphics.FillRectangle(Rect(bottomLeft - MarkRadius,
bottomLeft + MarkRadius.Height()), Black, Black);
}
}
当图形被移动时,矩形的指针是大小全指针(四个方向上的箭头)。它是一个根据抓取的角落而修改时带有箭头的指针:如果是最左上角或最右下角,则使用西北和东南箭头;如果是右上角或左下角,则使用东北和西南箭头。
CursorType RectangleFigure::GetCursor() const {
switch (rectangleMode) {
case TopLeftPoint:
case BottomRightPoint:
return Cursor::SizeNorthWestSouthEast;
case TopRightPoint:
case BottomLeftPoint:
return Cursor::SizeNorthEastSouthWest;
case MoveRectangle:
return Cursor::SizeAll;
default:
return Cursor::Normal;
}
}
WriteFigureToStream
、ReadFigureFromStream
、WriteFigureToClipboard
和 ReadFigureFromClipboard
方法调用 DrawFigure
类中的相应方法。然后它们写入和读取矩形的四个角,以及它是否被填充。
bool RectangleFigure::WriteFigureToStream(ostream& outStream)
const {
DrawFigure::WriteFigureToStream(outStream);
topLeft.WritePointToStream(outStream);
bottomRight.WritePointToStream(outStream);
outStream.write((char*) &filled, sizeof filled);
return ((bool) outStream);
}
bool RectangleFigure::ReadFigureFromStream (istream& inStream) {
DrawFigure::ReadFigureFromStream(inStream);
topLeft.ReadPointFromStream(inStream);
bottomRight.ReadPointFromStream(inStream);
inStream.read((char*) &filled, sizeof filled);
return ((bool) inStream);
}
void RectangleFigure::WriteFigureToClipboard(InfoList& infoList)
const {
DrawFigure::WriteFigureToClipboard(infoList);
topLeft.WritePointToClipboard(infoList);
bottomRight.WritePointToClipboard(infoList);
infoList.AddValue<bool>(filled);
}
void RectangleFigure::ReadFigureFromClipboard(InfoList& infoList) {
DrawFigure::ReadFigureFromClipboard(infoList);
topLeft.ReadPointFromClipboard(infoList);
bottomRight.ReadPointFromClipboard(infoList);
infoList.GetValue<bool>(filled);
}
The EllipseFigure 类
EllipseFigure
类是 RectangleFigure
类的子类。椭圆可以通过水平或垂直角落移动或重塑。RectangleFigure
类的大多数方法都没有被 Ellipse
类覆盖。
Ellipse.h
class EllipseFigure : public RectangleFigure {
public:
EllipseFigure(const Window* windowPtr);
FigureId GetId() const {return EllipseId;}
bool IsClick(Point mousePoint);
void Modify(Size distanceSize);
void Draw(Graphics& graphics) const;
CursoTyper GetCursor() const;
private:
enum {CreateEllipse, LeftPoint, TopPoint, RightPoint,
BottomPoint, MoveEllipse} ellipseMode;
};
Ellipse.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Draw.h"
#include "RectangleFigure.h"
#include "EllipseFigure.h"
EllipseFigure::EllipseFigure(const Window* windowPtr)
:RectangleFigure(windowPtr),
ellipseMode(CreateEllipse) {
// Empty.
}
正如矩形的情况一样,IsClick
方法首先决定用户是否点击了四个端点之一;然而,与矩形角落的位置相比,这些位置是不同的。
bool EllipseFigure::IsClick(Point mousePoint) {
Point leftPoint(topLeft.X(), (topLeft.Y() + bottomRight.Y())/2);
Rect leftRect(leftPoint - MarkRadius, leftPoint + MarkRadius);
if (leftRect.PointInside(mousePoint)) {
ellipseMode = LeftPoint;
return true;
}
Point topPoint((topLeft.X() + bottomRight.X()) / 2,topLeft.Y());
Rect topRect(topPoint - MarkRadius, topPoint + MarkRadius);
if (topRect.PointInside(mousePoint)) {
ellipseMode = TopPoint;
return true;
}
Point rightPoint(bottomRight.X(),
(topLeft.Y() + bottomRight.Y()) / 2);
Rect rightRect(rightPoint - MarkRadius,
rightPoint + MarkRadius);
if (rightRect.PointInside(mousePoint)) {
ellipseMode = RightPoint;
return true;
}
Point bottomPoint((topLeft.X() + bottomRight.X()) / 2,
bottomRight.Y());
Rect bottomRect(bottomPoint - MarkRadius,
bottomPoint + MarkRadius);
if (bottomRect.PointInside(mousePoint)) {
ellipseMode = BottomPoint;
return true;
}
如果用户没有点击任何一个修改位置,我们必须决定用户是否点击了椭圆本身。如果椭圆没有填充,这相当简单。我们使用 Win32 API 函数CreateEllipticRgn
创建一个椭圆区域,并测试鼠标位置是否在其中。如果椭圆没有填充,我们创建两个区域,一个稍微小一些,一个稍微大一些。如果鼠标位置包含在较大的区域中,但不包含在较小的区域中,则表示发生了点击。
ellipseMode = MoveEllipse;
Point minPoint = Min(topLeft, bottomRight),
maxPoint = Max(topLeft, bottomRight);
if (IsFilled()) {
HRGN ellipseRegion =
CreateEllipticRgn(minPoint.X(), minPoint.Y(),
maxPoint.X(), maxPoint.Y());
return (PtInRegion(ellipseRegion, mousePoint.X(),
mousePoint.Y()) != 0);
}
else {
HRGN smallRegion =
CreateEllipticRgn(minPoint.X() + MarkRadius.Width(),
minPoint.Y() + MarkRadius.Height(),
maxPoint.X() - MarkRadius.Width(),
maxPoint.Y() - MarkRadius.Height());
HRGN largeRegion =
CreateEllipticRgn(minPoint.X() - MarkRadius.Width(),
minPoint.Y() - MarkRadius.Height(),
maxPoint.X() + MarkRadius.Width(),
maxPoint.Y() + MarkRadius.Height());
return ((PtInRegion(largeRegion, mousePoint.X(),
mousePoint.Y()) != 0) &&
(PtInRegion(smallRegion, mousePoint.X(),
mousePoint.Y()) == 0));
}
return false;
}
Modify
方法根据IsClick
方法中ellipseMode
参数的设置移动角落。
void EllipseFigure::Modify(Size distanceSize) {
Invalidate();
switch (ellipseMode) {
case CreateEllipse:
bottomRight += distanceSize;
break;
case LeftPoint:
topLeft.X() += distanceSize.Width();
break;
case RightPoint:
bottomRight.X() += distanceSize.Width();
break;
case TopPoint:
topLeft.Y() += distanceSize.Height();
break;
case BottomPoint:
bottomRight.Y() += distanceSize.Height();
break;
case MoveEllipse:
Move(distanceSize);
break;
}
Invalidate();
}
Draw
方法填充或绘制椭圆,如果椭圆被标记,则绘制四个方块。
void EllipseFigure::Draw(Graphics& graphics) const {
if (filled) {
graphics.FillEllipse(Rect(topLeft, bottomRight),
GetColor(), GetColor());
}
else {
graphics.DrawEllipse(Rect(topLeft, bottomRight), GetColor());
}
if (IsMarked()) {
Point leftPoint(topLeft.X(), (topLeft.Y()+bottomRight.Y())/2);
graphics.FillRectangle(Rect(leftPoint - MarkRadius,
leftPoint + MarkRadius), Black, Black);
Point topPoint((topLeft.X() + bottomRight.X())/2,topLeft.Y());
graphics.FillRectangle(Rect(topPoint - MarkRadius,
topPoint + MarkRadius),Black, Black);
Point rightPoint(bottomRight.X(),
(topLeft.Y() + bottomRight.Y()) / 2);
graphics.FillRectangle(Rect(rightPoint - MarkRadius,
rightPoint + MarkRadius), Black,Black);
Point bottomPoint((topLeft.X() + bottomRight.X()) / 2,
bottomRight.Y());
graphics.FillRectangle(Rect(bottomPoint - MarkRadius,
bottomPoint + MarkRadius),Black,Black);
}
}
最后,关于光标,我们有以下五种不同的情况:
-
当椭圆正在创建时,会返回十字光标
-
当用户抓住椭圆的左端点或右端点时,会返回东西(左右)箭头
-
当用户抓住椭圆的顶部或底部端点时,会返回上下(上下)箭头
-
当用户移动椭圆时,会返回大小箭头(指向左、右、上、下的四个箭头)
-
最后,当用户既不移动也不修改椭圆时,会返回正常的箭头光标
CursorType EllipseFigure::GetCursor() const {
switch (ellipseMode) {
case CreateEllipse:
return Cursor::Crosshair;
case LeftPoint:
case RightPoint:
return Cursor::SizeWestEast;
case TopPoint:
case BottomPoint:
return Cursor::SizeNorthSouth;
case MoveEllipse:
return Cursor::SizeAll;
default:
return Cursor::Normal;
}
}
概述
在本章中,你学习了第四章中绘图程序的图形类层次结构,处理形状和图形。你涵盖了以下主题:
-
测试图形是否被鼠标点击击中或是否被矩形包围
-
图形的修改和移动
-
绘制图形和计算图形的面积
-
将图形写入和读取到文件流或剪贴板
-
根据图形的当前状态使用不同光标的游标处理
在第六章中,构建字处理器,你将开始开发字处理器。
第六章:构建文本处理器
在本章中,我们构建了一个能够处理字符级别文本的文本处理器:也就是说,一个具有自己的字体、颜色、大小和样式的单个字符。我们还介绍了光标处理、打印和打印预览、文件拖放,以及与 ASCII 和 Unicode 文本的剪贴板处理,这意味着我们可以在该应用程序和,例如,文本编辑器之间进行剪切和粘贴。
辅助类
在此应用程序中,文档由页面、段落、行和字符组成。让我尝试解释它们是如何相互关联的:
-
首先,文档由字符列表组成。每个字符都有自己的字体和指向其所属段落和行的指针。字符信息存储在
CharInfo
类的对象中。WordDocument
类中的charList
字段是一个CharInfo
对象列表。 -
字符被分为段落。一个段落不包含自己的字符列表。相反,它包含其第一个和最后一个字符在字符列表中的索引。
WordDocument
中的paragraphList
字段是一个Paragraph
对象列表。每个段落的最后一个字符始终是换行符。 -
每个段落被分为一行列表。下面的
Paragraph
类包含一个Line
对象列表。一行包含其相对于段落开始的第一个和最后一个字符的索引。 -
最后,文档也被分为页面。一个页面尽可能包含尽可能多的完整段落。
每次文档中发生更改时,当前行和段落都会重新计算。页面列表也会重新计算。
让我们继续深入了解CharInfo
、LineInfo
和Paragraph
类。
字符信息
CharInfo
类是一个结构,包含以下内容:
-
字符及其字体
-
它的包围矩形,用于绘制字符
-
指向所属行和段落的指针
CharInfo.h
class LineInfo;
class Paragraph;
class CharInfo {
public:
CharInfo(Paragraph* paragraphPtr = nullptr,
TCHAR tChar = TEXT('\0'),
Font font = SystemFont, Rect rect = ZeroRect);
CharInfo(const CharInfo& charInfo);
CharInfo& operator=(const CharInfo& charInfo);
bool WriteCharInfoToStream(ostream& outStream) const;
bool ReadCharInfoFromStream(istream& inStream);
void WriteCharInfoToClipboard(InfoList& infoList) const;
void ReadCharInfoFromClipboard(InfoList& infoList);
该类中的每个私有字段都有自己的获取和设置值的方法。第一组方法是常量方法,返回值本身,这意味着字段的值不能通过这些方法更改。第二组方法是非常量方法,返回字段的引用,这意味着值可以更改。然而,它们不能从常量对象中调用。
TCHAR Char() const {return tChar;}
Font CharFont() const {return charFont;}
Rect CharRect() const {return charRect;}
LineInfo* LineInfoPtr() const {return lineInfoPtr;}
Paragraph* ParagraphPtr() const {return paragraphPtr;}
TCHAR& Char() {return tChar;}
Font& CharFont() {return charFont;}
Rect& CharRect() {return charRect;}
LineInfo*& LineInfoPtr() {return lineInfoPtr;}
Paragraph*& ParagraphPtr() {return paragraphPtr;}
tChar
和charFont
字段包含字符本身及其字体,而charRect
坐标相对于字符所属段落的左上角位置。每个字符属于一个段落以及该段落的一行,paragraphPtr
和lineInfoPtr
指向这些位置。
private:
TCHAR tChar;
Font charFont;
Rect charRect;
Paragraph* paragraphPtr;
LineInfo* lineInfoPtr;
};
CharInfo.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "CharInfo.h"
font
参数的默认值是提供默认字体的系统字体。它通常是 10 磅的 Arial 字体。
CharInfo::CharInfo(Paragraph* paragraphPtr /* = nullptr */,
TCHAR tChar /* = TEXT('\0') */,
Font font/* = SystemFont */,
Rect rect /* = ZeroRect */)
:lineInfoPtr(nullptr),
paragraphPtr(paragraphPtr),
tChar(tChar),
charFont(font),
charRect(rect) {
// Empty.
}
复制构造函数和赋值运算符复制字段。它们在将字符写入和从文件流读取、剪切、复制或粘贴时被调用。
CharInfo::CharInfo(const CharInfo& charInfo)
:lineInfoPtr(charInfo.lineInfoPtr),
paragraphPtr(charInfo.paragraphPtr),
tChar(charInfo.tChar),
charFont(charInfo.charFont),
charRect(charInfo.charRect) {
// Empty.
}
CharInfo& CharInfo::operator=(const CharInfo& charInfo) {
lineInfoPtr = charInfo.lineInfoPtr;
paragraphPtr = charInfo.paragraphPtr;
tChar = charInfo.tChar;
charFont = charInfo.charFont;
charRect = charInfo.charRect;
return *this;
}
WriteCharInfoToStream
方法写入,而 ReadCharInfoFromStream
方法读取类的值到和从文件流和剪贴板。请注意,我们省略了 paragraphPtr
和 lineInfoPtr
指针,因为将指针地址保存到流中是没有意义的。相反,它们的值在 WordDocument
类中调用 ReadCharInfoFromStream
方法之后由 ReadDocumentFromStream
方法设置。
bool CharInfo::WriteCharInfoToStream(ostream& outStream) const {
outStream.write((char*) &tChar, sizeof tChar);
charFont.WriteFontToStream(outStream);
charRect.WriteRectToStream(outStream);
return ((bool) outStream);
}
bool CharInfo::ReadCharInfoFromStream(istream& inStream) {
inStream.read((char*) &tChar, sizeof tChar);
charFont.ReadFontFromStream(inStream);
charRect.ReadRectFromStream(inStream);
return ((bool) inStream);
}
WriteCharInfoToClipboard
方法写入,而 ReadCharInfoFromClipboard
方法读取值到和从剪贴板。此外,在这种情况下,我们省略了 paragraphPtr
和 lineInfoPtr
指针。这些指针在 WordDocument
类中调用 ReadCharInfoFromClipboard
方法之后由 PasteGeneric
方法设置。
void CharInfo::WriteCharInfoToClipboard(InfoList& infoList) const{
infoList.AddValue<TCHAR>(tChar);
charFont.WriteFontToClipboard(infoList);
}
void CharInfo::ReadCharInfoFromClipboard(InfoList& infoList) {
infoList.GetValue<TCHAR>(tChar);
charFont.ReadFontFromClipboard(infoList);
}
行信息
LineInfo
方法是一个小的结构,包含关于段落中行的信息:
-
它的第一个和最后一个字符的整数索引
-
它的高度和上升,即行上最大字符的高度和上升。
-
行相对于其段落顶部位置的顶部位置
LineInfo.h
class LineInfo {
public:
LineInfo();
LineInfo(int first, int last, int top,
int height, int ascent);
bool WriteLineInfoToStream(ostream& outStream) const;
bool ReadLineInfoFromStream(istream& inStream);
与前面提到的 CharInfo
方法类似,LineInfo
方法包含一组用于检查类字段的常量方法,以及一组用于修改它们的非常量方法。
int First() const {return first;}
int Last() const {return last;}
int Top() const {return top;}
int Height() const {return height;}
int Ascent() const {return ascent;}
int& First() {return first;}
int& Last() {return last;}
int& Top() {return top;}
int& Height() {return height;}
int& Ascent() {return ascent;}
该类的字段是四个整数值;first
和 last
字段分别指代行上的第一个和最后一个字符。top
、height
和 ascent
字段是行相对于段落顶部的顶部位置、最大高度和行上升。
private:
int first, last, top, height, ascent;
};
LineInfo.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "LineInfo.h"
当用户从流中读取文档时调用默认构造函数,而当生成段落的新行时调用第二个构造函数。
LineInfo::LineInfo() {
// Empty.
}
LineInfo::LineInfo(int first, int last, int top,
int height, int ascent)
:first(first),
last(last),
top(top),
height(height),
ascent(ascent) {
// Empty.
}
WriteLineInfoToStream
和 ReadLineInfoFromStream
方法简单地写入和读取字段值。请注意,没有相应的剪切、复制和粘贴方法,因为每次粘贴段落时,段落的行列表都会被重新生成。
bool LineInfo::WriteLineInfoToStream(ostream& outStream) const {
outStream.write((char*) &first, sizeof first);
outStream.write((char*) &last, sizeof last);
outStream.write((char*) &ascent, sizeof ascent);
outStream.write((char*) &top, sizeof top);
outStream.write((char*) &height, sizeof height);
return ((bool) outStream);
}
bool LineInfo::ReadLineInfoFromStream(istream& inStream) {
inStream.read((char*) &first, sizeof first);
inStream.read((char*) &last, sizeof last);
inStream.read((char*) &ascent, sizeof ascent);
inStream.read((char*) &top, sizeof top);
inStream.read((char*) &height, sizeof height);
return ((bool) inStream);
}
段落类
文档由一系列段落组成。Paragraph
结构包含以下内容:
-
它的第一个和最后一个字符的索引
-
它相对于文档开头的顶部位置及其高度
-
它在文档段落指针列表中的索引
-
它的对齐方式——段落可以是左对齐、居中对齐、两端对齐或右对齐
-
是否包含分页符,即此段落是否将位于下一页的开头
Paragraph.h
enum Alignment {Left, Center, Right, Justified};
class WordDocument:
class Paragraph {
public:
Paragraph();
Paragraph(int first, int last,
Alignment alignment, int index);
bool WriteParagraphToStream(ostream& outStream) const;
bool ReadParagraphFromStream(WordDocument* wordDocumentPtr,
istream& inStream);
void WriteParagraphToClipboard(InfoList& infoList) const;
void ReadParagraphFromClipboard(InfoList& infoList);
int& First() {return first;}
int& Last() {return last;}
int& Top() {return top;}
int& Index() {return index;}
int& Height() {return height;}
bool& PageBreak() {return pageBreak;}
正如你所见,我们命名AlignmentField
方法而不是仅仅命名Alignment
方法。这样做的原因是已经有一个名为Alignment
的类。我们不能同时给类和方法相同的名称。因此,我们在方法名称中添加了Field
后缀。
Alignment& AlignmentField() {return alignment;}
DynamicList<LineInfo*>& LinePtrList() {return linePtrList;}
first
和last
字段分别是段落中第一个和最后一个字符在文档字符列表中的索引;段落的最后一个字符始终是换行符。top
字段是段落相对于文档开始的顶部位置,对于文档的第一个段落始终为零,对于其他段落为正值。height
是段落的高度,index
指的是段落在文档段落指针列表中的索引。如果pageBreak
为true
,则段落将始终位于页面的开头。
int first, last, top, height, index;
bool pageBreak;
段落可以左对齐、右对齐、居中对齐和两端对齐。在两端对齐的情况下,为了使单词分布在整个页面宽度上,会扩展空格。
Alignment alignment;
段落至少由一行组成。linePtrList
列表的索引相对于段落中第一个字符的索引(不是文档),坐标相对于段落的顶部(再次不是文档)。
DynamicList<LineInfo*> linePtrList;
};
Paragraph.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "CharInfo.h"
#include "LineInfo.h"
#include "Paragraph.h"
#include "WordDocument.h"
Paragraph::Paragraph() { /* Empty. */ }
Paragraph::Paragraph(int first, int last, Alignment alignment, int index)
:top(-1), first(first), last(last), index(index), pageBreak(false), alignment(alignment) { /* Empty. */ }
这个想法是WriteParagraphToStream
和ReadParagraphFromStream
方法分别写入和读取段落的所有信息。记住,所有坐标都是以逻辑单位(毫米的百分之一)给出的,这意味着在不同的分辨率屏幕上保存和打开文件时会有所不同。
bool Paragraph::WriteParagraphToStream(ostream& outStream) const {
outStream.write((char*) &first, sizeof first);
outStream.write((char*) &last, sizeof last);
outStream.write((char*) &top, sizeof top);
outStream.write((char*) &height, sizeof height);
outStream.write((char*) &index, sizeof index);
outStream.write((char*) &pageBreak, sizeof pageBreak);
outStream.write((char*) &alignment, sizeof alignment);
{ int linePtrListSize = linePtrList.Size();
outStream.write((char*) &linePtrListSize,
sizeof linePtrListSize);
for (const LineInfo* lineInfoPtr : linePtrList) {
lineInfoPtr->WriteLineInfoToStream(outStream);
}
}
return ((bool) outStream);
}
bool Paragraph::ReadParagraphFromStream
(WordDocument* wordDocumentPtr, istream& inStream){
inStream.read((char*) &first, sizeof first);
inStream.read((char*) &last, sizeof last);
inStream.read((char*) &top, sizeof top);
inStream.read((char*) &height, sizeof height);
inStream.read((char*) &index, sizeof index);
inStream.read((char*) &pageBreak, sizeof pageBreak);
inStream.read((char*) &alignment, sizeof alignment);
当我们读取到段落的第一个和最后一个字符的索引时,我们需要设置每个字符的段落指针。
for (int charIndex = first; charIndex <= last; ++charIndex) {
wordDocumentPtr->CharList()[charIndex].ParagraphPtr() = this;
}
{ int linePtrListSize = linePtrList.Size();
inStream.read((char*) &linePtrListSize,
sizeof linePtrListSize);
for (int count = 0; count < linePtrListSize; ++count) {
LineInfo* lineInfoPtr = new LineInfo();
assert(lineInfoPtr != nullptr);
lineInfoPtr->ReadLineInfoFromStream(inStream);
linePtrList.PushBack(lineInfoPtr);
与上面段落指针的情况相同,我们需要设置每个字符的行指针。
for (int charIndex = lineInfoPtr->First();
charIndex <= lineInfoPtr->Last(); ++charIndex) {
wordDocumentPtr->CharList()[first + charIndex].
LineInfoPtr() = lineInfoPtr;
}
}
}
return ((bool) inStream);
}
另一方面,WriteParagraphToClipboard
和ReadParagraphFromClipboard
方法分别只写入和读取必要的信息。在读取段落之后,然后调用CalaulateParagraph
方法,该方法计算字符矩形和段落的行高,并生成其行指针列表。
void Paragraph::WriteParagraphToClipboard(InfoList& infoList) const {
infoList.AddValue<int>(first);
infoList.AddValue<int>(last);
infoList.AddValue<int>(top);
infoList.AddValue<int>(index);
infoList.AddValue<bool>(pageBreak);
infoList.AddValue<Alignment>(alignment);
}
void Paragraph::ReadParagraphFromClipboard(InfoList& infoList) {
infoList.GetValue<int>(first);
infoList.GetValue<int>(last);
infoList.GetValue<int>(top);
infoList.GetValue<int>(index);
infoList.GetValue<bool>(pageBreak);
infoList.GetValue<Alignment>(alignment);
}
MainWindow 类
MainWindow
类几乎与上一章的版本相同。它将应用程序名称设置为Word
并返回WordDocument
实例的地址:
#include "..\\SmallWindows\\SmallWindows.h"
#include "CharInfo.h"
#include "LineInfo.h"
#include "Paragraph.h"
#include "WordDocument.h"
void MainWindow(vector<String> /* argumentList */,
WindowShow windowShow) {
Application::ApplicationName() = TEXT("Word");
Application::MainWindowPtr() = new WordDocument(windowShow);
}
WordDocument 类
WordDocument
类是应用程序的主要类。它扩展了StandardDocument
类并利用了其基于文档的功能。
WordDocument.h
class WordDocument : public StandardDocument {
public:
WordDocument(WindowShow windowShow);
InitDocument
类由构造函数、ClearDocument
和Delete
类调用。
void InitDocument();
每当用户按下 Insert 键时,都会调用 OnKeyboardMode
方法。UpdateCaret
方法将光标设置为 insert
模式下的垂直条和 overwrite
模式下的块。当用户标记一个或多个字符时,光标会被清除。
void OnKeyboardMode(KeyboardMode keyboardMode);
void UpdateCaret();
当用户按下、移动和释放鼠标时,我们需要找到鼠标位置处的字符索引。MousePointToIndex
方法找到段落,而 MousePointToParagraphIndex
方法找到段落中的字符。InvalidateBlock
方法使从最小索引(包含)到最大索引(不包含)的字符无效。
void OnMouseDown(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed,
bool controlPressed);
void OnMouseMove(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed,
bool controlPressed);
void OnMouseUp(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed,
bool controlPressed);
int MousePointToIndex(Point mousePoint) const;
int MousePointToParagraphIndex(Paragraph* paragraphPtr,
Point mousePoint) const;
void InvalidateBlock(int firstIndex, int lastIndex);
当用户双击一个单词时,它将被标记。如果用户确实双击了一个单词(而不是空格、句号、逗号或问号),则 GetFirstWordIndex
和 GetLastWordIndex
方法分别找到单词的第一个和最后一个索引。
void OnDoubleClick(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
int GetFirstWordIndex(int charIndex) const;
int GetLastWordIndex(int charIndex) const;
在这个应用中,我们引入了触摸屏操作。与鼠标点击不同,可以同时触摸屏幕上的多个位置。因此,参数是一个点的列表,而不是一个单独的点。
void OnTouchDown(vector<Point> pointList);
void OnTouchMove(vector<Point> pointList);
当用户通过在 文件 菜单中选择 页面设置 菜单项来更改页面设置时,会调用 OnPageSetup
方法。这允许用户修改页面和段落设置。CalculateDocument
方法将段落分配到页面上。如果一个段落带有分页标记,或者它没有完全适合当前页的其余部分,它将被放置在下一页的开始处。
void OnPageSetup(PageSetupInfo pageSetupInfo);
void CalculateDocument();
与前几章中的应用不同,我们重写了 OnPaint
和 OnDraw
方法。当客户端区域需要重新绘制时调用 OnPaint
方法。它执行特定的绘制动作,即仅在文档在窗口中绘制时执行的动作,而不是在发送到打印机时执行。更具体地说,我们在客户端区域添加了分页标记,但不在打印机文本中。
然后,OnPaint
方法调用执行文档实际绘制的 OnDraw
方法。在 StandardDocument
类(我们没有重写)中还有一个名为 OnPrint
的方法,当打印文档时调用 OnDraw
方法。
void OnPaint(Graphics& graphics) const;
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
与前几章中的应用类似,当用户在 文件 菜单中选择 新建、保存、另存为 或 打开 菜单项时,会调用 ClearDocument
、WriteDocumentToStream
和 ReadDocumentFromStream
方法。
void ClearDocument();
bool WriteDocumentToStream(String name, ostream& outStream)
const;
bool ReadDocumentFromStream(String name, istream& inStream);
当文本准备好复制时,CopyEnable
方法返回true
,即当用户标记了文本的一部分时。当用户选择剪切或复制菜单项并复制标记的文本到字符串列表时,会调用CopyAscii
和CopyUnicode
方法。当用户选择剪切或复制菜单项并将标记的文本以应用程序特定的格式复制时,也会调用CopyGeneric
方法,这种格式还会复制字符的字体和样式。
bool CopyEnable() const;
bool IsCopyAsciiReady() const;
bool IsCopyUnicodeReady() const;
bool IsCopyGenericReady(int format) const;
void CopyAscii(vector<String>& textList) const;
void CopyUnicode(vector<String>& textList) const;
void CopyGeneric(int format, InfoList& infoList) const;
当用户选择粘贴菜单项时,会调用PasteAscii
、PasteUnicode
和PasteGeneric
方法。复制和粘贴之间的一个区别是,在复制时,上述三种方法都会被调用,但在粘贴时,只调用一个方法,其顺序与在StandardDocument
构造函数调用中给出的格式顺序相同。
void PasteAscii(const vector<String>& textList);
void PasteUnicode(const vector<String>& textList);
void PasteGeneric(int format, InfoList& infoList);
我们没有重写CutEnable
或OnCut
方法,因为StandardDocument
类中的CutEnable
方法会调用CopyEnable
方法,而OnCut
方法会调用OnDelete
方法,然后是OnCopy
方法。
删除菜单项处于启用状态,除非输入位置在文档末尾,在这种情况下,没有可以删除的内容。Delete
方法是一个通用方法,用于删除文本,当用户按下Delete或Backspace键或正在覆盖标记的文本块时会被调用。
bool DeleteEnable() const;
void OnDelete();
void Delete(int firstIndex, int lastIndex);
OnPageBreak
方法设置编辑段落的分页状态。如果发生分页,段落将被放置在下一页的开头。OnFont
方法显示标准字体对话框,用于设置下一个要输入的字符或标记块的字体和颜色。
DEFINE_BOOL_LISTENER(WordDocument, PageBreakEnable)
DEFINE_VOID_LISTENER(WordDocument, OnPageBreak)
DEFINE_VOID_LISTENER(WordDocument, OnFont)
段落可以左对齐、居中对齐、右对齐或两端对齐。如果当前编辑的段落或所有当前标记的段落具有所询问的对齐方式,则会出现单选标记。所有听众都会调用IsAlignment
和SetAlignment
方法,分别用于获取编辑段落或所有标记段落的当前对齐方式以及设置对齐方式。
DEFINE_BOOL_LISTENER(WordDocument, LeftRadio)
DEFINE_VOID_LISTENER(WordDocument, OnLeft)
DEFINE_BOOL_LISTENER(WordDocument, CenterRadio)
DEFINE_VOID_LISTENER(WordDocument, OnCenter)
DEFINE_BOOL_LISTENER(WordDocument, RightRadio)
DEFINE_VOID_LISTENER(WordDocument, OnRight)
DEFINE_BOOL_LISTENER(WordDocument, JustifiedRadio)
DEFINE_VOID_LISTENER(WordDocument, OnJustified)
bool IsAlignment(Alignment alignment) const;
void SetAlignment(Alignment alignment);
每当用户按下图形字符时,都会调用OnChar
方法;它根据键盘是否处于insert
或overwrite
模式来调用InsertChar
或OverwriteChar
方法。当文本被标记且用户更改字体时,字体会应用于所有标记字符。然而,在编辑文本时,下一个要输入的字符的字体会被设置。
当用户进行除输入下一个字符之外的其他操作时,例如点击鼠标或按下任何箭头键,会调用ClearNextFont
方法,该方法通过将其设置为SystemFont
方法来清除下一个字体。
void OnChar(TCHAR tChar);
void InsertChar(TCHAR tChar, Paragraph* paragraphPtr);
void OverwriteChar(TCHAR tChar, Paragraph* paragraphPtr);
void ClearNextFont();
每当用户按下键时,都会调用OnKeyDown
方法,例如箭头键、向上翻页和向下翻页、Home和End、Delete或Backspace:
bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
void OnRegularKey(WORD key);
void EnsureEditStatus();
void OnLeftArrowKey();
void OnRightArrowKey();
void OnUpArrowKey();
void OnDownArrowKey();
int MousePointToIndexDown(Point mousePoint) const;
void OnPageUpKey();
void OnPageDownKey();
void OnHomeKey();
void OnEndKey();
当用户按下键而没有同时按下 Shift 键时,光标会移动。然而,当他们按下 Shift 键时,文本的标记会改变。
void OnShiftKey(WORD key);
void EnsureMarkStatus();
void OnShiftLeftArrowKey();
void OnShiftRightArrowKey();
void OnShiftUpArrowKey();
void OnShiftDownArrowKey();
void OnShiftPageUpKey();
void OnShiftPageDownKey();
void OnShiftHomeKey();
void OnShiftEndKey();
当用户同时按下 Home 或 End 键和 Ctrl 键时,光标将被放置在文档的开始或结束位置。如果他们还按下 Shift 键,文本将被标记。
我们使用监听器而不是常规方法的原因是,所有涉及 Ctrl 键的操作都被 Small Windows 解释为加速器。监听器也被添加到以下构造函数中的菜单中。
DEFINE_VOID_LISTENER(WordDocument, OnControlHomeKey);
DEFINE_VOID_LISTENER(WordDocument, OnControlEndKey);
DEFINE_VOID_LISTENER(WordDocument, OnShiftControlHomeKey);
DEFINE_VOID_LISTENER(WordDocument, OnShiftControlEndKey);
同样存在 返回、退格 和 删除 键,在这种情况下,我们并不关心是否按下了 Shift 或 Ctrl 键。删除 键由 删除 菜单项加速器处理。
void OnNeutralKey(WORD key);
void OnReturnKey();
void OnBackspaceKey();
当用户使用键盘移动光标时,编辑字符将可见。MakeVisible
方法确保它是可见的,即使这意味着滚动文档。
void MakeVisible();
当段落发生某些变化(字符被添加或删除,字体或对齐方式改变,或页面设置)时,需要计算字符的位置。GenerateParagraph
方法为每个字符计算周围矩形,并通过调用 GenerateSizeAndAscentList
方法计算字符的大小和上升线,调用 GenerateLineList
方法将段落分成行,调用 GenerateRegularLineRectList
方法生成左对齐、居中对齐或右对齐段落的字符矩形,或调用 GenerateJustifiedLineRectList
方法为对齐段落生成字符矩形,以及调用 GenerateRepaintSet
方法使更改的字符无效。
void GenerateParagraph(Paragraph* paragraphPtr);
void GenerateSizeAndAscentList(Paragraph* paragraphPtr,
DynamicList<Size>& sizeList,
DynamicList<int>& ascentList);
void GenerateLineList(Paragraph* paragraphPtr,
DynamicList<Size>& sizeList,
DynamicList<int>& ascentList);
void GenerateRegularLineRectList(Paragraph* paragraphPtr,
LineInfo* lineInfoPtr,
DynamicList<Size>& sizeList,
DynamicList<int>&ascentList);
void GenerateJustifiedLineRectList(Paragraph* paragraphPtr,
LineInfo* lineInfoPtr,
DynamicList<Size>& sizeList,
DynamicList<int>& ascentList);
void InvalidateRepaintSet(Paragraph* paragraphPtr,
DynamicList<CharInfo>& prevRectList);
DynamicList<CharInfo>& CharList() {return charList;}
本应用的一个核心部分是 wordMode
方法。在某个时刻,应用可以被设置为 编辑
模式(光标可见),在这种情况下 wordMode
是 WordEdit
方法,或者 标记
模式(文本的一部分被标记),在这种情况下 wordMode
是 WordMark
方法。在章节的后面,我们将遇到 编辑模式 和 标记模式 这样的表达式,它们指的是 wordMode
的值:WordEdit
或 WordMark
。
我们还会遇到 插入模式 和 覆盖模式 的表达式,它们指的是键盘的 input
模式,即 InsertKeyboard
或 OverwriteKeyboard
方法,这是 Small Windows 类 Document
中的 GetKeyboardMode
方法返回的。
totalPages
字段包含页数,这在打印和设置垂直滚动条时使用。字符列表存储在 charList
列表中,段落指针列表存储在 paragraphList
列表中。请注意,段落是动态创建和删除的 Paragraph
对象,而字符是静态的 CharInfo
对象。此外,请注意,每个段落不包含字符列表。只有一个 charList
,它是所有段落的公共部分。然而,每个段落都包含它自己的 Line
指针列表,这些指针是段落本地的。
在本章中,我们还将遇到诸如 编辑字符 这样的表达式,它指的是 charList
列表中索引为 editIndex
的字符。如本章开头所述,每个字符都有指向其段落和行的指针。编辑段落 和 编辑行 这些表达式指的是由编辑字符指向的段落和行。
firstMarkIndex
和 lastMarkIndex
字段包含在 mark
模式下第一个和最后一个标记字符的索引。它们也出现在诸如 第一个标记字符、第一个标记段落、第一个标记行 以及 最后一个标记字符、最后一个标记段落 和 最后一个标记行 等表达式中。请注意,这两个字段指的是时间顺序,而不一定是它们的物理顺序。当需要时,我们将定义 minIndex
和 maxIndex
方法来按物理顺序引用文档中的第一个和最后一个标记。
当用户在 edit
模式下设置字体时,它被存储在 nextFont
字体中,然后当用户输入下一个字符时使用。光标会考虑 nextFont
字体的状态,即如果 nextFont
字体不等于 ZeroFont
字体,它就会用来设置光标。然而,一旦用户做其他任何事情,nextFont
字体就会被清除。
用户可以通过菜单项或触摸屏幕来放大文档。在这种情况下,我们需要 initZoom
和 initDistance
字段来跟踪缩放。最后,我们需要 WordFormat
字段来识别剪切、复制和粘贴的应用程序特定信息。它被赋予任意值 1002。
private:
enum {WordEdit, WordMark} wordMode;
int totalPages;
DynamicList<CharInfo> charList;
DynamicList<Paragraph*> paragraphList;
int editIndex, firstMarkIndex, lastMarkIndex;
Font nextFont;
double initZoom, initDistance;
static const unsigned int WordFormat = 1002;
};
WordDocument.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "CharInfo.h"
#include "LineInfo.h"
#include "Paragraph.h"
#include "WordDocument.h"
WordDocument
构造函数调用 StandardDocument
构造函数。UnicodeFormat
和 AsciiFormat
方法是由 Small Windows 定义的通用格式,而 WordFormat
方法是特定于这个应用程序的。
WordDocument::WordDocument(WindowShow windowShow)
:StandardDocument(LogicalWithScroll, USLetterPortrait,
TEXT("Word Files, wrd; Text Files, txt"),
nullptr, OverlappedWindow, windowShow,
{WordFormat, UnicodeFormat, AsciiFormat},
{WordFormat, UnicodeFormat, AsciiFormat}) {
格式 菜单包含 字体 和 分页符 菜单项。与本书中较早的应用程序不同,我们向 StandardFileMenu
发送 true
。这表示我们希望在 文件 菜单中包含 页面设置、打印预览 和 打印 菜单项。
Menu menuBar(this);
menuBar.AddMenu(StandardFileMenu(true));
menuBar.AddMenu(StandardEditMenu());
Menu formatMenu(this, TEXT("F&ormat"));
formatMenu.AddItem(TEXT("&Font\tCtrl+F"), OnFont);
formatMenu.AddItem(TEXT("&Page Break\tCtrl+B"),
OnPageBreak, PageBreakEnable);
menuBar.AddMenu(formatMenu);
对齐 菜单包含左对齐、居中对齐、右对齐和两端对齐的选项:
Menu alignmentMenu(this, TEXT("&Alignment"));
alignmentMenu.AddItem(TEXT("&Left\tCtrl+L"), OnLeft,
nullptr, nullptr, LeftRadio);
alignmentMenu.AddItem(TEXT("&Center\tCtrl+E"), OnCenter,
nullptr, nullptr, CenterRadio);
alignmentMenu.AddItem(TEXT("&Right\tCtrl+R"), OnRight,
nullptr, nullptr, RightRadio);
alignmentMenu.AddItem(TEXT("&Justified\tCtrl+J"), OnJustified,
nullptr, nullptr, JustifiedRadio);
menuBar.AddMenu(alignmentMenu);
menuBar.AddMenu(StandardHelpMenu());
SetMenuBar(menuBar);
extraMenu
菜单仅用于加速器;请注意,我们不会将其添加到菜单栏中。菜单文本或其项目内容也不重要。我们只想允许用户通过按住 Ctrl 键并使用 Home 或 End 键,以及可能使用 Shift 键来跳转到文档的开始或结束。
Menu extraMenu(this);
extraMenu.AddItem(TEXT("&A\tCtrl+Home"), OnControlHomeKey);
extraMenu.AddItem(TEXT("&B\tCtrl+End"), OnControlEndKey);
extraMenu.AddItem(TEXT("&C\tShift+Ctrl+Home"),
OnShiftControlHomeKey);
extraMenu.AddItem(TEXT("&D\tShift+Ctrl+End"),
OnShiftControlEndKey);
最后,我们调用 InitDocument
方法来初始化空文档。InitDocument
方法也由 ClearDocument
和 Delete
类在以下情况下调用,当初始化代码放置在其自己的方法中时。
InitDocument();
}
文档始终至少包含一个段落,该段落又至少包含一个换行符。我们创建第一个字符和第一个左对齐的段落。段落和字符被添加到 paragraphList
和 charList
列表中。
然后,段落通过 GenerateParagraph
方法计算,并通过 CalculateDocument
方法在文档中分布。最后,通过 UpdateCaret
方法更新光标。
void WordDocument::InitDocument() {
wordMode = WordEdit;
editIndex = 0;
Paragraph* firstParagraphPtr = new Paragraph(0, 0, Left, 0);
assert(firstParagraphPtr != nullptr);
Font font(TEXT("Times New Roman"), 36, false, true);
charList.PushBack(CharInfo(firstParagraphPtr, NewLine, font));
GenerateParagraph(firstParagraphPtr);
paragraphList.PushBack(firstParagraphPtr);
CalculateDocument();
UpdateCaret();
}
光标
由于在本章中我们介绍了文本处理,我们需要跟踪光标:在 insert
模式下的闪烁垂直线或块(在 overwrite
模式下)指示输入字符的位置。UpdateCaret
方法由 OnKeyboardMode
方法(当用户按下 Insert 键时调用)以及其他方法调用,当输入位置正在修改时。
void WordDocument::OnKeyboardMode(KeyboardMode/*=KeyboardMode*/) {
UpdateCaret();
}
void WordDocument::UpdateCaret() {
switch (wordMode) {
case WordEdit: {
CharInfo charInfo = charList[editIndex];
Rect caretRect = charList[editIndex].CharRect();
在 edit
模式下,光标将可见,我们获取编辑字符所在区域。然而,如果 nextFont
字体处于活动状态(不等于 SystemFont
字体),用户已更改字体,我们必须考虑这一点。在这种情况下,我们将光标宽度和高度设置为 nextFont
字体平均字符的大小。
if (nextFont != SystemFont) {
int width = GetCharacterAverageWidth(nextFont),
height = GetCharacterHeight(nextFont);
caretRect.Right() = caretRect.Left() + width;
caretRect.Top() = caretRect.Bottom() - height;
}
如果 nextFont
字体未处于活动状态,我们检查键盘是否处于 insert
模式,并且光标是否不在段落开头。在这种情况下,光标的垂直坐标将反映前一个字符的字体大小,因为下一个要输入的字符将使用该字体。
else if ((GetKeyboardMode() == InsertKeyboard) &&
(charInfo.ParagraphPtr()->First() < editIndex)) {
Rect prevCharRect = charList[editIndex - 1].CharRect();
caretRect.Top() = caretRect.Bottom() – prevCharRect.Height();
}
如果键盘处于 insert
模式,无论 nextFont
字体是否处于活动状态,光标都将是一条垂直线。它被赋予一个单位宽度(这后来会被四舍五入到物理像素的宽度)。
if (GetKeyboardMode() == InsertKeyboard) {
caretRect.Right() = caretRect.Left() + 1;
}
光标不会超出页面范围。如果它超出了,其右边界将被设置为页面的边界。
if (caretRect.Right() >= PageInnerWidth()) {
caretRect.Right() = PageInnerWidth() - 1;
}
最后,我们需要编辑段落的顶部位置,因为光标到目前为止是相对于其顶部位置计算的。
Paragraph* paragraphPtr =
charList[editIndex].ParagraphPtr();
Point topLeft = Point(0, paragraphPtr->Top());
SetCaret(topLeft + caretRect);
}
break;
在 mark
模式下,光标将不可见。因此,我们按照以下方式调用 ClearCaret
:
case WordMark:
ClearCaret();
break;
}
}
鼠标输入
OnMouseDown
、OnMouseMove
、OnMouseUp
和 OnDoubleClick
方法接收按下的按钮和鼠标坐标。在所有四种情况下,我们检查是否按下了左键鼠标。OnMouseDown
方法首先调用 EnsureEditStatus
方法以清除任何潜在标记区域。然后它将应用程序设置为 mark
模式(这可能会稍后被 OnMouseUp
方法更改)并通过调用 MousePointToIndex
方法查找指向的字符的索引。通过调用 ClearNextFont
方法清除 nextFont
字段。我们还调用 UpdateCaret
方法,因为当用户拖动鼠标时,光标将被清除。
void WordDocument::OnMouseDown(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
EnsureEditStatus();
ClearNextFont();
wordMode = WordMark;
firstMarkIndex = lastMarkIndex =
MousePointToIndex(mousePoint);
UpdateCaret();
}
}
在 OnMouseMove
方法中,我们通过调用 MousePointToIndex
方法检索鼠标的段落和字符。如果自上次调用 OnMouseDown
或 OnMouseMove
方法以来鼠标已移动到新字符,我们通过调用 InvalidateBlock
方法并传递当前和新的鼠标位置来更新标记文本,这将使当前和上次鼠标事件之间的文本部分无效。请注意,我们不使整个标记块无效。我们只使前一个和当前鼠标位置之间的块无效,以避免闪烁。
void WordDocument::OnMouseMove(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
int newLastMarkIndex = MousePointToIndex(mousePoint);
if (lastMarkIndex != newLastMarkIndex) {
InvalidateBlock(lastMarkIndex, newLastMarkIndex);
lastMarkIndex = newLastMarkIndex;
}
}
}
在 OnMouseUp
方法中,我们只需检查最后一个位置。如果它与第一个位置相同(用户在同一个字符上按下并释放鼠标),我们将应用程序更改为 edit
模式并调用 UpdateCaret
方法以使光标可见。
void WordDocument::OnMouseUp(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
if (mouseButtons == LeftButton) {
if (firstMarkIndex == lastMarkIndex) {
wordMode = WordEdit;
editIndex = min(firstMarkIndex, charList.Size() - 1);
UpdateCaret();
}
}
}
MousePointToIndex
方法用于找到用户点击的段落,并调用 MousePointToParagraphIndex
方法来找到段落中的字符。我们将功能分为两个方法的原因是,第七章中的 MousePointToIndexDown
方法,键盘输入和字符计算,也调用了 MousePointToParagraphIndex
方法,该方法遍历段落列表。如果垂直位置小于段落的顶部位置,则正确的段落是前一个段落。
这种寻找正确段落的略显繁琐的方法是由于段落以这种方式分布在页面上,即当段落无法适应页面的其余部分,或者如果它带有分页符时,它被放置在下一页的开头。这可能会导致文档中没有任何段落的位置。如果用户点击这样的区域,我们希望该区域之前的段落是正确的。同样,如果用户点击文档的最后一个段落下方,它将成为正确的段落。
int WordDocument::MousePointToIndex(Point mousePoint) const{
for (int parIndex = 1; parIndex < paragraphList.Size();
++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
if (mousePoint.Y() < paragraphPtr->Top()) {
return MousePointToParagraphIndex
(paragraphList[parIndex - 1], mousePoint);
}
}
return MousePointToParagraphIndex
(paragraphList[paragraphList.Size() - 1], mousePoint);
}
MousePointToParagraphIndex
方法用于找到段落中点击的字符。首先,我们从鼠标位置中减去段落的顶部位置,因为段落的行坐标是相对于段落顶部位置的。
int WordDocument::MousePointToParagraphIndex
(Paragraph* paragraphPtr,Point mousePoint) const{
mousePoint.Y() -= paragraphPtr->Top();
如前所述,用户可能点击在段落区域下方的一个位置。在这种情况下,我们将鼠标位置设置为它的-1
高度,这相当于用户点击了段落的最后一行。
if (mousePoint.Y() >= paragraphPtr->Height()) {
mousePoint.Y() = paragraphPtr->Height() - 1;
}
首先,我们需要在段落中找到正确的行。我们检查每一行,并通过将其与行的顶部位置和高度的加和进行比较来测试鼠标位置是否位于行内。与之前提到的MousePointToIndex
方法中的段落搜索相比,这个搜索要简单一些,因为段落中的行之间没有空格,而文档中的段落之间可能有空格。
int firstChar = paragraphPtr->First();
for (LineInfo* lineInfoPtr : paragraphPtr->LinePtrList()) {
if (mousePoint.Y() < (lineInfoPtr->Top() +
lineInfoPtr->Height())) {
Rect firstRect =
charList[firstChar +lineInfoPtr->First()].CharRect(),
lastRect =
charList[firstChar + lineInfoPtr->Last()].CharRect();
当我们找到正确的行时,我们需要考虑三种情况:用户可能点击了文本的左侧(如果段落是居中或右对齐的),右侧(如果它是左对齐或居中对齐的),或者文本本身。如果他们点击了行的左侧或右侧,我们返回行的第一个或最后一个字符的索引。请注意,我们添加了段落第一个字符的索引,因为行的索引是相对于段落第一个索引的。
if (mousePoint.X() < firstRect.Left()) {
return paragraphPtr->First() + lineInfoPtr->First();
}
else if (lastRect.Right() <= mousePoint.X()) {
return paragraphPtr->First() + lineInfoPtr->Last();
}
如果用户点击了文本,我们需要找到正确的字符。我们遍历行的字符,并将鼠标位置与字符的右侧边界进行比较。当我们找到正确的字符时,我们需要决定用户是否点击了字符的左侧或右侧边界。在右侧边界的情况下,我们将字符索引加一。
else {
for (int charIndex = lineInfoPtr->First();
charIndex <= lineInfoPtr->Last(); ++charIndex) {
Rect charRect = charList[charIndex].CharRect();
if (mousePoint.X() < charRect.Right()) {
int leftSize = mousePoint.X() - charRect.Left(),
rightSide = charRect.Right() - mousePoint.X();
return paragraphPtr->First() +
((leftSize < rightSide) ? charIndex
: (charIndex + 1));
}
}
}
}
}
如前所述,段落中的行与行之间没有空格。因此,我们总能找到正确的行,永远不会达到这个点。然而,为了避免编译器错误,我们仍然必须返回一个值。在这本书中,我们会在少数情况下使用以下符号:
assert(false);
return 0;
}
void WordDocument::InvalidateBlock(int firstIndex, int lastIndex){
int minIndex = min(firstIndex, lastIndex),
maxIndex = min(max(firstIndex, lastIndex).
charList.Size() - 1);
for (int charIndex = minIndex; charIndex <= maxIndex;
++charIndex) {
CharInfo charInfo = charList[charIndex];
Point topLeft(0, charInfo.ParagraphPtr()->Top());
Invalidate(topLeft + charInfo.CharRect());
}
}
当用户双击鼠标左键时,鼠标击中的单词将被标记。应用已被设置为编辑
模式,并且editIndex
方法已经被适当地设置,因为对OnDoubleClick
方法的调用总是先于对OnMouseDown
和OnMouseUp
方法的调用。如果鼠标击中了一个单词,我们将标记该单词并将应用设置为标记
模式。
我们通过调用GetFirstWordIndex
和GetLastWordIndex
方法来找到单词的第一个和最后一个字符的索引。如果第一个索引小于最后一个索引,用户实际上双击了一个单词,我们将它标记。如果第一个索引不小于最后一个索引,用户双击了空格或分隔符,在这种情况下,双击没有效果。
void WordDocument::OnDoubleClick(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {
int firstIndex = GetFirstWordIndex(editIndex),
lastIndex = GetLastWordIndex(editIndex);
if (firstIndex < lastIndex) {
wordMode = WordMark;
firstMarkIndex = firstIndex;
lastMarkIndex = lastIndex;
UpdateCaret();
InvalidateBlock(firstMarkIndex, lastMarkIndex);
UpdateWindow();
}
}
在GetFirstWordIndex
方法中,我们通过在字符列表中向后移动直到我们到达文档的开始或一个非字母字符来找到单词的第一个字符的索引。
int WordDocument::GetFirstWordIndex(int charIndex) const{
while ((charIndex >= 0) &&
(isalpha(charList[charIndex].Char()))) {
--charIndex;
}
return (charIndex + 1);
}
在GetLastWordIndex
方法中,我们不需要检查字符列表的末尾,因为最后一个字符始终是换行符,它不是一个字母。请注意,在这种情况下,我们返回单词最后一个字符之后的字符索引,因为文本标记有效到,但不包括最后一个字符。
int WordDocument::GetLastWordIndex(int charIndex) const{
while (isalpha(charList[charIndex].Char())) {
++charIndex;
}
return charIndex;
}
触摸屏
在触摸屏上,用户可以通过在屏幕上拖动两个手指来缩放文档。当用户触摸屏幕时,会调用OnTouchDown
方法,当用户移动手指时,会调用OnTouchMove
方法。与之前提到的鼠标输入方法不同,用户可以同时触摸屏幕上的多个点。这些点存储在pointList
列表中。
如果列表不包含两个点,我们只需让Window
类执行默认操作,即将每个触摸动作转换为鼠标动作。
void WordDocument::OnTouchDown(vector<Point> pointList) {
if (pointList.size() == 2) {
initZoom = GetZoom();
Point firstInitPoint = pointList[0],
secondInitPoint = pointList[1];
double width = firstInitPoint.X() - secondInitPoint.X(),
height = firstInitPoint.Y() - secondInitPoint.Y(),
initDistance = sqrt((width * width) + (height * height));
}
else {
Window::OnTouchDown(pointList);
}
}
当用户在屏幕上移动手指时,会计算手指之间的距离,并根据初始距离设置缩放。缩放的范围允许在 10%(因子 0.1)和 1,000%(因子 10.0)之间:
void WordDocument::OnTouchMove(vector<Point> pointList) {
if (pointList.size() == 2) {
Point firstPoint = pointList[0], secondPoint = pointList[1];
int width = firstPoint.X() - secondPoint.X(),
height = firstPoint.Y() - secondPoint.Y();
double distance = sqrt((width * width) + (height * height));
double factor = distance / initDistance;
double newZoom = factor * initZoom;
SetZoom(min(max(newZoom, 0.1), 10.0));
UpdateCaret();
Invalidate();
UpdateWindow();
}
else {
Window::OnTouchMove(pointList);
}
}
页面设置和计算
当用户在文件菜单中选择标准页面设置菜单项时,会调用OnPageSetup
方法。由于页面设置已被更改,我们需要重新计算每个段落以及整个文档。
void WordDocument::OnPageSetup(PageSetupInfo pageSetupInfo) {
ClearNextFont();
for (Paragraph* paragraphPtr : paragraphList) {
GenerateParagraph(paragraphPtr);
}
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
一个小的变化可能会影响整个文档,我们需要计算段落并将它们分配到文档的页面上。
void WordDocument::CalculateDocument() {
int pageInnerWidth = PageInnerWidth(),
pageInnerHeight = PageInnerHeight(),
documentHeight = 0, newTotalPages = 1;
我们遍历段落列表,如果当前文档高度与段落的顶部位置不同,我们更新其顶部位置并使其无效。
for (int parIndex = 0; parIndex < paragraphList.Size();
++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
if (paragraphPtr->Top() != documentHeight) {
paragraphPtr->Top() = documentHeight;
Invalidate(Rect(0, paragraphPtr->Top(), pageInnerWidth,
paragraphPtr->Top() + paragraphPtr->Height()));
}
如果段落被标记为分页,并且它尚未位于页面顶部,则会有一个分页符。
bool pageBreak = paragraphPtr->PageBreak() &&
((paragraphPtr->Top() % pageInnerHeight) != 0);
如果段落的顶部位置加上其高度大于页面高度,则段落无法适应页面的剩余部分。
bool notFitOnPage =
(documentHeight > 0) &&
((paragraphPtr->Top() + paragraphPtr->Height()) >
(newTotalPages * pageInnerHeight));
如果有分页符,或者如果段落无法适应页面的其余部分,我们需要使页面的其余部分无效,并将段落放置在下一页的顶部。
if (pageBreak || notFitOnPage) {
Rect restOfPage(0, documentHeight, pageInnerWidth,
newTotalPages * pageInnerHeight);
Invalidate(restOfPage);
paragraphPtr->Top() = (newTotalPages++) * pageInnerHeight;
由于段落已移动到新位置,我们需要使其新区域无效。
Invalidate(Rect(0, paragraphPtr->Top(), pageInnerWidth,
paragraphPtr->Top() + paragraphPtr->Height()));
documentHeight = paragraphPtr->Top() +
paragraphPtr->Height();
}
如果段落可以适应文档的其余部分,我们只需增加文档高度。
else {
documentHeight += paragraphPtr->Height();
}
}
在最后一个段落之后,我们需要使最后一页的其余部分无效。
Rect restOfPage(0, documentHeight, pageInnerWidth,
newTotalPages * pageInnerHeight);
Invalidate(restOfPage);
如果页数已更改,我们需要使不同的页面无效。
if (totalPages != newTotalPages) {
int minTotalPages = min(totalPages, newTotalPages),
maxTotalPages = max(totalPages, newTotalPages);
Invalidate(Rect(0, minTotalPages * pageInnerHeight,
pageInnerWidth, maxTotalPages * pageInnerHeight));
totalPages = newTotalPages;
SetVerticalScrollTotalHeight(totalPages * pageInnerHeight);
}
}
绘制和绘图
OnPaint
方法执行特定于绘制客户端区域的动作,而OnPrint
方法执行特定于打印的动作。在StandardDocument
类中,OnPaint
和OnPrint
方法的默认行为是调用OnDraw
方法。
在前几章的应用中,我们只重写了 OnDraw
方法,导致无论绘图是在客户端区域发生还是发送到打印机,绘图结果都相同。然而,在这个应用中,我们还重写了 OnPaint
方法,该方法用浅灰色填充客户端区域之外的文档部分,并在每对页面之间放置文本 分页符,最后调用执行实际文档绘制的 OnDraw
方法。
void WordDocument::OnPaint(Graphics& graphics) const {
int pageInnerWidth = PageInnerWidth(),
pageInnerHeight = PageInnerHeight();
int documentInnerHeight = totalPages * pageInnerHeight;
Size clientSize = GetClientSize();
if (pageInnerWidth() < clientSize.Width()) {
int maxHeight = max(documentInnerHeight, clientSize.Height());
Rect rightRect(pageInnerWidth, 0,
clientSize.Width(), maxHeight);
graphics.FillRectangle(rightRect, LightGray, LightGray);
}
if (documentInnerHeight() < clientSize.Height()) {
Rect bottomRect(0, documentInnerHeight(),
pageInnerWidth(), clientSize.Height());
graphics.FillRectangle(bottomRect, LightGray, LightGray);
}
OnDraw(graphics, Paint);
int breakWidth = min(clientSize.Width()),
breakHeight = GetCharacterHeight(SystemFont);
Size breakSize(breakWidth, breakHeight);
for (int pageIndex = 1; pageIndex < totalPages; ++pageIndex) {
int line = pageIndex * pageInnerHeight;
graphics.DrawLine(Point(0, line), Point(pageInnerWidth, line),
Black);
Point topLeft(0, line - (breakHeight / 2));
graphics.DrawText(Rect(topLeft, breakSize),
TEXT("Page Break"), SystemFont,Black,White);
}
}
OnDraw
方法绘制 charList
列表中的每个字符。当 OnDraw
方法由 OnPaint
方法调用时,drawMode
参数为 Paint
,而当它由 OnPrint
方法调用时,drawMode
参数为 Print
。在先前的应用中,我们忽略了 drawMode
方法。然而,在这个应用中,如果由 OnPaint
方法调用,我们会在每个带有分页符的段落处绘制一个小方块。
void WordDocument::OnDraw(Graphics& graphics, DrawMode drawMode) const {
minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
for (int charIndex = 0; charIndex <= charList.Size() - 1;
++charIndex) {
CharInfo charInfo = charList[charIndex];
Point topLeft(0, charInfo.ParagraphPtr()->Top());
Color textColor = charInfo.CharFont().GetColor();
Color backColor = textColor.Inverse();
如果字符被标记,其文本和背景颜色将被反转。
if ((wordMode == WordMark) &&
(minCharIndex <= charIndex)&&(charIndex < maxCharIndex)) {
swap(textColor, backColor);
}
如果字符是换行符,则绘制一个空格代替。
TCHAR tChar = (charInfo.Char() == NewLine)
? Space: charInfo.Char();
TCHAR text[] = {tChar, TEXT('\0')};
如果字符的矩形位于页面之外,其右边界被设置为页面右边界。
Rect charRect = charList[charIndex].CharRect();
if (charRect.Right() >= pageWidth) {
charRect.Right() = pageWidth - 1;
}
最后,绘制字符:
graphics.DrawText(topLeft + charRect, text,
charInfo.CharFont(), textColor, backColor);
}
实际上,还有一件事:如果 OnDraw
方法已经被 OnPaint
方法调用,我们会在每个带有分页符的段落的左上角绘制一个小红色方块(2 × 2 毫米)。
if (drawMode == Paint) {
for (Paragraph* paragraphPtr : paragraphList) {
if (paragraphPtr->PageBreak()) {
Point topLeft(0, paragraphPtr->Top());
graphics.FillRectangle(Rect(topLeft, topLeft +
Size(200, 200)), Red, Red);
}
}
}
}
文件管理
当用户在 文件 菜单中选择 新建 菜单项时,StandardDocument
类会调用 ClearDocument
方法;当用户在 文件 菜单中选择 保存 或 另存为 菜单项时,会调用 WriteDocumentToStream
方法,而当用户选择 打开 菜单项时,会调用 ReadDocumentFromStream
方法。
ClearDocument
方法通过调用 DeleteParagraph
方法删除 paragraphList
列表中的每个段落,而 DeleteParagraph
方法会删除段落的每一行。实际上,这是我们唯一需要删除的内存,因为它是本应用唯一动态分配的内存。最后,会调用 InitDocument
方法,该方法初始化一个空文档。
void DeleteParagraph(Paragraph* paragraphPtr) {
for (LineInfo* lineInfoPtr : paragraphPtr->LinePtrList()) {
delete lineInfoPtr;
}
delete paragraphPtr;
}
void WordDocument::ClearDocument() {
nextFont = SystemFont;
for (Paragraph* paragraphPtr : paragraphList) {
DeleteParagraph(paragraphPtr);
}
charList.Clear();
paragraphList.Clear();
InitDocument();
}
WriteDocumentToStream
方法将有关文档的所有信息写入流:application
模式(编辑或标记)、编辑字符的索引、第一个和最后一个标记字符的索引、文档中的页数以及下一个字体。想法是文档将以写入时的确切形状打开。
bool WordDocument::WriteDocumentToStream(String name,
ostream& outStream)const{
if (EndsWith(name, TEXT(".wrd")) &&
WritePageSetupInfoToStream(outStream)){
outStream.write((char*) &wordMode, sizeof wordMode);
outStream.write((char*) &editIndex, sizeof editIndex);
outStream.write((char*) &firstMarkIndex,
sizeof firstMarkIndex);
outStream.write((char*) &lastMarkIndex, sizeof lastMarkIndex);
outStream.write((char*) &totalPages, sizeof totalPages);
nextFont.WriteFontToStream(outStream);
{ int charInfoListSize = charList.Size();
outStream.write((char*) &charInfoListSize,
sizeof charInfoListSize);
for (CharInfo charInfo : charList) {
charInfo.WriteCharInfoToStream(outStream);
}
}
{ int paragraphListSize = paragraphList.Size();
outStream.write((char*) ¶graphListSize,
sizeof paragraphListSize);
for (const Paragraph* paragraphPtr : paragraphList) {
paragraphPtr->WriteParagraphToStream(outStream);
}
}
}
然而,如果文件扩展名是 .txt
,我们将单词以文本格式保存并丢弃所有格式。
else if (EndsWith(name, TEXT(".txt"))) {
for (CharInfo charInfo : charList) {
char c = (char) charInfo.Char();
outStream.write(&c, sizeof c);
}
}
return ((bool) outStream);
}
ReadDocumentFromStream
方法读取由 WriteDocumentToStream
方法写入的信息。请注意,为了使当前位置可见,在最后会调用 MakeVisible
方法。
bool WordDocument::ReadDocumentFromStream(String name,
istream& inStream) {
if (EndsWith(name, TEXT(".wrd")) &&
ReadPageSetupInfoFromStream(inStream)){
inStream.read((char*) &wordMode, sizeof wordMode);
inStream.read((char*) &editIndex, sizeof editIndex);
inStream.read((char*) &firstMarkIndex, sizeof firstMarkIndex);
inStream.read((char*) &lastMarkIndex, sizeof lastMarkIndex);
inStream.read((char*) &totalPages, sizeof totalPages);
nextFont.ReadFontFromStream(inStream);
{ charList.Clear();
int charInfoListSize;
inStream.read((char*) &charInfoListSize,
sizeof charInfoListSize);
for (int count = 0; count < charInfoListSize; ++count) {
CharInfo charInfo;
charInfo.ReadCharInfoFromStream(inStream);
charList.PushBack(charInfo);
}
}
{ paragraphList.Clear();
int paragraphListSize;
inStream.read((char*) ¶graphListSize,
sizeof paragraphListSize);
for (int count = 0; count < paragraphListSize; ++count) {
Paragraph* paragraphPtr = new Paragraph();
assert(paragraphPtr != nullptr);
paragraphPtr->ReadParagraphFromStream(this, inStream);
paragraphList.PushBack(paragraphPtr);
}
}
}
然而,如果文件具有文件扩展名 .txt
,我们只读取字符,并且所有字符都赋予系统字体。
else if (EndsWith(name, TEXT(".txt"))) {
wordMode = WordEdit;
editIndex = 0;
firstMarkIndex = 0;
lastMarkIndex = 0;
totalPages = 0;
nextFont = SystemFont;
Paragraph* paragraphPtr = new Paragraph(0, 0, Left, 0);
int charIndex = 0, paragraphIndex = 0;
char c;
while (inStream >> c) {
CharInfo charInfo(paragraphPtr, (TCHAR) c,
SystemFont, ZeroRect);
charList.PushBack(charInfo);
if (c == '\n') {
paragraphPtr->Last() = charIndex;
for (int index = paragraphPtr->First();
index <= paragraphPtr->Last(); ++index) {
charList[index].ParagraphPtr() = paragraphPtr;
}
GenerateParagraph(paragraphPtr);
paragraphList.PushBack(paragraphPtr);
Paragraph* paragraphPtr =
new Paragraph(charIndex + 1, 0, Left, ++paragraphIndex);
}
++charIndex;
}
paragraphPtr->Last() = charIndex;
for (int index = paragraphPtr->First();
index <= paragraphPtr->Last(); ++index) {
charList[index].ParagraphPtr() = paragraphPtr;
}
GenerateParagraph(paragraphPtr);
paragraphList.PushBack(paragraphPtr);
CalculateDocument();
}
MakeVisible();
return ((bool) inStream);
}
剪切、复制和粘贴
编辑菜单中的复制项在mark
模式下是启用的:
bool WordDocument::CopyEnable() const {
return (wordMode == WordMark);
}
只要之前提到的CopyEnable
方法返回true
,我们就始终准备好以每种格式进行复制。因此,我们必须让IsCopyAsciiReady
、IsCopyUnicodeReady
和IsCopyGenericReady
方法返回true
(如果它们在StandardDocument
类中返回false
)。
bool WordDocument::IsCopyAsciiReady() const {
return true;
}
bool WordDocument::IsCopyUnicodeReady() const {
return true;
}
bool WordDocument::IsCopyGenericReady(int /* format */) const {
return true;
}
CopyAscii
方法简单地调用CopyUnicode
方法,因为文本以通用文本格式存储,并在保存到全局剪贴板时转换为 ASCII 和 Unicode。CopyUnicode
方法遍历标记的段落,并且对于每个标记段落,将存储在段落中的标记文本提取到textList
参数中。当它遇到换行符时,它将textList
参数中的当前文本推入。
void WordDocument::CopyAscii(vector<String>& textList) {
CopyUnicode(textList);
}
void WordDocument::CopyUnicode(vector<String>& textList) {
int minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
String text;
for (int charIndex = minCharIndex; charIndex < maxCharIndex;
++charIndex) {
CharInfo charInfo = charList[charIndex];
text.push_back(charInfo.Char());
if (charInfo.Char() == NewLine) {
textList.push_back(text);
text.clear();
}
}
textList.push_back(text);
}
CopyGeneric
方法比CopyUnicode
方法简单。它首先保存要复制的字符数,然后遍历标记的字符(不是段落),然后对每个字符调用WriteCharInfoToClipboard
方法。这可行,因为charList
列表中每对段落之间已经通过换行符分隔。我们实际上并不关心格式,因为在这个应用程序中,通用剪切、复制和粘贴操作只有一个格式(WordFormat
)。
void WordDocument::CopyGeneric(int /* format */,
InfoList& infoList) const {
int minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
int copySize = maxCharIndex - minCharIndex;
infoList.AddValue<int>(copySize);
for (int charIndex = minCharIndex; charIndex < maxCharIndex;
++charIndex) {
CharInfo charInfo = charList[charIndex];
charInfo.WriteCharInfoToClipboard(infoList);
}
}
复制和粘贴之间的一个区别在于,当用户选择剪切或复制时,在先前的StandardDocument
构造函数中给出的所有三种格式(ASCII、Unicode 和通用)都会被复制。它们的顺序并不重要。另一方面,在粘贴时,StandardDocument
构造函数会尝试按照构造函数调用中给出的格式顺序粘贴文本。如果它在全局剪贴板中找到一个格式的粘贴信息,它就不会继续检查其他格式。在这个应用程序中,这意味着如果以通用格式(WordFormat
)复制了文本,那么无论 ASCII 或 Unicode 格式(AsciiFormat
或UnicodeFormat
)中是否有文本,都会粘贴该文本。
PasteAscii
方法调用PasteUnicode
方法(再次,ASCII 和 Unicode 文本都被转换成通用文本类型),它遍历textList
参数,并为每个文本插入一个新的段落。请注意,我们没有重写PasteEnable
方法,因为StandardDocument
构造函数通过检查是否存在包含在StandardDocument
构造函数调用中定义的任何格式的剪贴板缓冲区来处理它。
理念是文本列表中的第一和最后一段文本将通过编辑段落的第一个和最后部分合并。潜在剩余的文本将作为段落插入其中。首先,如果存在标记文本,我们确保edit
模式,并清除nextFont
参数(将其设置为SystemFont
)。
void WordDocument::PasteUnicode(const vector<String>& textList) {
if (wordMode == WordMark) {
Delete(firstMarkIndex, lastMarkIndex);
EnsureEditStatus();
}
else {
ClearNextFont();
}
我们从段落列表中移除了编辑段落,这使得稍后插入粘贴的段落更加容易。
Paragraph* paragraphPtr = charList[editIndex].ParagraphPtr();
paragraphList.Erase(paragraphPtr->Index());
我们为粘贴的字符和段落使用编辑字符的字体和编辑段落的对齐方式。
Alignment alignment = paragraphPtr->AlignmentField();
Font font = charList[editIndex].CharFont();
我们保存编辑段落剩余字符的数量。我们还保存当前编辑索引,以便计算最终粘贴字符的总数。
int restChars = paragraphPtr->Last() - editIndex,
prevEditIndex = editIndex, textListSize = textList.size();
我们将编辑段落中的每个文本的字符插入。
for (int textIndex = 0; textIndex < textListSize; ++textIndex) {
for (TCHAR tChar : textList[textIndex]) {
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, font));
}
由于每个文本都将完成一个段落(除了最后一个),我们创建并插入一个新的段落。
if (textIndex < (textListSize - 1)) {
charList.Insert(editIndex++,
CharInfo(paragraphPtr, NewLine));
paragraphPtr->Last() = editIndex - 1;
for (int index = paragraphPtr->First();
index <= paragraphPtr->Last(); ++index) {
charList[index].ParagraphPtr() = paragraphPtr;
}
GenerateParagraph(paragraphPtr);
paragraphList.Insert(paragraphPtr->Index(), paragraphPtr);
paragraphPtr = new Paragraph(editIndex, 0, alignment,
paragraphPtr->Index() + 1);
}
对于最后一段文本,我们使用原始编辑段落并更改其最后一个字符索引。
else {
paragraphPtr->Last() = editIndex + restChars;
for (int index = paragraphPtr->First();
index <= paragraphPtr->Last(); ++index) {
charList[index].ParagraphPtr() = paragraphPtr;
}
GenerateParagraph(paragraphPtr);
paragraphList.Insert(paragraphPtr->Index(), paragraphPtr);
}
}
我们可能还需要更新后续段落的索引,因为可能粘贴了多个段落。由于我们知道至少粘贴了一个字符,我们肯定需要至少修改后续段落的第一和最后一个索引。
int totalAddedChars = editIndex - prevEditIndex;
for (int parIndex = paragraphPtr->Index() + 1;
parIndex < paragraphList.Size(); ++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
paragraphPtr->Index() = parIndex;
paragraphPtr->First() += totalAddedChars;
paragraphPtr->Last() += totalAddedChars;
}
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
PasteGeneric
方法以类似于先前的 PasteUnicode
方法的方式读取并插入存储在剪贴板中的通用段落信息。不同之处在于段落被分隔成换行符,并且每个粘贴的字符都带有自己的字体。
void WordDocument::PasteGeneric(int /* format */,
InfoList& infoList) {
if (wordMode == WordMark) {
Delete(firstMarkIndex, lastMarkIndex);
EnsureEditStatus();
}
else {
ClearNextFont();
}
我们擦除编辑段落以使插入更容易,就像在先前的 PasteUnicode
方法中一样。我们使用编辑段落的对齐方式,但不使用编辑字符的字体,因为每个粘贴的字符都有自己的字体。
Paragraph* paragraphPtr = charList[editIndex].ParagraphPtr();
paragraphList.Erase(paragraphPtr->Index());
Alignment alignment = paragraphPtr->AlignmentField();
我们读取粘贴的大小,即要粘贴的字符数。
int pasteSize, restChars = paragraphPtr->Last() - editIndex;
infoList.GetValue<int>(pasteSize);
我们从粘贴缓冲区中读取每个字符并将字符插入到字符列表中。当我们遇到换行符时,我们插入一个新段落。
for (int pasteCount = 0; pasteCount < pasteSize; ++pasteCount) {
CharInfo charInfo(paragraphPtr);
charInfo.ReadCharInfoFromClipboard(infoList);
charList.Insert(editIndex++, charInfo);
if (charInfo.Char() == NewLine) {
paragraphPtr->Last() = editIndex - 1;
GenerateParagraph(paragraphPtr);
paragraphList.Insert(paragraphPtr->Index(), paragraphPtr);
paragraphPtr = new Paragraph(editIndex, 0, alignment,
paragraphPtr->Index() + 1);
assert(paragraphPtr != nullptr);
}
}
paragraphPtr->Last() = editIndex + restChars;
for (int charIndex = editIndex;
charIndex <= paragraphPtr->Last(); ++charIndex) {
charList[charIndex].ParagraphPtr() = paragraphPtr;
}
在插入之前,我们需要计算原始段落。
GenerateParagraph(paragraphPtr);
paragraphList.Insert(paragraphPtr->Index(), paragraphPtr);
与前面的 PasteUnicode
情况类似,我们可能需要更新后续段落的索引,因为可能粘贴了多个段落。我们还需要修改它们的第一和最后一个索引,因为至少粘贴了一个字符。
for (int parIndex = paragraphPtr->Index() + 1;
parIndex < paragraphList.Size(); ++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
paragraphPtr->Index() = parIndex;
paragraphPtr->First() += pasteSize;
paragraphPtr->Last() += pasteSize;
}
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
删除
在 edit
模式下,除非字符位于文档的末尾,否则可以删除字符。在 mark
模式下,标记的文本始终可以被删除:
bool WordDocument::DeleteEnable() const {
switch (wordMode) {
case WordEdit:
return (editIndex < (charList.Size() - 1));
case WordMark:
return true;
}
return false;
}
在 edit
模式下,我们删除编辑字符,在 mark
模式下,我们删除标记的文本。在这两种情况下,我们都调用 Delete
方法来执行实际的删除操作。
void WordDocument::OnDelete() {
switch (wordMode) {
case WordEdit:
ClearNextFont();
Delete(editIndex, editIndex + 1);
break;
case WordMark:
Delete(firstMarkIndex, lastMarkIndex);
editIndex = min(firstMarkIndex, lastMarkIndex);
wordMode = WordEdit;
break;
}
SetDirty(true);
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
Delete
方法由 OnDelete
、EnsureEditStatus
、PasteUnicode
和 PasteGeneric
方法调用。它删除给定索引之间的字符,这些字符不必按顺序排列。被删除的段落被删除,后续段落被更新。
void WordDocument::Delete(int firstIndex, int lastIndex) {
int minCharIndex = min(firstIndex, lastIndex),
maxCharIndex = max(firstIndex, lastIndex);
Paragraph* minParagraphPtr =
charList[minCharIndex].ParagraphPtr();
Paragraph* maxParagraphPtr =
charList[maxCharIndex].ParagraphPtr();
被删除的区域至少覆盖两个段落,我们将最大段落的字符设置为指向最小段落,因为它们将被合并。我们还将它们的矩形设置为零,以确保它们将被重新绘制。
if (minParagraphPtr != maxParagraphPtr) {
for (int charIndex = maxParagraphPtr->First();
charIndex <= maxParagraphPtr->Last(); ++charIndex) {
CharInfo& charInfo = charList[charIndex];
charInfo.ParagraphPtr() = minParagraphPtr;
charInfo.CharRect() = ZeroRect;
}
}
字符将从charList
列表中删除,并且最小段落的最后一个索引被更新。它被设置为最大段落(可能和最小段落相同)的最后一个字符减去要删除的字符数。然后重新生成最小段落。
int deleteChars = maxCharIndex - minCharIndex;
minParagraphPtr->Last() = maxParagraphPtr->Last() - deleteChars;
charList.Remove(minCharIndex, maxCharIndex - 1);
GenerateParagraph(minParagraphPtr);
如果存在,最小和最大段落之间的段落被删除,并且后续段落的索引被设置。我们为每个段落调用DeleteParagraph
以删除它们的动态分配的内存。
int minParIndex = minParagraphPtr->Index(),
maxParIndex = maxParagraphPtr->Index();
if (minParIndex < maxParIndex) {
for (int parIndex = minParIndex + 1;
parIndex <= maxParIndex; ++parIndex) {
DeleteParagraph(paragraphList[parIndex]);
}
paragraphList.Remove(minParIndex + 1, maxParIndex);
}
最后,我们需要设置后续段落的索引。请注意,无论是否已经删除了任何段落,我们都必须更新第一个和最后一个索引,因为我们至少删除了一个字符。
int deleteParagraphs = maxParIndex - minParIndex;
for (int parIndex = minParagraphPtr->Index() + 1;
parIndex < paragraphList.Size(); ++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
paragraphPtr->Index() -= deleteParagraphs;
paragraphPtr->First() -= deleteChars;
paragraphPtr->Last() -= deleteChars;
}
当删除过程完成后,应用程序设置为编辑
模式,并且编辑索引被设置为第一个标记的字符。
wordMode = WordEdit;
editIndex = minCharIndex;
}
分页符
在编辑
模式下,分页符菜单项被启用,并且OnPageBreak
方法也非常简单。它只是反转编辑段落的分页符状态:
bool WordDocument::PageBreakEnable() const {
return (wordMode == WordEdit);
}
void WordDocument::OnPageBreak() {
Paragraph* paragraphPtr = charList[editIndex].ParagraphPtr();
paragraphPtr->PageBreak() = !paragraphPtr->PageBreak();
CalculateDocument();
UpdateCaret();
}
字体
当用户选择字体菜单项并显示字体对话框时,会调用OnFont
方法。在编辑
模式下,我们首先需要找到对话框中要使用的默认字体。如果nextFont
参数是活动的(不等于SystemFont
),我们使用它。如果不是活动的,我们检查编辑字符是否是段落的第一个字符。如果是第一个字符,我们使用它的字体。如果不是第一个字符,我们使用其前一个字符的字体。这与前面的UpdateCaret
方法中的相同程序:
void WordDocument::OnFont() {
switch (wordMode) {
case WordEdit: {
Font font;
if (nextFont != SystemFont) {
font = nextFont;
}
else if (editIndex ==
charList[editIndex].ParagraphPtr()->First()) {
font = charList[editIndex].CharFont();
}
else {
font = charList[editIndex - 1].CharFont();
}
如果用户通过选择确定来关闭字体对话框,我们将设置nextFont
参数并重新计算编辑段落。
if (StandardDialog::FontDialog(this, font)) {
nextFont = font;
Paragraph* paragraphPtr =
charList[editIndex].ParagraphPtr();
GenerateParagraph(paragraphPtr);
SetDirty(true);
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
}
break;
在标记
模式下,我们选择具有最低索引的标记字符作为字体对话框中的默认字体。
case WordMark: {
int minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
Font font = charList[minCharIndex].CharFont();
如果用户选择确定,我们将设置每个标记字符的字体并重新计算它们的每个段落。
if (StandardDialog::FontDialog(this, font)) {
for (int charIndex = minCharIndex;
charIndex < maxCharIndex; ++charIndex) {
charList[charIndex].CharFont() = font;
}
int minParIndex =
charList[minCharIndex].ParagraphPtr()->Index(),
maxParIndex =
charList[maxCharIndex].ParagraphPtr()->Index();
for (int parIndex = minParIndex;
parIndex <= maxParIndex; ++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
GenerateParagraph(paragraphPtr);
}
SetDirty(true);
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
}
break;
}
}
对齐
所有单选对齐监听器调用IsAlignment
方法,所有选择监听器调用SetAlignment
方法。
bool WordDocument::LeftRadio() const {
return IsAlignment(Left);
}
void WordDocument::OnLeft() {
SetAlignment(Left);
}
bool WordDocument::CenterRadio() const {
return IsAlignment(Center);
}
void WordDocument::OnCenter() {
SetAlignment(Center);
}
bool WordDocument::RightRadio() const {
return IsAlignment(Right);
}
void WordDocument::OnRight() {
SetAlignment(Right);
}
bool WordDocument::JustifiedRadio() const {
return IsAlignment(Justified);
}
void WordDocument::OnJustified() {
SetAlignment(Justified);
}
在编辑
模式下,IsAlignment
方法检查编辑段落是否具有给定的对齐方式。在标记
模式下,它检查所有部分或完全标记的段落是否具有给定的对齐方式。这意味着如果几个段落被标记为不同的对齐方式,则没有对齐菜单项会被标记为单选按钮。
bool WordDocument::IsAlignment(Alignment alignment) const {
switch (wordMode) {
case WordEdit: {
Alignment editAlignment =
charList[editIndex].ParagraphPtr()->AlignmentField();
return (editAlignment == alignment);
}
case WordMark: {
int minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
int minParIndex =
charList[minCharIndex].ParagraphPtr()->Index(),
maxParIndex =
charList[maxCharIndex].ParagraphPtr()->Index();
for (int parIndex = minParIndex; parIndex < maxParIndex;
++parIndex) {
Alignment markAlignment =
paragraphList[parIndex]->AlignmentField();
if (markAlignment != alignment) {
return false;
}
}
return true;
}
}
assert(false);
return false;
}
SetAlignment
方法设置编辑或标记段落的对齐方式。在 edit
模式下,我们只设置编辑段落的对齐方式。请记住,此方法只能在段落具有另一种对齐方式时调用。在 mark
模式下,我们遍历标记段落,并设置那些尚未具有所讨论对齐方式的段落的对齐方式。也要记住,此方法只能在至少有一个段落不保持所讨论对齐方式的情况下调用。需要重新计算对齐方式的段落。然而,新的对齐方式不会影响段落的长度,这意味着我们不需要为剩余的段落调用 CalculateDocument
方法。
void WordDocument::SetAlignment(Alignment alignment) {
switch (wordMode) {
case WordEdit: {
Paragraph* paragraphPtr =
charList[editIndex].ParagraphPtr();
paragraphPtr->AlignmentField() = alignment;
GenerateParagraph(paragraphPtr);
UpdateCaret();
}
break;
case WordMark: {
int minCharIndex = min(firstMarkIndex, lastMarkIndex),
maxCharIndex = max(firstMarkIndex, lastMarkIndex);
int minParIndex =
charList[minCharIndex].ParagraphPtr()->Index(),
maxParIndex =
charList[maxCharIndex].ParagraphPtr()->Index();
for (int parIndex = minParIndex; parIndex < maxParIndex;
++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
paragraphPtr->AlignmentField() = alignment;
GenerateParagraph(paragraphPtr);
}
}
break;
}
UpdateWindow();
}
摘要
在本章中,你开始开发一个能够处理单个字符的字处理器。该字处理器支持以下功能:
-
每个字符的独立字体和样式
-
每个段落的左对齐、居中对齐、右对齐和对齐方式
-
分布在页面上的段落
-
滚动和缩放
-
触摸屏
-
使用 ASCII 或 Unicode 文本进行剪切、复制和粘贴,以及应用特定的通用信息
在 第七章,键盘输入和字符计算 中,我们将继续讨论键盘输入和字符计算。
第七章. 键盘输入和字符计算
在本章中,我们将继续在第六章构建一个文字处理器的基础上对文字处理器进行工作。更具体地说,我们将探讨键盘输入和字符计算。键盘处理部分处理常规字符输入和一组相当大的特殊键,例如Home、End、Page Up和Page Down、Return、Backspace和箭头键。
计算部分处理每个字符的计算,包括其字体、段落对齐以及页面设置。最后,我们将计算文档中每个单独字符的位置和大小。
键盘处理
首先,我们来看一下常规字符的输入。每当用户按下图形字符(ASCII 值在 32 到 127 之间,包括 127)或回车键时,都会调用OnChar
方法。如果文本的一部分被标记,那么这部分首先会被移除。然后,根据keyboard
模式,通过OverwriteChar
类的InsertChar
方法将字符添加到字符列表中。
void WordDocument::OnChar(TCHAR tChar) {
if (isprint(tChar) || (tChar == NewLine)) {
if (wordMode == WordMark) {
OnDelete();
}
Paragraph* paragraphPtr = charList[editIndex].ParagraphPtr();
switch (GetKeyboardMode()) {
case InsertKeyboard:
OnInsertChar(tChar, paragraphPtr);
break;
case OverwriteKeyboard:
OnOverwriteChar(tChar, paragraphPtr);
break;
}
SetDirty(true);
GenerateParagraph(paragraphPtr);
CalculateDocument();
if (MakeVisible()) {
Invalidate();
UpdateWindow();
}
UpdateCaret();
}
}
当插入字符时,我们有三种情况,这与第六章中UpdateCaret
和OnFont
方法的处理类似,即构建一个文字处理器。如果nextFont
参数是激活的(如果不等于SystemFont
),我们则用它来处理新字符。然后,通过ClearNextFont
方法清除nextFont
参数。
void WordDocument::OnInsertChar(TCHAR tChar,
Paragraph* paragraphPtr) {
if (nextFont != SystemFont) {
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, nextFont));
ClearNextFont();
}
如果nextFont
参数未激活且输入不在段落的开始处,我们则使用前一个字符的字体来处理新字符。
else if (charList[editIndex].ParagraphPtr()->First() <
editIndex) {
Font font = charList[editIndex - 1].CharFont();
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, font));
}
然而,如果输入位于段落的开始处,我们则使用段落中第一个字符的字体。
else {
Font font = charList[editIndex].CharFont();
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, font));
}
为了为插入的字符腾出空间,我们增加其段落的最后一个索引。同时,我们也增加后续段落的第一个和最后一个索引。
++paragraphPtr->Last();
for (int parIndex = paragraphPtr->Index() + 1;
parIndex <= paragraphList.Size() - 1; ++parIndex) {
++paragraphList[parIndex]->First();
++paragraphList[parIndex]->Last();
}
}
在overwrite
模式下,我们有两种情况。如果输入位于文档的末尾,我们则插入字符而不是覆盖它;否则,我们覆盖最后一个段落的换行符。然而,我们可以自由地覆盖除最后一个段落外的每个段落的终止换行符,在这种情况下,两个段落将合并为一个。
与InsertChar
方法类似,如果nextFont
参数不等于SystemFont
参数,我们则使用它。如果它等于SystemFont
参数,我们则使用被覆盖字符的字体,而不是像在InsertChar
情况中那样使用前一个字符的字体。
void WordDocument::OnOverwriteChar(TCHAR tChar,
Paragraph* paragraphPtr) {
if (editIndex == (charList.Size() - 1)) {
if (nextFont != SystemFont) {
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, nextFont));
charList[editIndex] =
CharInfo(paragraphPtr, NewLine, nextFont);
ClearNextFont();
}
else {
Font font = charList[editIndex].CharFont();
charList.Insert(editIndex++,
CharInfo(paragraphPtr, tChar, font));
}
++paragraphPtr->Last();
}
else {
if (nextFont != SystemFont) {
charList[editIndex++] =
CharInfo(paragraphPtr, tChar, nextFont);
ClearNextFont();
}
else {
Font font = charList[editIndex].CharFont();
charList[editIndex++] = CharInfo(paragraphPtr, tChar, font);
}
}
}
ClearNextFont
方法通过将其值设置为 SystemFont
字体来清除 nextFont
参数。它还会重新计算编辑段落和文档,因为移除 nextFont
参数可能会导致编辑行(以及因此的编辑段落)降低。行上的字符字体可能都低于 nextFont
参数,这会导致移除 nextFont
参数后行降低。
void WordDocument::ClearNextFont() {
if (nextFont != SystemFont) {
nextFont = SystemFont;
Paragraph* paragraphPtr = charList[editIndex].ParagraphPtr();
GenerateParagraph(paragraphPtr);
CalculateDocument();
UpdateWindow();
}
}
每次用户按下键时,都会调用 OnKeyDown
方法。根据键和是否按下 Shift 键,OnKeyDown
方法会依次调用 OnShiftKey
、OnRegularKey
或 OnNeutralKey
方法。Delete、Backspace 和 Return 键在是否按下 Shift 键的情况下执行相同的行为。
bool WordDocument::OnKeyDown(WORD key, bool shiftPressed,
bool /* controlPressed */) {
switch (key) {
case KeyLeft:
case KeyRight:
case KeyUp:
case KeyDown:
case KeyHome:
case KeyEnd: {
if (shiftPressed) {
OnShiftKey(key);
}
else {
OnRegularKey(key);
}
}
return true;
case KeyBackspace:
case KeyReturn:
OnNeutralKey(key);
return true;
}
return false;
}
当用户按下图形键时,应用程序将被设置为 edit
模式。EnsureEditStatus
方法确保这一点。按键可能将光标移动到客户端区域可见部分之外的位置。因此,如果需要,我们调用 MakeVisible
方法来移动滚动条,以便光标出现在客户端区域的可见部分。想法是使光标和编辑字符始终在窗口中可见。
void WordDocument::OnRegularKey(WORD key) {
EnsureEditStatus();
switch (key) {
case KeyLeft:
OnLeftArrowKey();
break;
case KeyRight:
OnRightArrowKey();
break;
case KeyUp:
OnUpArrowKey();
break;
case KeyDown:
OnDownArrowKey();
break;
case KeyHome:
OnHomeKey();
break;
case KeyEnd:
OnEndKey();
break;
}
if (MakeVisible()) {
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
当用户按下 Page Up、Page Down 或箭头键之一,而没有按下 Shift 键时,我们必须确保应用程序设置为 edit
模式。EnsureEditStatus
方法负责这一点。editIndex
被设置为 lastMarkIndex
。
void WordDocument::EnsureEditStatus() {
if (wordMode == WordMark) {
wordMode = WordEdit;
editIndex = lastMarkIndex;
InvalidateBlock(firstMarkIndex, lastMarkIndex);
UpdateCaret();
UpdateWindow();
}
}
箭头键
当用户按下左箭头键时,会调用 OnLeftArrowKey
方法。它的目的是将光标向左移动一步,这很简单。我们必须确保编辑位置不在文档的开始处。如果我们向左移动位置,我们还需要清除 nextFont
参数,因为它只有在用户即将输入新字符时才会激活。
void WordDocument::OnLeftArrowKey() {
if (editIndex > 0) {
ClearNextFont();
--editIndex;
}
}
当用户按下右箭头键时,会调用 OnRightArrowKey
方法。如果光标位置不在文档末尾,我们将它向右移动一步。
void WordDocument::OnRightArrowKey() {
if (editIndex < (charList.Size() - 1)) {
ClearNextFont();
++editIndex;
}
}
当用户按下上箭头键时,我们必须找到编辑行上面的键。我们通过在行稍上方(一个逻辑单位)模拟鼠标点击来实现这一点。请注意,我们必须查找编辑行。仅使用字符矩形是不够的,因为字符的高度和上升(参考下一节)可能不同,我们无法确定字符矩形是该行上最高的矩形。因此,我们查找编辑行的高度。在下面的屏幕截图中,文本被矩形包围以供说明。代码实际上并没有绘制矩形。如果我们使用数字四的矩形,我们就不会达到前面的行,因为数字 5 的矩形更高。相反,我们必须使用行 456 的行矩形。
void WordDocument::OnUpArrowKey() {
CharInfo charInfo = charList[editIndex];
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
LineInfo* lineInfoPtr = charInfo.LineInfoPtr();
Rect lineRect =
topLeft + Rect(0, lineInfoPtr->Top(), PageInnerWidth(),
lineInfoPtr->Top() + lineInfoPtr->Height());
我们需要检查编辑字符是否不在文档的第一行。如果编辑字符已经在第一行,则输出不会发生任何变化。
if (lineRect.Top() > 0) {
ClearNextFont();
Rect charRect = topLeft + charInfo.CharRect();
editIndex =
MousePointToIndex(Point(charRect.Left(), lineRect.Top()-1));
}
}
当用户按下向下箭头键时,我们通过调用MousePointToIndexDown
方法来模拟鼠标点击。在调用中,我们使用位于编辑行稍下方的位置(1 个单位)来找到下一行相同水平位置上的字符索引。与前面的UpArrowKey
情况相比,我们调用MousePointToIndexDown
方法而不是MousePointToIndex
方法,因为这可能是在段落的最后一行,并且可能在下一个段落之前有一些空间。在这种情况下,我们希望得到空格后面的字符的索引,这是MousePointToIndexDown
方法返回的,而MousePointToIndex
方法返回的是空格前面的字符的索引。
void WordDocument::OnDownArrowKey() {
CharInfo charInfo = charList[editIndex];
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
LineInfo* lineInfoPtr = charInfo.LineInfoPtr();
Rect lineRect =
topLeft + Rect(0, lineInfoPtr->Top(), PageInnerWidth(),
lineInfoPtr->Top() + lineInfoPtr->Height());
与前面的OnUpArrowKey
情况类似,我们需要确保编辑行不是文档中的最后一行。我们通过将其与最后一个段落的底部进行比较来实现这一点。如果是最后一行,则输出不会发生任何变化。
Paragraph* lastParagraphPtr = paragraphList.Back();
int bottom = lastParagraphPtr->Top() +
lastParagraphPtr->Height();
if (lineRect.Bottom() < bottom) {
ClearNextFont();
Rect charRect = topLeft + charInfo.CharRect();
editIndex =
MousePointToIndexDown(Point(charRect.Left(),
lineRect.Bottom() + 1));
}
}
MousePointToIndexDown
方法返回被点击的字符的索引。如果鼠标点在两个段落之间,则返回前一个字符的索引。
int WordDocument::MousePointToIndexDown(Point mousePoint) const{
for (int parIndex = 0; parIndex < paragraphList.Size();
++parIndex) {
Paragraph* paragraphPtr = paragraphList[parIndex];
if (mousePoint.Y() <=
(paragraphPtr->Top() + paragraphPtr->Height())) {
return MousePointToParagraphIndex
(paragraphList[parIndex], mousePoint);
}
}
由于此方法始终找到正确的段落,因此此点永远不会达到,但我们断言在编码错误的情况下,其行为可能会有所不同。
assert(false);
return 0;
}
OnPageUp
和OnPageDown
方法查找当前垂直滚动条的高度,以便模拟向上或向下翻一页的鼠标点击。
void WordDocument::OnPageUpKey() {
CharInfo charInfo = charList[editIndex];
Rect editRect = charInfo.CharRect();
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
int scrollPage = GetVerticalScrollPageHeight();
Point editPoint((editRect.Left() + editRect.Right()) / 2,
((editRect.Top() + editRect.Bottom()) / 2) - scrollPage);
editIndex = MousePointToIndex(topLeft + editPoint);
}
void WordDocument::OnPageDownKey() {
CharInfo charInfo = charList[editIndex];
Rect editRect = charInfo.CharRect();
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
int scrollPage = GetVerticalScrollPageHeight();
Point editPoint((editRect.Left() + editRect.Right()) / 2,
((editRect.Top() + editRect.Bottom()) / 2) + scrollPage);
editIndex = MousePointToIndex(topLeft + editPoint);
}
Home 和 End
当用户按下Home键时调用OnHomeKey
方法。它通过跟随其段落和行指针来查找编辑行上第一个字符的索引。它使用行中第一个字符的索引。
void WordDocument::OnHomeKey() {
CharInfo charInfo = charList[editIndex];
int homeCharIndex = charInfo.ParagraphPtr()->First() +
charInfo.LineInfoPtr()->First();
如果编辑字符尚未位于行的开头,ClearNextFont
方法会清除nextFont
参数,更新编辑索引,并更新光标。
if (homeCharIndex < editIndex) {
ClearNextFont();
editIndex = homeCharIndex;
UpdateCaret();
}
}
当用户按下End键时调用OnEndKey
方法。它通过跟随其段落和行指针并使用行中最后一个字符的索引来查找编辑行上最后一个字符的索引。
void WordDocument::OnEndKey() {
CharInfo charInfo = charList[editIndex];
int endCharIndex = charInfo.ParagraphPtr()->First() +
charInfo.LineInfoPtr()->Last();
如果编辑字符尚未位于行的末尾,ClearNextFont
方法会清除nextFont
参数,更新编辑索引,并更新光标。
if (editIndex < endCharIndex) {
ClearNextFont();
editIndex = endCharIndex;
UpdateCaret();
}
}
Shift 箭头键
当用户同时按下Shift键和某个键时调用OnShiftKey
方法:
void WordDocument::OnShiftKey(WORD key) {
EnsureMarkStatus();
switch (key) {
case KeyLeft:
OnShiftLeftArrowKey();
break;
case KeyRight:
OnShiftRightArrowKey();
break;
case KeyUp:
OnShiftUpArrowKey();
break;
case KeyDown:
OnShiftDownArrowKey();
break;
case KeyPageUp:
OnShiftPageUpKey();
break;
case KeyPageDown:
OnShiftPageDownKey();
break;
case KeyHome:
OnShiftHomeKey();
break;
case KeyEnd:
OnShiftEndKey();
break;
}
if (MakeVisible()) {
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
如果用户同时按下Shift键和某个键,我们必须确保应用程序设置为mark
模式;EnsureMarkMode
方法处理这个问题。它会清除nextFont
参数(通过将其设置为SystemFont
),将应用程序设置为mark
模式,并将第一个和最后一个标记的索引分配给编辑索引。
void WordDocument::EnsureMarkStatus() {
if (wordMode == WordEdit) {
ClearNextFont();
wordMode = WordMark;
firstMarkIndex = editIndex;
lastMarkIndex = editIndex;
UpdateCaret();
}
}
OnShiftLeftArrowKey
方法减少最后一个标记索引。请注意,我们只使 lastMarkIndex
方法的旧值和新值之间的索引无效,以避免闪烁:
void WordDocument::OnShiftLeftArrowKey() {
if (lastMarkIndex > 0) {
InvalidateBlock(lastMarkIndex, --lastMarkIndex);
}
}
OnShiftRightArrowKey
方法以类似于 OnShiftLeftArrowKey
方式移动最后标记字符的位置。
void WordDocument::OnShiftRightArrowKey() {
if (lastMarkIndex < charList.Size()) {
InvalidateBlock(lastMarkIndex, lastMarkIndex++);
}
}
当用户同时按下上箭头键或下箭头键以及 Shift 键时,会调用 OnShiftUpArrowKey
和 OnShiftDownArrowKey
方法。其任务是向上移动最后一个标记位置。我们以与之前 OnUpArrowKey
和 OnDownArrowKey
方法相同的方式模拟鼠标点击。
void WordDocument::OnShiftUpArrowKey() {
CharInfo charInfo = charList[lastMarkIndex];
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
LineInfo* lineInfoPtr = charInfo.LineInfoPtr();
Rect lineRect =
topLeft + Rect(0, lineInfoPtr->Top(), PageInnerWidth(),
lineInfoPtr->Top() + lineInfoPtr->Height());
if ((paragraphPtr->Top() + lineRect.Top()) > 0) {
Rect charRect = topLeft + charInfo.CharRect();
int newLastMarkIndex =
MousePointToIndex(Point(charRect.Left(), lineRect.Top()-1));
InvalidateBlock(lastMarkIndex, newLastMarkIndex);
lastMarkIndex = newLastMarkIndex;
}
}
void WordDocument::OnShiftDownArrowKey() {
CharInfo charInfo = charList[lastMarkIndex];
Paragraph* paragraphPtr = charInfo.ParagraphPtr();
Point topLeft(0, paragraphPtr->Top());
LineInfo* lineInfoPtr = charInfo.LineInfoPtr();
Rect lineRect =
topLeft + Rect(0, lineInfoPtr->Top(), PageInnerWidth(),
lineInfoPtr->Top() + lineInfoPtr->Height());
Paragraph* lastParagraphPtr = paragraphList.Back();
int bottom = lastParagraphPtr->Top() +
lastParagraphPtr->Height();
if (lineRect.Bottom() < bottom) {
Rect charRect = topLeft + charInfo.CharRect();
int newLastMarkIndex =
MousePointToIndexDown(Point(charRect.Left(),
lineRect.Bottom() + 1));
InvalidateBlock(lastMarkIndex, newLastMarkIndex);
lastMarkIndex = newLastMarkIndex;
}
}
Shift Page Up 和 Page Down
OnShiftPageUpKey
和 OnShiftPageDown
方法通过模拟在 Page Up 或 Page Down 上进行鼠标点击来移动编辑字符索引一个页面高度:
void WordDocument::OnShiftPageUpKey() {
Rect lastRectMark = charList[lastMarkIndex].CharRect();
int scrollPage = GetVerticalScrollPageHeight();
Point lastPointMark
((lastRectMark.Left() + lastRectMark.Right()) / 2,
(lastRectMark.Top()+lastRectMark.Bottom()) / 2 - scrollPage);
int newLastMarkIndex = MousePointToIndex(lastPointMark);
InvalidateBlock(lastMarkIndex, newLastMarkIndex);
lastMarkIndex = newLastMarkIndex;
}
void WordDocument::OnShiftPageDownKey() {
Rect lastRectMark = charList[lastMarkIndex].CharRect();
int scrollPage = GetVerticalScrollPageHeight();
Point lastPointMark
((lastRectMark.Left() + lastRectMark.Right()) / 2,
(lastRectMark.Top()+lastRectMark.Bottom())/2 + scrollPage);
int newLastMarkIndex = MousePointToIndexDown(lastPointMark);
InvalidateBlock(lastMarkIndex, newLastMarkIndex);
lastMarkIndex = newLastMarkIndex;
}
Shift Home 和 End
当用户同时按下 Home 或 End 键以及 Shift 键时,会调用 OnShiftHomeKey
和 OnShiftEndKey
方法。它们的作用是标记从当前位置到行首或行尾的整行:
void WordDocument::OnShiftHomeKey() {
CharInfo charInfo = charList[editIndex];
int homeCharIndex = charInfo.ParagraphPtr()->First() +
charInfo.LineInfoPtr()->First();
if (homeCharIndex < lastMarkIndex) {
InvalidateBlock(lastMarkIndex, homeCharIndex);
lastMarkIndex = homeCharIndex;
}
}
void WordDocument::OnShiftEndKey() {
CharInfo charInfo = charList[editIndex];
int endCharIndex = charInfo.ParagraphPtr()->First() +
charInfo.LineInfoPtr()->Last();
if (lastMarkIndex < endCharIndex) {
InvalidateBlock(lastMarkIndex, endCharIndex);
lastMarkIndex = endCharIndex;
}
}
Control Home 和 End
OnControlHomeKey
和 OnControlEndKey
方法将编辑字符位置设置为文档的开始或结束。由于这些方法是监听器,而不是由 OnRegularKey
方法调用,因此我们需要调用 EnsureEditStatus
、MakeVisible
和 UpdateCaret
方法:
void WordDocument::OnControlHomeKey() {
EnsureEditStatus();
if (editIndex > 0) {
editIndex = 0;
if (MakeVisible()) {
Invalidate();
UpdateWindow();
}
UpdateCaret();
}
}
void WordDocument::OnControlEndKey() {
EnsureEditStatus();
if (editIndex < (charList.Size() - 1)) {
editIndex = charList.Size() - 1;
if (MakeVisible()) {
Invalidate();
UpdateWindow();
}
UpdateCaret();
}
}
Shift Control Home 和 End
OnShiftControlHomeKey
和 OnShiftControlEndKey
方法将最后一个标记索引设置为文档的开始或结束:
void WordDocument::OnShiftControlHomeKey() {
EnsureMarkStatus();
ClearNextFont();
if (lastMarkIndex > 0) {
InvalidateBlock(0, lastMarkIndex);
lastMarkIndex = 0;
if (MakeVisible()) {
Invalidate();
UpdateWindow();
}
UpdateCaret();
}
}
void WordDocument::OnShiftControlEndKey() {
EnsureMarkStatus();
if (lastMarkIndex < (charList.Size() - 1)) {
int lastIndex = charList.Size() - 1;
InvalidateBlock(lastMarkIndex, lastIndex);
lastMarkIndex = lastIndex;
if (MakeVisible()) {
Invalidate();
UpdateWindow();
}
UpdateCaret();
}
}
中性键
Backspace 和 Return 键是中性键,从意义上讲,我们不在乎用户是否按下了 Shift 或 Ctrl 键。注意,Delete 键不是由 OnNeutralKey
方法处理的,因为 Delete 菜单项将 Delete 键作为其快捷键:
void WordDocument::OnNeutralKey(WORD key) {
switch (key) {
case KeyBackspace:
OnBackspaceKey();
break;
case KeyReturn:
OnReturnKey();
break;
}
if (MakeVisible()) {
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
OnBackSpaceKey
方法所做的是相当简单的——它只是调用 OnDelete
方法。在 edit
模式下,我们首先向左移动一步,除非编辑位置已经不在文档的开始处。如果是这样,则不执行任何操作。在 mark
模式下,Delete 键和 Backspace 键具有相同的效果——它们都删除标记的文本。
void WordDocument::OnBackspaceKey() {
switch (wordMode) {
case WordEdit:
if (editIndex > 0) {
OnLeftArrowKey();
OnDelete();
}
break;
case WordMark:
OnDelete();
break;
}
}
当用户按下 Return 键时,会调用 OnReturnKey
方法。首先,我们使用换行符调用 OnChar
方法。OnChar
方法在其他任何情况下都不会带换行符调用,因为换行符不是一个图形字符。
void WordDocument::OnReturnKey() {
OnChar(NewLine);
在字符列表中添加换行后,我们需要将编辑段落分成两部分。editIndex
字段已被 OnChar
方法更新,现在是换行后的字符索引。第二段从编辑索引开始,到第一段末尾结束。第一段的最后一个索引设置为编辑索引减一。这意味着第一段包含换行符及其之前的所有字符,而第二段包含换行符之后的字符。
Paragraph* firstParagraphPtr =
charList[editIndex].ParagraphPtr();
Paragraph* secondParagraphPtr =
new Paragraph(editIndex, firstParagraphPtr->Last(),
firstParagraphPtr->AlignmentField(),
firstParagraphPtr->Index() + 1);
assert(firstParagraphPtr != nullptr);
firstParagraphPtr->Last() = editIndex - 1;
我们在段落列表中插入第二段;我们还需要将第二段中的字符设置为指向第二段。
paragraphList.Insert(firstParagraphPtr->Index() + 1,
secondParagraphPtr);
for (int charIndex = secondParagraphPtr->First();
charIndex <= secondParagraphPtr->Last(); ++charIndex) {
charList[charIndex].ParagraphPtr() = secondParagraphPtr;
}
由于第一段丢失了字符,而第二段是最近创建的,我们需要重新计算第一段和第二段。
GenerateParagraph(firstParagraphPtr);
GenerateParagraph(secondParagraphPtr);
由于我们添加了一个段落,我们需要增加后续段落的索引。
for (int parIndex = secondParagraphPtr->Index() + 1;
parIndex < paragraphList.Size(); ++parIndex) {
++paragraphList[parIndex]->Index();
}
SetDirty(true);
CalculateDocument();
UpdateCaret();
UpdateWindow();
}
可见字符
当用户使用键盘时,编辑中的字符或最后标记的字符始终可见。我们首先找到可见区域;在编辑
模式下,它是编辑字符的区域。在标记
模式下,它是最后一个标记索引之前的字符区域,除非它是零,在这种情况下,索引被设置为零。
bool WordDocument::MakeVisible() {
Rect visibleArea;
switch (wordMode) {
case WordEdit: {
Paragraph* editParagraphPtr =
charList[editIndex].ParagraphPtr();
Point topLeft(0, editParagraphPtr->Top());
visibleArea = topLeft + charList[editIndex].CharRect();
}
break;
case WordMark: {
Paragraph* lastParagraphPtr =
charList[max(0, lastMarkIndex - 1)].ParagraphPtr();
Point topLeft(0, lastParagraphPtr->Top());
visibleArea =
topLeft + charList[max(0,lastMarkIndex - 1)].CharRect();
}
break;
}
我们测试可见区域是否在当前时刻实际上是可见的。如果不可见,我们调整滚动条以使其可见。
int horiScrollLeft = GetHorizontalScrollPosition(),
horiScrollPage = GetHorizontalScrollPageWidth(),
vertScrollTop = GetVerticalScrollPosition(),
vertScrollPage = GetVerticalScrollPageHeight();
int horiScrollRight = horiScrollLeft + horiScrollPage,
vertScrollBottom = vertScrollTop + vertScrollPage;
如果可见区域的左边界不可见,我们将水平滚动位置设置为它的左边界。同样,如果可见区域的顶部边界不可见,我们将垂直滚动位置设置为它的顶部边界。
if (visibleArea.Left() < horiScrollLeft) {
SetHorizontalScrollPosition(visibleArea.Left());
return true;
}
if (visibleArea.Top() < vertScrollTop) {
SetVerticalScrollPosition(visibleArea.Top());
return true;
}
当涉及到可见区域的右边界和底部边界时,事情变得稍微复杂一些。我们首先计算可见区域右边界和右滚动位置(左滚动位置加上水平滚动条的大小)之间的距离,并将水平滚动位置增加该距离。同样,我们计算可见区域右边界和底部滚动位置(顶部滚动位置加上垂直滚动条的大小)之间的距离,并将垂直滚动位置增加该距离。
if (visibleArea.Right() > horiScrollRight) {
int horiDifference = visibleArea.Right() - horiScrollRight;
SetHorizontalScrollPosition(horiScrollLeft + horiDifference);
return true;
}
if (visibleArea.Bottom() > vertScrollBottom) {
int vertDifference = visibleArea.Bottom() - vertScrollBottom;
SetVerticalScrollPosition(vertScrollTop + vertDifference);
return true;
}
return false;
}
字符计算
GenerateParagraph
函数在字符添加或删除、字体或对齐方式更改时,为段落生成字符矩形和行列表。首先,我们通过调用GenerateSizeAndAscentList
和GenerateLineList
方法生成每个字符的大小和上升线列表以及行列表。然后,我们遍历行列表,通过调用GenerateLineRectList
方法生成字符矩形。最后,我们通过将它们与原始矩形列表进行比较来使已更改的字符无效:
void WordDocument::GenerateParagraph(Paragraph* paragraphPtr) {
if (!charList.Empty()) {
DynamicList<Size> sizeList;
DynamicList<int> ascentList;
DynamicList<CharInfo> prevCharList;
charList.Copy(prevCharList, paragraphPtr->First(),
paragraphPtr->Last());
GenerateSizeAndAscentList(paragraphPtr, sizeList, ascentList);
GenerateLineList(paragraphPtr, sizeList, ascentList);
for (LineInfo* lineInfoPtr : paragraphPtr->LinePtrList()) {
if (paragraphPtr->AlignmentField() == Justified) {
GenerateJustifiedLineRectList(paragraphPtr, lineInfoPtr,
sizeList, ascentList);
}
else {
GenerateRegularLineRectList(paragraphPtr, lineInfoPtr,
sizeList, ascentList);
}
}
GenerateRepaintSet(paragraphPtr, prevCharList);
}
}
字符大小和上升线
上升线分隔字母的上部和下部,如下图所示:
GenerateSizeAndAscentList
方法将给定的列表填充为段落中每个字符的大小(宽度和高度)和上升线:
void WordDocument::GenerateSizeAndAscentList
(Paragraph* paragraphPtr, DynamicList<Size>& sizeList,
DynamicList<int>& ascentList) {
int index = 0;
for (int charIndex = paragraphPtr->First();
charIndex <= paragraphPtr->Last(); ++charIndex) {
CharInfo charInfo = charList[charIndex];
TCHAR tChar = (charInfo.Char() == NewLine) ? Space
: charInfo.Char();
int width = GetCharacterWidth(charInfo.CharFont(), tChar),
height = GetCharacterHeight(charInfo.CharFont()),
ascent = GetCharacterAscent(charInfo.CharFont());
sizeList.PushBack(Size(width, height));
ascentList.PushBack(ascent);
}
}
行生成
GenerateLineList
方法生成行列表。主要点是我们必须决定每行可以容纳多少单词。我们遍历字符并计算每个单词的大小。当下一个单词无法适应行时,我们开始新的一行。我们保存行上第一个和最后一个字符的索引以及其顶部位置。我们还保存其最大高度和上升,即行上最大字符的高度和上升:
void WordDocument::GenerateLineList(Paragraph* paragraphPtr,
DynamicList<Size>& sizeList,
DynamicList<int>& ascentList){
int maxHeight = 0, maxAscent = 0, lineWidth = 0,
spaceLineHeight = 0, spaceLineAscent = 0,
startIndex = paragraphPtr->First(), spaceIndex = -1;
我们删除先前存储在行列表中的行。清除行列表和段落高度。将lineTop
变量设置为零,并在计算每行的顶部位置时使用。
for (LineInfo* lineInfoPtr : paragraphPtr->LinePtrList()) {
delete lineInfoPtr;
}
paragraphPtr->Height() = 0;
paragraphPtr->LinePtrList().Clear();
int lineTop = 0;
for (int charIndex = paragraphPtr->First();
charIndex <= paragraphPtr->Last(); ++charIndex) {
CharInfo charInfo = charList[charIndex];
if (charInfo.Char() != NewLine) {
lineWidth +=
sizeList[charIndex - paragraphPtr->First()].Width();
}
如果nextFont
参数是活动的(不等于SystemFont
)并且我们在编辑模式下达到了编辑索引,我们计算nextFont
参数的高度和上升。在这种情况下,我们只对字体的高度和上升感兴趣,而不需要计算其平均字符的宽度。
if ((nextFont != SystemFont) && (charIndex == editIndex) &&
(wordMode == WordEdit)) {
maxHeight = max(maxHeight, GetCharacterHeight(nextFont));
maxAscent = max(maxAscent, GetCharacterAscent(nextFont));
}
注意,我们必须减去段落的第一个索引,因为每行的索引都是相对于段落开头的。记住,字符列表是文档中所有段落的公共部分。
else {
maxHeight = max(maxHeight,
sizeList[charIndex - paragraphPtr->First()].Height());
maxAscent = max(maxAscent,
ascentList[charIndex - paragraphPtr->First()]);
}
if (charInfo.Char() == Space) {
spaceIndex = charIndex;
spaceLineHeight = max(spaceLineHeight, maxHeight);
spaceLineAscent = max(spaceLineAscent, maxAscent);
maxHeight = 0;
maxAscent = 0;
}
当我们找到换行符时,我们已经到达段落的末尾。
if (charInfo.Char() == NewLine) {
spaceLineHeight = max(spaceLineHeight, maxHeight);
spaceLineAscent = max(spaceLineAscent, maxAscent);
LineInfo* lineInfoPtr =
new LineInfo(startIndex - paragraphPtr->First(),
charIndex - paragraphPtr->First(),
lineTop, spaceLineHeight, spaceLineAscent);
assert(lineInfoPtr != nullptr);
for (int index = lineInfoPtr->First();
index <= lineInfoPtr->Last(); ++index) {
charList[paragraphPtr->First() + index].LineInfoPtr() =
lineInfoPtr;
}
paragraphPtr->Height() += spaceLineHeight;
paragraphPtr->LinePtrList().PushBack(lineInfoPtr);
break;
}
当编辑行的宽度超过页面宽度时,实际上有三种不同的情况:
-
行由至少一个完整的单词组成(空格不等于负一)
-
行由一个太长而无法适应页面的单词组成(空格等于负一且
charIndex
大于startIndex
) -
行由一个比页面宽一个字符的单词组成(空格等于负一且
charIndex
等于startIndex
)
第三种情况不太可能但有可能发生。
if (lineWidth > PageInnerWidth()) {
LineInfo* lineInfoPtr = new LineInfo();
assert(lineInfoPtr != nullptr);
lineInfoPtr->Top() = lineTop;
lineTop += spaceLineHeight;
如果一行由至少一个完整的单词后跟一个空格组成,我们丢弃最后一个空格,并从下一个字符开始新行。
if (spaceIndex != -1) {
lineInfoPtr->First() = startIndex - paragraphPtr->First();
lineInfoPtr->Last() = spaceIndex - paragraphPtr->First();
lineInfoPtr->Ascent() = spaceLineAscent;
lineInfoPtr->Height() = spaceLineHeight;
startIndex = spaceIndex + 1;
}
如果一行由一个单独的单词(至少有两个字母)组成,且其宽度不适合在页面上,我们定义该行包含最后一个适合的字符,并从下一个字符开始新行。
else {
if (charIndex > startIndex) {
lineInfoPtr->First() =
startIndex - paragraphPtr->First();
lineInfoPtr->Last() =
charIndex - paragraphPtr->First() - 1;
startIndex = charIndex;
}
最后,在不太可能的情况下,如果单个字符比页面宽,我们只需让该字符构成整个行,并让下一个索引是起始索引。
else {
lineInfoPtr->First() =charIndex - paragraphPtr->First();
lineInfoPtr->Last() = charIndex - paragraphPtr->First();
startIndex = charIndex + 1;
}
行的高度和上升是最大高度和上升(具有最大高度和上升的字符的高度和上升)。
lineInfoPtr->Height() = maxHeight;
lineInfoPtr->Ascent() = maxAscent;
}
我们将行上的所有字符设置为指向该行。
for (int index = lineInfoPtr->First();
index <= lineInfoPtr->Last(); ++index) {
charList[paragraphPtr->First() + index].LineInfoPtr() =
lineInfoPtr;
}
段落的高度通过行高增加,并将行指针添加到行指针列表中。
paragraphPtr->Height() += spaceLineHeight;
paragraphPtr->LinePtrList().PushBack(lineInfoPtr);
为了准备下一次迭代,清除行宽、最大高度和上升。
lineWidth = 0;
maxAscent = 0;
maxHeight = 0;
将charIndex
循环变量设置为最新的空格索引,并将spaceIndex
设置为-1
,表示我们尚未在新行上找到空格。
charIndex = startIndex;
spaceIndex = -1;
}
}
}
正规和两端对齐的矩形列表生成
当我们为每个字符决定大小和上升线,并将字符分成行后,就是生成字符矩形的时候了。对于常规(左、居中或右对齐)段落,我们分三步进行。对齐对齐的段落由GenerateJustifiedLineRectList
方法如下处理:
-
我们计算每行的宽度。
-
我们找到最左端的位置。
-
我们为字符生成矩形。
void WordDocument::GenerateRegularLineRectList
(Paragraph* paragraphPtr,LineInfo* lineInfoPtr,
DynamicList<Size>& sizeList,
DynamicList<int>& ascentList) {
我们遍历行的字符并计算其宽度。如果行的最后一个字符之后不是空格或换行符,我们也为其生成矩形。
for (int charIndex = lineInfoPtr->First();
charIndex < lineInfoPtr->Last(); ++charIndex) {
if (charList[paragraphPtr->First() + charIndex].Char() !=
NewLine) {
lineWidth +=
sizeList[charIndex - lineInfoPtr->First()].Width();
}
}
if ((charList[paragraphPtr->First()+lineInfoPtr->Last()].Char()
!= Space) &&
(charList[paragraphPtr->First()+lineInfoPtr->Last()].Char()
!=NewLine)) {
lineWidth +=
sizeList[lineInfoPtr->Last()-lineInfoPtr->First()].Width();
}
然后,我们找到行的最左端位置以开始矩形生成。在左对齐的情况下,起始位置始终为零。在居中对齐的情况下,它是页面和文本宽度差的一半。在右对齐的情况下,它是页面和文本宽度之间的整个差值。
int leftPos;
switch (paragraphPtr->AlignmentField()) {
case Left:
leftPos = 0;
break;
case Center:
leftPos = (PageInnerWidth() - lineWidth) / 2;
break;
case Right:
leftPos = PageInnerWidth() - lineWidth;
break;
}
接下来,我们遍历行并生成每个矩形。如果行的最后一个字符之后是空格,我们也为其生成矩形。
for (int charIndex = lineInfoPtr->First();
charIndex <= lineInfoPtr->Last(); ++charIndex) {
Size charSize = sizeList[charIndex];
int ascent = ascentList[charIndex];
int topPos = lineInfoPtr->Top() +
lineInfoPtr->Ascent() - ascent;
charList[paragraphPtr->First() + charIndex].CharRect() =
Rect(leftPos, topPos, leftPos + charSize.Width(),
topPos + charSize.Height());
leftPos += charSize.Width();
}
}
GenerateJustifiedLineRectList
方法比GenerateRegularLineRectList
方法稍微复杂一些。我们遵循之前提到的相同三个步骤。然而,在计算文本宽度时,我们省略了空格的宽度,而是计算空格的数量。
void WordDocument::GenerateJustifiedLineRectList
(Paragraph* paragraphPtr, LineInfo* lineInfoPtr,
DynamicList<Size>& sizeList, DynamicList<int>& ascentList) {
int spaceCount = 0, lineWidth = 0;
for (int charIndex = lineInfoPtr->First();
charIndex <= lineInfoPtr->Last(); ++charIndex) {
CharInfo charInfo =
charList[paragraphPtr->First() + charIndex];
我们将行上的每个字符都包括在lineWidth
中,除了空格和换行符。
if (charInfo.Char() == Space) {
++spaceCount;
}
else if (charInfo.Char() != NewLine) {
lineWidth += sizeList[charIndex].Width();
}
}
if ((charList[paragraphPtr->First()+lineInfoPtr->Last()].Char()
!= Space) &&
(charList[paragraphPtr->First()+lineInfoPtr->Last()].Char()
!=NewLine)) {
lineWidth += sizeList[lineInfoPtr->Last()].Width();
}
与之前的左对齐情况类似,对齐对齐的左端位置始终为零。如果行上至少有一个空格,我们通过将页面和文本宽度的差除以空格的数量来计算空格的宽度。我们需要检查空格的数量是否大于零。否则,我们将除以零。另一方面,如果空格的数量为零,我们不需要空格宽度。
int leftPos = 0, spaceWidth;
if (spaceCount > 0) {
spaceWidth = (PageInnerWidth() - lineWidth) / spaceCount;
}
for (int charIndex = lineInfoPtr->First();
charIndex <= lineInfoPtr->Last(); ++charIndex) {
Size charSize = sizeList[charIndex];
int ascent = ascentList[charIndex], charWidth;
如果字符是空格,我们使用计算出的空格宽度而不是其实际宽度。
if (charList[paragraphPtr->First() + charIndex].Char() ==
Space) {
charWidth = spaceWidth;
}
else {
charWidth = charSize.Width();
}
int topPos =
lineInfoPtr->Top() + lineInfoPtr->Ascent() - ascent;
charList[paragraphPtr->First() + charIndex].CharRect() =
Rect(leftPos, topPos, leftPos + charWidth,
topPos + charSize.Height());
leftPos += charWidth;
}
}
无效矩形集生成
最后,我们需要使已更改的矩形集无效。有两种情况需要考虑。首先,我们有矩形本身。我们遍历字符列表,并对每个字符比较其先前和当前的矩形,如果它们不同(这将导致它们两个区域都被重绘),则使它们两个都无效。记住,无效意味着我们准备在下次窗口更新时重绘的区域。然后我们查看行列表,并在行上如果有,将文本左侧和右侧的区域添加到其中。
void WordDocument::GenerateRepaintSet(Paragraph* paragraphPtr,
DynamicList<CharInfo>& prevCharList) {
Point topLeft(0, paragraphPtr->Top());
for (int charIndex = paragraphPtr->First();
charIndex <= paragraphPtr->Last(); ++ charIndex) {
Rect prevRect =
prevCharList[charIndex - paragraphPtr->First()].CharRect(),
currRect = charList[charIndex].CharRect();
if (prevRect != currRect) {
Invalidate(topLeft + prevRect);
Invalidate(topLeft + currRect);
}
}
int pageWidth = PageInnerWidth();
for (LineInfo* lineInfoPtr : paragraphPtr->LinePtrList()) {
Rect firstRect = charList[paragraphPtr->First() +
lineInfoPtr->First()].CharRect();
if (firstRect.Left() > 0) {
Rect leftRect(0, lineInfoPtr->Top(), firstRect.Left(),
lineInfoPtr->Top() + lineInfoPtr->Height());
Invalidate(topLeft + leftRect);
}
Rect lastRect = charList[paragraphPtr->First() +
lineInfoPtr->Last()].CharRect();
if (lastRect.Right() < pageWidth) {
Rect rightRect(lastRect.Right(), lineInfoPtr->Top(),
pageWidth, lineInfoPtr->Top()+lineInfoPtr->Height());
Invalidate(topLeft + rightRect);
}
}
}
摘要
在本章中,我们通过查看键盘处理和字符计算完成了我们的文字处理器的开发。在第八章《构建电子表格应用程序》中,我们将开始开发电子表格程序。
第八章。构建电子表格应用程序
在本章中,我们将开始开发本书的最后一个应用程序——一个能够计算数值表达式以及使用相对引用剪切和粘贴单元格的电子表格程序。类似于前几章中的文本处理器,电子表格程序剪切和粘贴 ASCII 和 Unicode 文本以及特定于应用程序的信息。此外,还可以更改单元格及其水平和垂直对齐方式的字体和颜色。
在本章中,我们将探讨以下内容:
-
鼠标和键盘输入
-
绘制电子表格
-
保存和加载电子表格
-
剪切、复制和粘贴单元格块
-
单元格块的字体、颜色和对齐方式
MainWindow
类
本章中 MainWindow
的定义与之前的定义非常相似。
MainWindow.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
#include "Error.h"
#include "Scanner.h"
#include "TreeNode.h"
#include "Parser.h"
#include "Cell.h"
#include "CalcDocument.h"
void MainWindow(vector<String> /* argumentList */,
WindowShow windowShow) {
Application::ApplicationName() = TEXT("Calc");
Application::MainWindowPtr() = new CalcDocument(windowShow);
}
CalcDocument
类
CalcDocument
类是应用程序的主要类。它捕获鼠标和键盘事件,处理滚动和绘制,并处理菜单操作。然而,单元格级别的操作由 Cell
类处理,我们将在第九章([ch09.html](https://example.org/ch09.html "第九章。公式解释")中介绍),公式解释 中进行介绍。
用户可以标记一个或多个单元格,在这种情况下,私有字段 calcMode
被设置为 Mark
。用户还可以编辑一个单元格中的文本,在这种情况下,calcMode
字段被设置为 Edit
。类似于前几章中的文本处理器,我们在 标记模式 和 编辑模式 等表达式中引用 calcMode
字段的当前值。
class CalcDocument : public StandardDocument {
public:
CalcDocument(WindowShow windowShow);
OnMouseDown
、OnMouseMove
和 OnDoubleClick
方法以与先前应用程序相同的方式捕获鼠标动作。请注意,我们没有重写 OnMouseUp
方法。与第七章([ch07.html](https://example.org/ch07.html "第七章。键盘输入和字符计算")中提到的“键盘输入和字符计算”的文本处理器相反,此应用程序在用户实际输入字符之前保持 mark
模式,即使他们只标记了一个单元格。用户还可以通过拖动鼠标标记多个单元格。
void OnMouseDown(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnMouseMove(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnDoubleClick(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
void OnMouseUp(MouseButton mouseButtons, Point mousePoint,
bool shiftPressed, bool controlPressed);
当用户更改滚动条时,会调用 OnHorizontalScroll
和 OnVerticalScroll
方法。在先前的应用程序中,我们没有重写这些函数,但在这个应用程序中,我们希望每次滚动都导致单元格移动一个精确的数量。此外,在 StandardDocument
构造函数调用中,我们使用 LogicalWithoutScroll
坐标系统,以便能够处理电子表格的行和列标题,这些标题始终位于客户端区域的顶部和左侧,无论滚动条设置如何。这意味着我们必须手动处理滚动条移动。
virtual void OnHorizontalScroll(WORD flags, WORD x);
virtual void OnVerticalScroll(WORD flags, WORD y);
用户可以通过点击左上角的all框(ClickAll
)标记所有单元格,通过点击列标题标记一列中的所有单元格(ClickCol
),通过点击行标题标记一行中的所有单元格(ClickRow
),或者通过点击单元格来标记单个单元格(ClickCell
)。
enum ClickArea {ClickAll, ClickRow, ClickColumn, ClickCell};
GetMouseLocation
方法分析鼠标点击并返回一个ClickArea
值。如果用户点击在电子表格的右侧,则选择该行的最右单元格,如果他们点击在电子表格下方,则选择该列的最下单元格。Reference
类在第十二章 辅助类中定义。
ClickArea GetMouseLocation(Point mousePoint,
Reference& cellRef) const;
MarkBlock
方法根据标记标记点击区域内的块:
void MarkBlock(ClickArea clickArea, Reference newFirstMarkRef,
Reference newLastMarkRef);
OnDraw
方法绘制行和列标题以及单元格本身。在edit
模式下,UpdateCaret
方法设置正在编辑的单元格中的光标。
void OnDraw(Graphics& graphics, DrawMode drawMode) const;
void UpdateCaret();
当用户使用键盘标记单元格时,最新标记的单元格始终可见。如果可见,IsCellVisible
方法返回true
,而MakeCellVisible
方法通过滚动确保其可见性,如果需要的话。
无参数的MakeCellVisible
方法调用带有编辑单元格的参数的MakeCellVisible
方法,或者根据应用是否处于edit
或mark
模式,调用最后标记的单元格的MakeCellVisible
方法。
bool IsCellVisible(Reference cellRef) const;
void MakeCellVisible();
void MakeCellVisible(Reference cellRect);
当用户在一个单元格中完成输入文本后,会调用ToMarkMode
方法,尝试将应用模式从edit
更改为mark
。如果输入失败(如果输入了有语法错误的公式),则返回false
:
bool ToMarkMode();
当用户标记一个或多个单元格时,会调用Remark
方法。为了避免混淆,它不会标记已经标记的单元格:
void Remark(Reference newFirstRef, Reference newLastRef);
当用户在mark
模式下输入字符时,会调用OnChar
方法;应用模式将变为edit
模式:
void OnChar(TCHAR tChar);
OnKeyDown
方法调用一个特定的按键处理方法,在edit
模式下改变光标位置,在mark
模式下改变单元格标记:
bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
void OnLeftArrowKey(bool shiftPressed);
void OnRightArrowKey(bool shiftPressed);
void OnUpArrowKey(bool shiftPressed);
void OnDownArrowKey(bool shiftPressed);
void OnHomeKey(bool shiftPressed, bool controlPressed);
void OnEndKey(bool shiftPressed, bool controlPressed);
OnReturnKey
和OnTabulatorKey
方法在edit
模式下完成输入(除非发生语法错误)并将标记位置向下移动一步(Return),向左(Shift + Tab)或向右(Tab)。然而,如果发生错误,将显示错误消息框,并且edit
模式保持不变。用户完成有语法错误的公式的输入的唯一方法是按下Esc键,在这种情况下将调用OnEscapeKey
方法,并将单元格的值重置为输入开始时的值:
void OnReturnKey();
void OnTabulatorKey(bool shiftPressed);
void OnEscapeKey();
OnDeleteKey
和OnBackspaceKey
方法从edit
模式中删除当前字符,并在mark
模式下清除标记的单元格:
void OnDeleteKey();
void OnBackspaceKey();
与前面的应用程序类似,当用户选择 新建 菜单项时调用 ClearDocument
方法,当用户选择 打开 菜单项时调用 ReadDocumentFromStream
方法,当用户选择 保存 或 另存为 菜单项时调用 WriteDocumentToStream
方法:
void ClearDocument();
bool ReadDocumentFromStream(String name, istream& inStream);
bool WriteDocumentToStream(String name, ostream& outStream)
const;
以等号(=) 开头后跟带有单元格引用的数值表达式被视为 公式。技术上讲,等号后跟的不是数值表达式的内容也被视为公式。然而,在这种情况下,它是一个存在语法错误的公式。当用户输入公式时,公式中引用的单元格构成了单元格的 源集。单元格的 目标集 由具有该单元格作为源(集合在本章末尾更精确地定义)的单元格组成。WriteSetMapToStream
和 ReadSetMapFromStream
方法写入和读取源和目标集映射:
static bool WriteSetMapToStream(const map<Reference,
set<Reference>>& setMap, ostream& outStream);
static bool ReadSetMapFromStream(map<Reference,set<Reference>>
&setMap, istream& inStream);
在此应用程序中,我们重写了 StandardDocument
类中的 IsCopyAsciiReady
、IsCopyUnicodeReady
和 IsCopyGenericReady
方法。它们是由 StandardDocument
类中的 OnCopy
方法调用的:
bool CopyEnable() const;
bool IsCopyAsciiReady() const {return true;}
bool IsCopyUnicodeReady() const {return true;}
bool IsCopyGenericReady(int format) const {return true;}
可能看起来很奇怪,CopyEnable
方法以及三个更具体的启用方法都被重写了。然而,CopyEnable
方法在应用程序准备好复制时返回 true
(在 mark
模式下是这样),而其他方法是由 StandardDocument
类中的 OnCopy
方法调用来决定应用程序是否准备好以给定格式复制。
它们的默认实现是返回 false
,但我们需要重写它们,因为在 mark
模式下始终可以复制标记的单元格:
void CopyAscii(vector<String>& textList) const;
void CopyUnicode(vector<String>& textList) const;
void CopyGeneric(int format, InfoList& infoList) const;
我们可以像重写 CopyEnable
方法一样重写 StandardDocument
类中的 PasteEnable
方法。然而,在此应用程序中,我们需要进行一些更精细的测试。因此,我们重写了 IsPasteAsciiReady
、IsPasteUnicodeReady
和 IsPasteGenericReady
方法。在前几章的文字处理器中,我们可以始终粘贴文本,无论字符数或段落数。然而,在此应用程序中,我们需要检查要粘贴的块是否适合电子表格:
bool IsPasteAsciiReady(const vector<String>& textList) const;
bool IsPasteUnicodeReady(const vector<String>& textList)const;
bool IsPasteGenericReady(int format, InfoList& infoList)const;
与文字处理器类似,我们重写了 PasteAscii
、PasteUnicode
和 PasteGeneric
方法。记住,这些方法是在 CalcDocument
构造函数调用中给出的格式列表的顺序中调用的。当相应的启用方法 IsPasteAsciiReady
、IsPasteUnicodeReady
或 IsPasteGenericReady
返回 true
时,将调用 PasteAscii
、PasteUnicode
或 PasteGeneric
方法。只有第一个粘贴方法会被调用。如果没有任何启用方法返回 true
,则不会调用任何粘贴方法:
void PasteAscii(const vector<String>& textList);
void PasteUnicode(const vector<String>& textList);
void PasteGeneric(int format, InfoList& infoList);
DeleteEnable
方法在 mark
模式下始终返回 true
,因为总是至少有一个单元格被标记并准备好删除。在 edit
模式下,如果光标不在编辑单元格文本的末尾,则返回 true
。OnDelete
方法简单地调用 OnDeleteKey
方法,因为删除菜单项与用户按下 Delete 键具有相同的效果:
bool DeleteEnable() const;
void OnDelete();
当用户选择字体或背景颜色菜单项时,会调用 OnFont
和 OnBackgroundColor
方法。它们显示标准的字体或颜色对话框:
DEFINE_VOID_LISTENER(CalcDocument, OnFont);
DEFINE_VOID_LISTENER(CalcDocument, OnBackgroundColor);
水平方向上,单元格的文本可以左对齐、居中对齐、右对齐或两端对齐。垂直方向上,它可以上对齐、居中对齐或底对齐。所有单选方法都调用 IsHorizontalAlignment
或 IsVerticalAlignment
方法,所有选择方法都调用 SetHorizontalAlignment
或 SetVerticalAlignment
方法:
DEFINE_BOOL_LISTENER(CalcDocument, HorizontalLeftRadio);
DEFINE_BOOL_LISTENER(CalcDocument, HorizontalCenterRadio);
DEFINE_BOOL_LISTENER(CalcDocument, HorizontalRightRadio);
DEFINE_BOOL_LISTENER(CalcDocument, HorizontalJustifiedRadio);
DEFINE_VOID_LISTENER(CalcDocument, OnHorizontalLeft);
DEFINE_VOID_LISTENER(CalcDocument, OnHorizontalCenter);
DEFINE_VOID_LISTENER(CalcDocument, OnHorizontalRight);
DEFINE_VOID_LISTENER(CalcDocument, OnHorizontalJustified);
bool IsHorizontalAlignment(Alignment alignment) const;
void SetHorizontalAlignment(Alignment alignment);
DEFINE_BOOL_LISTENER(CalcDocument, VerticalTopRadio);
DEFINE_BOOL_LISTENER(CalcDocument, VerticalCenterRadio);
DEFINE_BOOL_LISTENER(CalcDocument, VerticalBottomRadio);
DEFINE_VOID_LISTENER(CalcDocument, OnVerticalTop);
DEFINE_VOID_LISTENER(CalcDocument, OnVerticalCenter);
DEFINE_VOID_LISTENER(CalcDocument, OnVerticalBottom);
bool IsVerticalAlignment(Alignment alignment) const;
void SetVerticalAlignment(Alignment alignment);
InterpretEditCell
方法在用户完成输入后解释单元格,并在公式的情况下创建一个语法树(在第九章公式解释中描述,公式解释)。如果出现语法错误,则抛出异常。IsCircular
方法如果单元格是循环引用的一部分(单元格公式直接或间接地引用自身),则返回 true
。RemoveTargetSetMap
方法删除单元格的目标,而 AddTargetSetMap
方法向单元格添加目标。EvaluateCell
方法评估单个单元格的值,而 EvaluateRecursive
方法递归地评估所有目标单元格的值。最后,InvalidateCell
方法使单元格无效,以便稍后可以重新绘制:
bool InterpretEditCell();
bool IsCircular(Reference cellRef, set<Reference>& targetSet);
void RemoveTargetSetMap(Reference cellRef);
void AddTargetSetMap(Reference cellRef,
set<Reference>& newSourceSet);
void InvalidateCell(Reference cellRef);
void EvaluateRecursive(Reference cellRef,
set<Reference>& invalidateSet);
void EvaluateCell(Reference cellRef);
如本节开头所述,calcMode
方法设置为 Mark
或 Edit
,我们将其当前值称为标记模式和编辑模式:
private:
enum CalcMode {Edit, Mark} calcMode = Mark;
markOk
字段由 OnMouseDown
方法设置,以通知 OnMouseMove
方法可以标记单元格:
bool markOk;
在 mark
模式下,firstMarkRef
和 lastMarkRef
字段分别指向电子表格中第一个和最后一个标记的单元格。请注意,它们指的是它们的顺序而不是物理顺序,这意味着第一个标记引用可以大于最后一个标记引用。在必要时,在某些方法中,会计算最小和最大引用:
Reference firstMarkRef, lastMarkRef, editRef;
在 edit
模式下,editRef
指的是当前编辑的单元格,而 editIndex
指的是单元格文本中的下一个输入位置索引(以及光标位置):
int editIndex;
cellMatrix
字段持有应用程序的电子表格。Rows
和 Cols
是常量值,Cell
是包含每个单元格信息的类。Matrix
在第九章公式解释中定义。
Matrix<Rows,Cols,Cell> cellMatrix;
当用户在单元格中输入一个公式时,公式中的每个引用都成为一个源。同样,每个源单元格被赋予一个目标单元格。单元格的源和目标集合存储在sourceSetMap
和targetSetMap
方法中:
map<Reference,set<Reference>> sourceSetMap, targetSetMap;
在识别剪切、复制和粘贴格式时使用的CalcFormat
方法的值被任意选择为 1003:
static const unsigned int CalcFormat = 1003;
当用户使用Esc键完成单元格的输入时,单元格的先前内容(输入开始之前存储在单元格中的内容)被存储在prevCell
变量中,并复制回单元格:
Cell prevCell;
};
CalcDocument.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
#include "Error.h"
#include "Scanner.h"
#include "TreeNode.h"
#include "Parser.h"
#include "Cell.h"
#include "CalcDocument.h"
构造函数以与上一章中的文字处理器相同的方式调用StandardDocument
构造函数。然而,请注意,我们选择了LogicalWithoutScroll
坐标系(在文字处理器中,我们选择了LogicalWithScroll
)。这意味着当用户更改滚动条设置时,客户端区域的坐标不会更新。相反,我们必须使用OnHorizontalScroll
和OnVerticalScroll
方法来捕获滚动条移动。这是因为行和列标题始终放置在客户端区域的顶部和左侧,无论当前的滚动条设置如何。此外,我们还想使滚动产生精确的行和列移动。我们还把第七个参数设置为false
,表示在这个应用程序的文件菜单中省略打印和打印预览文件项:
CalcDocument::CalcDocument(WindowShow windowShow)
:StandardDocument(LogicalWithoutScroll, USLetterPortrait,
TEXT("Calc Files, clc; Text Files, txt"),
nullptr, OverlappedWindow, windowShow,
{CalcFormat, UnicodeFormat, AsciiFormat},
{CalcFormat, UnicodeFormat, AsciiFormat}) {
在这个应用程序中,我们只将格式菜单添加到文件、编辑和帮助标准菜单之外的标准菜单栏中。格式菜单包含字体和背景颜色项目,以及子菜单水平对齐和垂直对齐。
Menu menuBar(this);
menuBar.AddMenu(StandardFileMenu(false));
menuBar.AddMenu(StandardEditMenu());
Menu formatMenu(this, TEXT("F&ormat"));
formatMenu.AddItem(TEXT("&Font ...\tCtrl+F"), OnFont);
formatMenu.AddItem(TEXT("&Background Color ...\tCtrl+B"),
OnBackgroundColor);
Menu horizontalMenu(this, TEXT("&Horizontal Alignment"));
horizontalMenu.AddItem(TEXT("&Left"), OnHorizontalLeft,
nullptr, nullptr, HorizontalLeftRadio);
horizontalMenu.AddItem(TEXT("&Center"), OnHorizontalCenter,
nullptr, nullptr, HorizontalCenterRadio);
horizontalMenu.AddItem(TEXT("&Right"), OnHorizontalRight,
nullptr, nullptr, HorizontalRightRadio);
horizontalMenu.AddItem(TEXT("&Justified"),OnHorizontalJustified,
nullptr, nullptr, HorizontalJustifiedRadio);
Menu verticalMenu(this, TEXT("&Vertical Alignment"));
verticalMenu.AddItem(TEXT("&Top"), OnVerticalTop,
nullptr, nullptr, VerticalTopRadio);
verticalMenu.AddItem(TEXT("&Center"), OnVerticalCenter,
nullptr, nullptr, VerticalCenterRadio);
verticalMenu.AddItem(TEXT("&Bottom"), OnVerticalBottom,
nullptr, nullptr, VerticalBottomRadio);
formatMenu.AddMenu(horizontalMenu);
formatMenu.AddMenu(verticalMenu);
menuBar.AddMenu(formatMenu);
menuBar.AddMenu(StandardHelpMenu());
SetMenuBar(menuBar);
对于工作表中的每个单元格,都会调用GenerateCaretList
方法,尽管每个单元格最初都是空的。然而,为了应对用户双击空单元格的情况,我们会在文本右侧的位置生成一个额外的光标矩形。如果用户这样做,我们使用光标列表来找到点击的字符索引(对于空单元格,这个索引自然为零):
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
cellMatrix[Reference(row, col)].GenerateCaretList(this);
}
}
鼠标输入
OnMouseDown
和OnMouseMove
方法查找鼠标位置所在的电子表格部分,并标记适当的单元格集合。如果用户输入了一个语法错误的公式,则无法从edit
模式切换到mark
模式,因此会在消息框中显示错误消息,并且edit
模式保持不变。在这种情况下,markOk
方法被设置为false
,表示OnMouseMove
和OnDoubleClick
方法将不采取任何操作:
void CalcDocument::OnMouseDown(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed /*=false*/,
bool controlPressed /* = false */) {
if ((calcMode == Mark) || ToMarkMode()) {
markOk = true;
Reference newFirstMarkRef;
ClickArea clickArea =
GetMouseLocation(mousePoint, newFirstMarkRef);
MarkBlock(clickArea, newFirstMarkRef, newFirstMarkRef);
UpdateCaret();
}
else {
markOk = false;
}
}
注意,OnMouseMove
方法仅在OnMouseDown
方法中将markOk
方法设置为true
时才会采取行动。由于OnMouseDown
方法总是在OnMouseMove
方法之前被调用,因此markOk
方法总是被正确设置。OnMouseDown
和OnMouseMove
方法之间有一个区别,即OnMouseDown
方法设置第一个和最后一个标记的单元格引用,而OnMouseMove
方法只设置最后一个标记的单元格引用:
void CalcDocument::OnMouseMove(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed /*=false*/,
bool controlPressed /* = false */) {
if ((mouseButtons == LeftButton) && markOk) {
Reference newLastMarkRef;
ClickArea clickArea =
GetMouseLocation(mousePoint, newLastMarkRef);
MarkBlock(clickArea, firstMarkRef, newLastMarkRef);
}
}
当用户双击时,输入位置(和光标)将被设置为点击的字符。我们以与OnMouseDown
和OnMouseMove
方法相同的方式查找点击区域。然而,只有当用户点击单元格而不是全选框或行或列标题时,双击才生效。我们标记点击的单元格,将应用程序设置为edit
模式,并通过调用MouseToIndex
方法从单元格中提取编辑索引:
void CalcDocument::OnDoubleClick(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed /*=false*/,
bool controlPressed /* = false */) {
if ((mouseButtons == LeftButton) && markOk) {
ClickArea clickArea = GetMouseLocation(mousePoint, editRef);
if (clickArea == ClickCell) {
calcMode = Edit;
Cell& editCell = cellMatrix[editRef];
prevCell = editCell;
editCell.DisplayFormula();
editIndex = editCell.MouseDown(mousePoint.X() % ColWidth);
InvalidateCell(editRef);
UpdateWindow();
UpdateCaret();
}
}
}
滚动和标记
当用户更改滚动条设置时,会调用OnHorizontalScroll
和OnVerticalScroll
方法。我们将位置调整到最近的列或行并设置滚动位置。这些方法(连同GetMouseLocation
)是我们选择在CalcDocument
构造函数调用中选择LogicalWithoutScroll
坐标系的原因:
void CalcDocument::OnHorizontalScroll(WORD flags, WORD x) {
int col = x / ColWidth;
SetHorizontalScrollPosition(col * ColWidth);
}
void CalcDocument::OnVerticalScroll(WORD flags, WORD y) {
int row = y / RowHeight;
SetVerticalScrollPosition(row * RowHeight);
}
GetMouseLocation
方法获取鼠标点击的位置,并返回客户端窗口的四个区域之一:左上角的全选框(ClickAll
)、列标题之一(ClickCol
)、行标题之一(ClickRow
)或电子表格中的单元格之一(ClickCell
)。为了使这些方法正常工作,我们必须在CalcDocument
构造函数调用中选择LogicalWithoutScroll
坐标系。我们必须能够在不考虑当前滚动设置的情况下找到鼠标位置。
如果用户点击全选框(其中水平和垂直位置都在标题维度内),我们返回ClickAll
方法:
CalcDocument::ClickArea CalcDocument::GetMouseLocation
(Point mousePoint, Reference& cellRef) const {
if ((mousePoint.X() <= HeaderWidth) &&
(mousePoint.Y() <= HeaderHeight)) {
return ClickAll;
}
如果鼠标点击不在全选框内但位于标题宽度内,我们返回ClickRow
方法并将单元格引用设置为点击的行。如果鼠标点击在底部行下方,则选择底部行:
else if (mousePoint.X() <= HeaderWidth) {
mousePoint.Y() += GetVerticalScrollPosition() - HeaderHeight;
cellRef = Reference(min(Rows-1, mousePoint.Y()/RowHeight), 0);
return ClickRow;
}
如果鼠标点击不在全选框或行标题内,但位于标题高度内,我们返回ClickCol
方法并将单元格引用设置为点击的列。如果鼠标点击在最右侧列的右侧,则选择最右侧的列:
else if (mousePoint.Y() <= HeaderHeight) {
mousePoint.X() += GetHorizontalScrollPosition() - HeaderWidth;
cellRef = Reference(0, min(Cols - 1,
mousePoint.X() / ColWidth));
return ClickColumn;
}
如果鼠标点击不在全选框内或在行或列标题上,我们返回ClickCell
方法并将单元格引用设置为点击的单元格。如果鼠标点击在底部行下方,则选择底部行,并且如果鼠标点击在最右侧列的右侧,则选择最右侧的列:
else {
mousePoint.X() += GetHorizontalScrollPosition() - HeaderWidth;
mousePoint.Y() += GetVerticalScrollPosition() - HeaderHeight;
cellRef = Reference(min(Rows - 1, mousePoint.Y() / RowHeight),
min(Cols - 1, mousePoint.X() / ColWidth));
return ClickCell;
}
}
下面是电子表格不同部分的概述:
MarkBlock
方法根据clickArea
参数标记工作表的一部分:
void CalcDocument::MarkBlock(ClickArea clickArea,
Reference newFirstMarkRef, Reference newLastMarkRef) {
switch (clickArea) {
如果用户点击全选框,工作表中的所有单元格都会被标记:
case ClickAll:
Remark(ZeroReference, Reference(Rows - 1, Cols - 1));
break;
如果他们点击一行,该行中的所有单元格都会被标记:
case ClickRow:
Remark(Reference(newFirstMarkRef.Row(), 0),
Reference(newLastMarkRef.Row(), Cols - 1));
break;
如果他们点击一个列,该列中的所有单元格都会被标记:
case ClickColumn:
Remark(Reference(0, newFirstMarkRef.Col()),
Reference(Rows - 1, newLastMarkRef.Col()));
break;
如果他们点击一个单元格,只有该单元格会被标记:
case ClickCell:
Remark(newFirstMarkRef, newLastMarkRef);
break;
}
}
绘制
当窗口客户端区域需要部分或完全重绘时,会调用OnDraw
方法。客户端区域可以划分为五个部分,如前所述:左上角、行标题、列标题、单元格空间以及工作表外的区域:
void CalcDocument::OnDraw(Graphics& graphics,
DrawMode /* drawMode */) const {
int horizontalScroll = GetHorizontalScrollPosition(),
verticalScroll = GetVerticalScrollPosition();
我们使用滚动条设置来找到顶部和最左边的行和列。我们不能简单地绘制所有单元格(除非滚动条设置为零),因为这会覆盖行或列标题:
int startRow = horizontalScroll / RowHeight,
startCol = verticalScroll / ColWidth;
全选框只是一个矩形:
graphics.DrawRectangle(Rect(0, 0, HeaderWidth, HeaderHeight),
Black);
当绘制列标题时,我们通过将列索引乘以列宽度来计算单元格左边的水平位置。我们还需要减去当前的水平滚动条设置并加上标题的宽度。第一列的索引为零,将被命名为A
,因此我们将列索引加到字符A
上以找到其名称:
for (int col = startCol; col < Cols; ++col) {
int x = (col * ColWidth) - horizontalScroll + HeaderWidth;
Rect headerRect(x, 0, x + ColWidth, HeaderHeight);
graphics.DrawRectangle(Rect(x, 0, x + ColWidth, HeaderHeight),
Black);
TCHAR buffer[] = {(TCHAR) (TEXT('A') + col), TEXT('\0')};
graphics.DrawText(headerRect, buffer,
SystemFont, Black, White);
}
同样,当绘制行标题时,我们通过将行索引乘以行高度来计算单元格顶部的垂直位置。我们还需要减去当前的垂直滚动条设置并加上标题的高度:
for (int row = startRow; row < Rows; ++row) {
int y = (row * RowHeight) - verticalScroll + HeaderHeight;
Rect headerRect(0, y, HeaderWidth, y + RowHeight);
graphics.DrawRectangle(Rect(0, y, HeaderWidth, y + RowHeight),
Black);
String buffer = to_String(row + 1);
graphics.DrawText(headerRect, buffer,
SystemFont, Black, White);
}
由于标记的单元格将被反转,并且firstMarkRef
和lastMarkRef
方法指的是标记的时间顺序,因此我们计算最小和最大的标记:
int minMarkRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
maxMarkRow = max(firstMarkRef.Row(), lastMarkRef.Row()),
maxMarkCol = max(firstMarkRef.Col(), lastMarkRef.Col());
最后,我们绘制单元格。对于标记或正在编辑的单元格,第三个DrawCell
参数是true
,单元格被反转:
for (int row = startRow; row < Rows; ++row) {
for (int col = startCol; col < Cols; ++col) {
bool edit = (calcMode == Edit) &&
(row == editRef.Row())&&(col == editRef.Col());
bool mark = (calcMode == Mark) &&
(row >= minMarkRow) && (row <= maxMarkRow) &&
(col >= minMarkCol) && (col <= maxMarkCol);
Reference cellRef(row, col);
Cell cell = cellMatrix[cellRef];
cell.DrawCell(graphics, cellRef, edit || mark);
}
}
}
可视性
IsCellVisible
方法返回true
,如果单元格在窗口的客户端区域内可见。第一行和最后一行以及第一列和最后一列的索引是从当前的滚动条设置中计算出来的。然后,给定的单元格引用与可见单元格的引用进行比较:
bool CalcDocument::IsCellVisible(Reference cellRef) const{
int horizontalScrollPos = GetHorizontalScrollPosition(),
horizontalScrollPage = GetHorizontalScrollPageWidth();
int firstVisibleRow = horizontalScrollPos / RowHeight;
int lastVisibleRow = firstVisibleRow +
(horizontalScrollPage / RowHeight);
int verticalScrollPos = GetVerticalScrollPosition(),
verticalScrollPage = GetVerticalScrollPageHeight();
int firstVisibleCol = verticalScrollPos / ColWidth;
int lastVisibleCol = firstVisibleCol +
(verticalScrollPage / ColWidth);
int row = cellRef.Row(), col = cellRef.Col();
return (row >= firstVisibleRow) && (row <= lastVisibleRow) &&
(col >= firstVisibleCol) && (col <= lastVisibleCol);
}
MakeCellVisible
方法在edit
模式下使正在编辑的单元格可见,在mark
模式下使最后标记的单元格可见:
void CalcDocument::MakeCellVisible() {
switch (calcMode) {
case Edit:
MakeCellVisible(editRef);
break;
case Mark:
MakeCellVisible(lastMarkRef);
break;
}
}
MakeCellVisible
方法通过将其与当前的滚动条设置进行比较,使单元格可见。如果需要,它会更改滚动条设置:
void CalcDocument::MakeCellVisible(Reference cellRef) {
Point topLeft(cellRef.Col() * ColWidth,
cellRef.Row() * RowHeight);
Rect cellRect(topLeft, Size(ColWidth, RowHeight));
Size clientSize = GetClientSize();
首先,我们检查工作表的宽度是否大于客户端区域的宽度,如果是这样,可能需要更改水平滚动条的设置:
if (clientSize.Width() < (HeaderWidth + Cols * ColWidth)) {
int left = GetHorizontalScrollPosition(),
xPage = GetHorizontalScrollPageWidth();
int right = left + xPage - 1;
如果单元格的左边界位于客户端区域左边界左侧或单元格的右边界位于客户端区域右边界右侧,我们将更改滚动条设置,如下所示:
if (cellRect.Left() < left) {
SetHorizontalScrollPosition(cellRect.Left());
Invalidate();
UpdateWindow();
}
if (cellRect.Right() > right) {
int distance = cellRect.Right() - right;
distance += ColWidth - distance % ColWidth;
SetHorizontalScrollPosition(left + distance);
Invalidate();
UpdateWindow();
}
}
如果工作表的高度超过客户端区域的高度,可能需要更改水平滚动条的设置:
if (clientSize.Height() < (HeaderHeight + Rows * RowHeight)) {
int top = GetHorizontalScrollPosition(),
yPage = GetHorizontalScrollPageWidth();
int bottom = top + yPage - 1;
如果单元格的顶部边框位于客户端区域顶部边框之上或单元格的底部边框位于客户端区域底部边框之下,我们更改滚动条设置:
if (cellRect.Top() < top) {
SetVerticalScrollPosition(cellRect.Top());
Invalidate();
UpdateWindow();
}
if (cellRect.Bottom() > bottom) {
int distance = cellRect.Bottom() - bottom;
distance += RowHeight - distance % RowHeight;
SetVerticalScrollPosition(top + distance);
Invalidate();
UpdateWindow();
}
}
}
标记和更新
UpdateCaret
方法在编辑的单元格可见时将光标设置在 edit
模式下。否则,它清除光标。我们必须检查单元格是否可见。否则,光标可能会显示在某个标题区域中。在键盘 insert
模式下,光标是一个垂直条,而在 overwrite
模式下,它是一个与当前字符大小相同的矩形。
void CalcDocument::UpdateCaret() {
if ((calcMode == Edit) && IsCellVisible(editRef)) {
Point topLeft(HeaderWidth + (editRef.Col() * ColWidth) +
CellMargin, HeaderHeight + (editRef.Row() *
RowHeight) + CellMargin);
Cell& editCell = cellMatrix[editRef];
Rect caretRect = editCell.CaretList()[editIndex];
if (GetKeyboardMode() == InsertKeyboard) {
caretRect.Right() = caretRect.Left() + 1;
}
SetCaret(topLeft + caretRect);
}
else {
ClearCaret();
}
}
当用户通过按下 Return 或 Tab 键或点击鼠标来结束单元格中的文本输入时,会调用 ToMarkMode
方法。它的第一个任务是调用 InterpretEditCell
方法来检查输入是否有效,如果文本包含语法错误的公式,则返回 false
。在这种情况下,edit
模式保持不变,并返回 false
。然而,如果单元格解释顺利,应用程序设置为 mark
模式,并返回 true
:
bool CalcDocument::ToMarkMode() {
if (calcMode == Edit) {
if (InterpretEditCell()) {
calcMode = Mark;
firstMarkRef = editRef;
lastMarkRef = editRef;
return true;
}
return false;
}
return true;
}
Remark
方法取消标记已标记的单元格,并标记由参数给出的新块,而不进行任何不必要的更新。也就是说,已标记的单元格不应被无效化。请注意,第一个和最后一个标记单元格指的是它们的时序顺序,而不是它们在电子表格上的位置。最后一行或列可能不如第一行或列明显。因此,我们引入最小和最大变量来反映它们在电子表格中的实际位置:
void CalcDocument::Remark(Reference newFirstRef,
Reference newLastRef) {
Reference
minOldMarked(min(firstMarkRef.Row(), lastMarkRef.Row()),
min(firstMarkRef.Col(), lastMarkRef.Col())),
maxOldMarked(max(firstMarkRef.Row(), lastMarkRef.Row()),
max(firstMarkRef.Col(), lastMarkRef.Col())),
minNewMarked(min(newFirstRef.Row(), newLastRef.Row()),
min(newFirstRef.Col(), newLastRef.Col())),
maxNewMarked(max(newFirstRef.Row(), newLastRef.Row()),
max(newFirstRef.Col(), newLastRef.Col()));
在上一个标记块中,所有不在新标记块中的单元格都被无效化,以便它们被重新绘制为未标记的单元格。新标记块内的任何旧单元格都不会被无效化:
for (int row = minOldMarked.Row();
row <= maxOldMarked.Row(); ++row) {
for (int col = minOldMarked.Col();
col <= maxOldMarked.Col(); ++col) {
Reference cellRef(row, col);
if (!cellRef.Inside(minNewMarked, maxNewMarked)) {
InvalidateCell(cellRef);
}
}
}
在新标记块中,所有不在旧标记块中的单元格都被无效化,以便它们被重新绘制为未标记的单元格。任何已经标记的单元格都不会被无效化:
for (int row = minNewMarked.Row();
row <= maxNewMarked.Row(); ++row) {
for (int col = minNewMarked.Col();
col <= maxNewMarked.Col(); ++col) {
Reference cellRef(row, col);
if (!cellRef.Inside(minOldMarked, maxOldMarked)) {
InvalidateCell(Reference(row, col));
}
}
}
设置第一个和最后一个标记引用,并更新无效化的单元格:
firstMarkRef = newFirstRef;
lastMarkRef = newLastRef;
UpdateWindow();
}
键盘输入
每次用户在键盘上按下图形键时都会调用 OnCharDown
方法。在 mark
模式下,应用程序更改为 edit
模式,其中编辑引用设置为第一个标记引用,编辑索引设置为输入开始以来的零,并将 prevCell
变量设置为备份,以防用户通过按下 Esc 键完成输入:
void CalcDocument::OnChar(TCHAR tChar) {
if (calcMode == Mark) {
calcMode = Edit;
editRef = firstMarkRef;
Remark(editRef, editRef);
editIndex = 0;
Cell& editCell = cellMatrix[editRef];
prevCell = *editCell;
editCell.Reset();
}
要编辑的单元格被设置为可见,字符被添加到文本中,并且光标矩形被重新生成。最后,由于单元格已被更改并且编辑索引已更新,因此更新光标和窗口:
MakeCellVisible(editRef);
Cell& cell = cellMatrix[editRef];
cell.CharDown(editIndex++, tChar, GetKeyboardMode());
cell.GenerateCaretList(this);
InvalidateCell(editRef);
UpdateCaret();
UpdateWindow();
}
每次用户按下键时都会调用 OnKeyDown
方法。在箭头键、Page Up、Page Down、Home、End、Return、Tab、Insert、Delete 或 Backspace 的情况下,会调用适当的方法:
bool CalcDocument::OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed) {
switch (key) {
case KeyLeft:
OnLeftArrowKey(shiftPressed);
break;
case KeyRight:
OnRightArrowKey(shiftPressed);
break;
case KeyUp:
OnUpArrowKey(shiftPressed);
break;
case KeyDown:
OnDownArrowKey(shiftPressed);
break;
case KeyHome:
OnHomeKey(shiftPressed, controlPressed);
break;
case KeyEnd:
OnEndKey(shiftPressed, controlPressed);
break;
case KeyReturn:
OnReturnKey();
break;
case KeyTabulator:
OnTabulatorKey(shiftPressed);
break;
case KeyEscape:
OnEscapeKey();
break;
case KeyDelete:
OnDeleteKey();
break;
case KeyBackspace:
OnBackspaceKey();
break;
}
UpdateCaret();
UpdateWindow();
return true;
}
当用户按下左箭头键时,会调用OnLeftArrowKey
方法。根据edit
或mark
模式以及用户是否按下Shift键,我们有三种不同的情况要考虑。在edit
模式下,我们使编辑单元格可见,如果编辑索引不在最左侧位置,则将其向左移动一个步骤,并更新光标:
void CalcDocument::OnLeftArrowKey(bool shiftPressed) {
switch (calcMode) {
case Edit: {
MakeCellVisible(editRef);
if (editIndex > 0) {
--editIndex;
}
}
break;
在mark
模式下,我们必须考虑是否按下了Shift键。如果没有按下,我们将标记的块(第一个和最后一个标记单元格)相对于最后一个标记单元格向左移动一个步骤,除非它已经在最左侧列:
case Mark:
if (lastMarkRef.Col() > 0) {
if (!shiftPressed) {
Reference newLastMarkRef(lastMarkRef.Row(),
lastMarkRef.Col() - 1);
MakeCellVisible(newLastMarkRef);
Remark(newLastMarkRef, newLastMarkRef);
}
如果按下了Shift键,如果标记单元格不在最左侧位置,我们将最后一个标记单元格向左移动一个步骤,除非它已经在最左侧位置。第一个标记单元格不受影响:
else {
Reference newLastRefMark(lastMarkRef.Row(),
lastMarkRef.Col() - 1);
MakeCellVisible(newLastRefMark);
Remark(firstMarkRef, newLastRefMark);
}
}
break;
}
}
当用户按下右箭头键时,会调用OnRightArrowKey
方法。它的工作方式与OnLeftArrowKey
方法类似。在edit
模式下,我们使编辑单元格可见,如果编辑索引不在最右侧位置,则将其向右移动一个步骤,并更新光标:
void CalcDocument::OnRightArrowKey(bool shiftPressed) {
switch (calcMode) {
case Edit: {
MakeCellVisible(editRef);
if (editIndex <
((int) cellMatrix[editRef].GetText().length())) {
++editIndex;
}
}
break;
在mark
模式下,我们必须考虑是否按下了Shift键。如果没有按下,我们将标记的块相对于第一个标记单元格向右移动一个步骤,除非它已经在最右侧列:
case Mark:
if (lastMarkRef.Col() < (Cols - 1)) {
if (!shiftPressed) {
Reference newLastMarkRef(lastMarkRef.Row(),
lastMarkRef.Col() + 1);
MakeCellVisible(newLastMarkRef);
Remark(newLastMarkRef, newLastMarkRef);
}
如果按下了Shift键,如果标记单元格不在最右侧位置,我们将最后一个标记单元格向右移动一个步骤,除非它已经在最右侧位置。第一个标记单元格不受影响:
else {
Reference newLastRefMark(lastMarkRef.Row(),
lastMarkRef.Col() + 1);
MakeCellVisible(newLastRefMark);
Remark(firstMarkRef, newLastRefMark);
}
}
break;
}
}
当用户按下上箭头键时,会调用OnUpArrowKey
方法。在edit
模式下,不执行任何操作:
void CalcDocument::OnUpArrowKey(bool shiftPressed) {
switch (calcMode) {
case Edit:
break;
如果在mark
模式下没有按下Shift键,如果标记单元格不在最上行,我们将标记单元格相对于第一个标记单元格向上移动一个步骤。在这种情况下,我们将标记块放在第一个标记单元格中:
case Mark:
if (lastMarkRef.Row() > 0) {
if (!shiftPressed) {
Reference newLastMarkRef(lastMarkRef.Row() - 1,
lastMarkRef.Col());
MakeCellVisible(newLastMarkRef);
Remark(newLastMarkRef, newLastMarkRef);
}
如果按下了Shift键,如果标记单元格不在最上行,我们将最后一个标记单元格向上移动一个步骤,除非它已经在最上行。第一个标记单元格不受影响:
else {
Reference newLastRefMark(lastMarkRef.Row() - 1,
lastMarkRef.Col());
MakeCellVisible(newLastRefMark);
Remark(firstMarkRef, newLastRefMark);
}
}
break;
}
}
当用户按下下箭头键时,会调用OnDownArrowKey
方法。它的工作方式与OnUpArrowKey
方法类似。在edit
模式下,不执行任何操作:
void CalcDocument::OnDownArrowKey(bool shiftPressed) {
switch (calcMode) {
case Edit:
break;
如果在mark
模式下没有按下Shift键,我们将标记的块相对于第一个标记单元格向下移动一个步骤,除非它已经在最后一行:
case Mark:
if (lastMarkRef.Row() < (Rows - 1)) {
if (!shiftPressed) {
Reference newMarkRef(lastMarkRef.Row() + 1,
lastMarkRef.Col());
MakeCellVisible(newMarkRef);
Remark(newMarkRef, newMarkRef);
}
如果按下了Shift键,我们将最后一个标记单元格向下移动一个步骤,除非它已经在最后一行。第一个标记单元格不受影响:
else {
Reference newLastRefMark(lastMarkRef.Row() + 1,
lastMarkRef.Col());
MakeCellVisible(newLastRefMark);
Remark(firstMarkRef, newLastRefMark);
}
}
break;
}
}
当用户按下Home键时,会调用OnHomeKey
方法。在edit
模式下,我们使编辑单元格可见,将编辑索引移动到最左侧索引,并更新光标:
void CalcDocument::OnHomeKey(bool shiftPressed,
bool controlPressed) {
switch (calcMode) {
case Edit: {
MakeCellVisible(editRef);
editIndex = 0;
UpdateCaret();
}
break;
如果在mark
模式下没有按下Shift或Ctrl键,我们将标记块移动到第一个标记行的最左侧列。如果按下了Shift键,我们将最后一个标记单元格移动到最后一个标记行的最左侧列。第一个标记单元格不受影响:
case Mark:
if (!shiftPressed && !controlPressed) {
Remark(Reference(firstMarkRef.Row(), 0),
Reference(firstMarkRef.Row(), 0));
MakeCellVisible(firstMarkRef);
}
else if (shiftPressed && !controlPressed) {
Remark(firstMarkRef, Reference(firstMarkRef.Row(), 0));
MakeCellVisible(lastMarkRef);
}
如果按下Ctrl键但没有按下Shift键,我们将标记的块移动到左上角单元格。如果没有按下Ctrl键,我们将最后一个标记的单元格移动到该行的最左侧位置:
else if (!shiftPressed && controlPressed) {
Remark(ZeroReference, ZeroReference);
MakeCellVisible(lastMarkRef);
}
else if (shiftPressed && controlPressed) {
Remark(firstMarkRef, ZeroReference);
MakeCellVisible(lastMarkRef);
}
break;
}
}
当用户按下End键时,会调用OnEndKey
方法,其工作方式与OnHomeKey
方法类似。在edit
模式下,我们使编辑单元格可见,将编辑索引移动到最右侧索引,并更新光标:
void CalcDocument::OnEndKey(bool shiftPressed, bool controlPressed) {
switch (calcMode) {
case Edit: {
MakeCellVisible(editRef);
editIndex = cellMatrix[editRef].GetText().length();
UpdateCaret();
}
break;
在mark
模式下,如果没有按下Shift键或Ctrl键,我们将标记的块移动到第一个标记行的最右侧列。如果按下Shift键,我们将最后一个标记的单元格移动到最后一个标记行的最右侧列。第一个标记的单元格不受影响:
case Mark:
if (!shiftPressed && !controlPressed) {
Remark(Reference(firstMarkRef.Row(), Cols - 1),
Reference(firstMarkRef.Row(), Cols - 1));
MakeCellVisible(firstMarkRef);
}
else if (shiftPressed && !controlPressed) {
Remark(firstMarkRef,
Reference(firstMarkRef.Row(), Cols - 1));
MakeCellVisible(lastMarkRef);
}
如果按下Ctrl键但没有按下Shift键,我们将标记的块移动到底部右边的单元格。如果没有按下Ctrl键,我们将最后一个标记的单元格移动到该行的最右侧位置:
else if (!shiftPressed && controlPressed) {
Remark(Reference(Rows - 1, Cols - 1),
Reference(Rows - 1, Cols - 1));
MakeCellVisible(lastMarkRef);
}
else if (shiftPressed && controlPressed) {
Remark(firstMarkRef, Reference(Rows - 1, Cols - 1));
MakeCellVisible(lastMarkRef);
}
break;
}
}
Return键结束编辑会话,除非用户输入了有语法错误的公式,在这种情况下会显示错误信息框。用户也可以通过按下Tab键或单击鼠标来结束编辑;在任一情况下,Remark
方法负责完成编辑过程。当编辑完成后,我们尝试标记单元格:
void CalcDocument::OnReturnKey() {
if ((calcMode == Mark) || ToMarkMode()) {
Reference newMarkedRef(min(firstMarkRef.Row() + 1, Rows - 1),
firstMarkRef.Col());
Remark(newMarkedRef, newMarkedRef);
MakeCellVisible(newMarkedRef);
}
}
Tab键几乎与Return键做同样的事情。唯一的区别是,如果可能的话,下一个标记的单元格是向右或向左的单元格(如果用户按下了Shift键):
void CalcDocument::OnTabulatorKey(bool shiftPressed) {
if ((calcMode == Mark) || ToMarkMode()) {
if (shiftPressed && (lastMarkRef.Col() > 0)) {
Reference firstMarkRef(lastMarkRef.Row(),
firstMarkRef.Col() - 1);
Remark(firstMarkRef, firstMarkRef);
MakeCellVisible(firstMarkRef);
}
if (!shiftPressed && (lastMarkRef.Col() < (Cols - 1))) {
Reference firstMarkRef(firstMarkRef.Row(),
firstMarkRef.Col() + 1);
Remark(firstMarkRef, firstMarkRef);
MakeCellVisible(firstMarkRef);
}
}
}
当用户按下Esc键时,会调用OnEscapeKey
方法,并将单元格重置为prevCell
变量的值:
void CalcDocument::OnEscapeKey() {
if (calcMode == Edit) {
Cell& editCell = cellMatrix[editRef];
editCell = prevCell;
InvalidateCell(editRef);
calcMode = Mark;
firstMarkRef = lastMarkRef = editRef;
}
}
当用户按下Delete键或选择删除菜单项来删除edit
模式下的字符或在mark
模式下的标记块内容时,会调用OnDeleteKey
方法。在edit
模式下,我们删除编辑索引处的字符,除非它位于文本的末尾。在mark
模式下,我们只需重置标记的单元格。当单元格被重置时,我们需要递归地重新评估它们的目标单元格:
void CalcDocument::OnDeleteKey() {
switch (calcMode) {
case Edit: {
Cell& editCell = cellMatrix[editRef];
String& cellText = editCell.GetText();
if (editIndex < ((int) cellText.length())) {
String leftPart = cellText.substr(0, editIndex),
rightPart = cellText.substr(editIndex + 1);
editCell.SetText(leftPart + rightPart);
editCell.GenerateCaretList(this);
InvalidateCell(editRef);
UpdateWindow();
SetDirty(true);
}
}
break;
case Mark: {
int minMarkRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
maxMarkRow = max(firstMarkRef.Row(), lastMarkRef.Row()),
maxMarkCol = max(firstMarkRef.Col(), lastMarkRef.Col());
set<Reference> invalidateSet;
for (int row = minMarkRow; row <= minMarkRow; ++row) {
for (int col = minMarkCol; col <= minMarkCol; ++col) {
Reference cellRef = Reference(row, col);
cellMatrix[cellRef].Reset();
EvaluateRecursive(editRef, invalidateSet);
}
}
for (Reference cellRef : invalidateSet) {
InvalidateCell(cellRef);
}
UpdateWindow();
SetDirty(true);
}
break;
}
}
当用户按下Backspace键以在edit
模式下的单元格中删除字符或在mark
模式下的标记块内容中删除内容时,会调用OnBackspaceKey
方法。在edit
模式下,我们通过调用新索引处的OnDeleteKey
方法来递减编辑索引并删除字符,除非编辑位置已经在文本的开头。在mark
模式下,我们只需调用OnDeleteKey
方法:
void CalcDocument::OnBackspaceKey() {
switch (calcMode) {
case Edit:
if (editIndex > 0) {
--editIndex;
OnDeleteKey();
}
break;
case Mark:
OnDeleteKey();
break;
}
}
文件管理
与前面的应用类似,当用户选择新建菜单项时,StandardDocument
类会调用ClearDocument
方法,当用户选择保存或另存为时,会调用WriteDocumentToStream
方法,当用户选择打开菜单项时,会调用ReadDocumentFromStream
方法。
在ClearDocument
方法中,每个单元格及其源集和目标集都会被清除。当单元格被重置时,其文本会被清除。当它被清除时,其字体和颜色也会被清除。最后,应用程序设置为mark
模式,其中左上角的单元格被标记:
void CalcDocument::ClearDocument() {
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
cellMatrix[Reference(row, col)].Clear();
}
}
sourceSetMap.clear();
targetSetMap.clear();
calcMode = Mark;
firstMarkRef.Clear();
lastMarkRef.Clear();
}
WriteCellToStream
方法是一个回调函数,它接受一个单元格和一个输出流,并将单元格写入流中。同样,ReadCellFromStream
方法从输入流中读取单元格:
void WriteCellToStream(Cell cell, ostream& outStream) {
cell.WriteCellToStream(outStream);
}
void ReadCellFromStream(Cell& cell, istream& inStream) {
cell.ReadCellFromStream(inStream);
}
WriteDocumentToStream
和ReadDocumentFromStream
方法写入和读取电子表格。更具体地说,它们读取和写入application
模式、编辑索引和引用、标记引用、源集和目标集以及单元格矩阵中的单元格:
bool CalcDocument::WriteDocumentToStream(String name,
ostream& outStream)const{
if (EndsWith(name, TEXT(".clc"))) {
outStream.write((char*) &calcMode, sizeof calcMode);
outStream.write((char*) &editIndex, sizeof editIndex);
editRef.WriteReferenceToStream(outStream);
firstMarkRef.WriteReferenceToStream(outStream);
lastMarkRef.WriteReferenceToStream(outStream);
prevCell.WriteCellToStream(outStream);
WriteSetMapToStream(sourceSetMap, outStream);
WriteSetMapToStream(targetSetMap, outStream);
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
cellMatrix[row][col].WriteCellToStream(outStream);
}
}
}
else if (EndsWith(name, TEXT(".txt"))) {
for (int row = 0; row < Rows; ++row) {
if (row > 0) {
outStream << "\n";
}
for (int col = 0; col < Cols; ++col) {
if (col > 0) {
outStream << "\t";
}
const Cell& cell = cellMatrix[row][col];
String text = cell.IsFormula()
? (TEXT("=") + cell.TreeToString())
: cell.GetText();
for (TCHAR c : text) {
outStream << ((char) c);
}
}
}
}
return ((bool) outStream);
}
注意,我们在ReadDocumentFromStream
方法的末尾调用MakeCellVisible
方法。其想法是用户应该能够从他们离开的地方继续使用电子表格:
bool CalcDocument::ReadDocumentFromStream(String name,
istream& inStream) {
if (EndsWith(name, TEXT(".clc")) &&
ReadPrintSetupInfoFromStream(inStream)){
inStream.read((char*)&calcMode, sizeof calcMode);
inStream.read((char*) &editIndex, sizeof editIndex);
editRef.ReadReferenceFromStream(inStream);
firstMarkRef.ReadReferenceFromStream(inStream);
lastMarkRef.ReadReferenceFromStream(inStream);
prevCell.ReadCellFromStream(inStream);
ReadSetMapFromStream(sourceSetMap, inStream);
ReadSetMapFromStream(targetSetMap, inStream);
MakeCellVisible();
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
cellMatrix[Reference(row, col)].
ReadCellFromStream(inStream);
}
}
}
else if (EndsWith(name, TEXT(".txt"))) {
String text;
int row = 0, col = 0;
while (inStream) {
char c;
inStream.read(&c, sizeof c);
if (inStream) {
switch (c) {
case ';':
cellMatrix[Reference(row, col++)].SetText(text);
text.clear();
break;
case '\n':
cellMatrix[Reference(row++, col)].SetText(text);
text.clear();
col = 0;
break;
default:
text.push_back((TCHAR) c);
break;
}
}
}
}
return ((bool) inStream);
}
WriteSetMapToStream
和ReadSetMapFromStream
方法写入和读取源集和目标集。它们是静态的,因为它们为sourceSetMap
和targetSetMap
都调用。对于电子表格中的每个单元格,都会写入和读取集合的大小以及集合的引用:
bool CalcDocument::WriteSetMapToStream(const
map<Reference,set<Reference>>& setMap,
ostream& outStream) {
int mapSize = setMap.size();
outStream.write((char*) &mapSize, sizeof mapSize);
for (pair<Reference,set<Reference>> entry : setMap) {
Reference cellRef = entry.first;
cellRef.WriteReferenceToStream(outStream);
set<Reference> set = entry.second;
int setSize = set.size();
outStream.write((char*) &setSize, sizeof setSize);
for (Reference ref : set) {
ref.WriteReferenceToStream(outStream);
}
}
return ((bool) outStream);
}
bool CalcDocument::ReadSetMapFromStream
(map<Reference,set<Reference>>& setMap,
istream& inStream) {
int mapSize;
inStream.read((char*) &mapSize, sizeof mapSize);
for (int mapIndex = 0; mapIndex < mapSize; ++mapIndex) {
Reference cellRef;
cellRef.ReadReferenceFromStream(inStream);
int setSize;
inStream.read((char*) &setSize, sizeof setSize);
set<Reference> set;
for (int setIndex = 0; setIndex < setSize; ++setIndex) {
Reference ref;
ref.ReadReferenceFromStream(inStream);
set.insert(ref);
}
setMap[cellRef] = set;
}
return ((bool) inStream);
}
剪切、复制和粘贴
复制菜单项在mark
模式下被启用。请注意,我们没有覆盖PasteEnable
方法,因为StandardDocument
类会在剪贴板缓冲区包含应用程序格式之一(即AsciiFormat
、UnicodeFormat
或CalcFormat
格式)时启用粘贴菜单项:
bool CalcDocument::CopyEnable() const {
return (calcMode == Mark);
}
CopyAscii
方法简单地调用CopyUnicode
,而CopyUnicode
方法则将复制的文本填充到textList
列表中。textList
列表中的每个文本都代表一行,列之间由分号(;
)分隔:
void CalcDocument::CopyAscii(vector<String>& textList) const {
CopyUnicode(textList);
}
void CalcDocument::CopyUnicode(vector<String>& textList) const {
int minMarkRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(), lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
String text;
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
const Cell& markCell = cellMatrix[markRef];
text.append(((col > 0) ? TEXT(";") : TEXT("")) +
markCell.TreeToString());
}
textList.push_back(text);
}
}
CopyGeneric
方法存储标记块的左上角位置和大小,并为每个标记单元格调用WriteCellToClipboard
方法:
void CalcDocument::CopyGeneric(int /* format */,
InfoList& infoList) const {
int minRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
copyRows = abs(firstMarkRef.Row() - lastMarkRef.Row()) + 1,
copyCols = abs(firstMarkRef.Col() - lastMarkRef.Col()) + 1;
infoList.AddValue<int>(copyRows);
infoList.AddValue<int>(copyCols);
infoList.AddValue<int>(minRow);
infoList.AddValue<int>(minCol);
for (int row = 0; row < copyRows; ++row) {
for (int col = 0; col < copyCols; ++col) {
Reference sourceRef(minRow + row, minCol + col);
const Cell& cell = cellMatrix[sourceRef];
cell.WriteCellToClipboard(infoList);
}
}
}
IsPasteAsciiReady
方法简单地调用IsPasteUnicodeReady
方法,如果当前只标记了一个单元格且要粘贴的块适合在电子表格中,或者当前标记的块与要粘贴的块具有相同大小,则返回true
。请注意,在第一种情况下,如果只标记了一个单元格,要粘贴的块不需要每行有相同数量的列,只要它们适合在电子表格中即可:
bool CalcDocument::IsPasteAsciiReady
(const vector<String>& textList) const {
return IsPasteUnicodeReady(textList);
}
bool CalcDocument::IsPasteUnicodeReady
(const vector<String>& textList) const {
int markedRows = abs(firstMarkRef.Row() - lastMarkRef.Row()) +1,
markedCols = abs(firstMarkRef.Col() - lastMarkRef.Col()) +1,
minMarkedRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkedCol = min(firstMarkRef.Col(), lastMarkRef.Col());
if ((markedRows == 1) && (markedCols == 1)) {
int copyRows = textList.size();
int maxCopyCols = 0;
for (String text : textList) {
maxCopyCols = max(maxCopyCols,
((int) Split(text, ';').size()));
}
return ((minMarkedRow + copyRows) < Rows) &&
((minMarkedCol + maxCopyCols) < Cols);
}
else {
if (textList.size() != markedRows) {
return false;
}
for (String text : textList) {
if (((int) Split(text, ';').size()) != markedCols) {
return false;
}
}
return true;
}
}
与IsPasteUnicodeReady
方法类似,IsPasteGenericReady
方法在当前只标记了一个单元格且要粘贴的块适合在电子表格中,或者当前标记的块和要粘贴的块具有相同大小时返回true
。然而,与之前看到的 Unicode 情况不同,要粘贴的通用块的每一行都具有相同的大小:
bool CalcDocument::IsPasteGenericReady(int /* format */,
InfoList& infoList) const {
int markedRows = abs(firstMarkRef.Row() - lastMarkRef.Row()) +1,
markedCols = abs(firstMarkRef.Col() - lastMarkRef.Col()) +1,
minMarkedRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkedCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
copyRows, copyCols;
infoList.PeekValue<int>(copyRows, 0);
infoList.PeekValue<int>(copyCols, sizeof(int));
return (((markedRows == copyRows)&&(markedCols == copyCols)) ||
((markedRows == 1) && (markedCols == 1))) &&
((minMarkedRow + copyRows) <= Rows) &&
((minMarkedCol + copyCols) <= Cols);
}
PasteAscii
方法简单地调用PasteUnicode
方法,首先备份单元格矩阵以及源和目标集映射,因为要粘贴的单元格可能包含有语法错误的公式,在这种情况下,粘贴过程将被终止。然后,它遍历要粘贴的文本并将每一行分割成列。每一列的文本被复制到粘贴的单元格中:
void CalcDocument::PasteAscii(const vector<String>& textList) {
PasteUnicode(textList);
}
void CalcDocument::PasteUnicode(const vector<String>& textList) {
Matrix<Rows,Cols,Cell> backupMatrix =
Matrix<Rows,Cols,Cell>(cellMatrix);
map<Reference,set<Reference>> backupSourceSetMap = sourceSetMap,
backupTargetSetMap = targetSetMap;
try {
set<Reference> invalidateSet;
int row = min(firstMarkRef.Row(), lastMarkRef.Row()),
minCol = min(firstMarkRef.Col(), lastMarkRef.Col());
Reference diffRef(row, minCol);
for (String rowText : textList) {
int col = minCol;
vector<String> columnList = Split(rowText, ';');
列的文本被解释,如果它包含有语法错误的公式,将抛出异常,停止迭代并恢复备份矩阵以及源和目标集映射。这实际上就是为什么EvaluateRecursive
方法填充要无效化的引用集而不是直接无效化的原因。如果粘贴过程由于语法错误的公式而失败,我们不希望任何单元格被无效化和更新:
for (String colText : columnList) {
Reference targetRef(row, col++);
RemoveTargetSetMap(targetRef);
Cell& targetCell = cellMatrix[targetRef];
targetCell.Reset();
targetCell.SetText(colText)
set<Reference> sourceSet;
targetCell.InterpretCell(sourceSet);
targetCell.GenerateCaretList(this);
当文本被解释后,我们需要更新引用,如果它包含公式,通过比较标记块的位置与粘贴块原始位置(它被复制的地方)来确定引用是否相对:
if (!diffRef.IsEmpty()) {
sourceSet.clear();
targetCell.UpdateTree(diffRef, sourceSet);
}
最后,我们设置单元格的源和目标集,评估其值,并生成其光标矩形列表。评估可能会导致错误(缺失值、引用超出范围、循环引用或除以零),在这种情况下,错误消息将存储在单元格文本中:
AddTargetSetMap(targetRef, sourceSet);
sourceSetMap[targetRef] = sourceSet;
EvaluateRecursive(targetRef, invalidateSet);
targetCell.GenerateCaretList(this);
}
++row;
}
粘贴的单元格只有在遍历它们并且没有发现包含有语法错误的公式时才被无效化。请注意,可能还有其他需要无效化的单元格,即粘贴块外的其他单元格,它们是粘贴单元格的目标,并且因此需要被评估:
for (Reference cellRef : invalidateSet) {
InvalidateCell(cellRef);
}
}
如果粘贴的单元格中包含有语法错误的公式,我们只需恢复备份并显示一个消息框:
catch (Error error) {
cellMatrix = backupMatrix;
sourceSetMap = backupSourceSetMap;
targetSetMap = backupTargetSetMap;
MessageBox(error.ErrorText(), TEXT("Syntax Error"), Ok, Stop);
}
}
PasteGeneric
方法比PasteUnicode
方法简单:由于不需要单元格解释(因为单元格是从电子表格中复制的,因此包含有效的公式),因此不需要备份,也不会抛出异常:
void CalcDocument::PasteGeneric(int /* format */,
InfoList& infoList) {
int minMarkedRow = min(firstMarkRef.Row(), lastMarkRef.Row()),
minMarkedCol = min(firstMarkRef.Col(), lastMarkRef.Col()),
copyRows, copyCols, minCopyRow, minCopyCol;
infoList.GetValue<int>(copyRows);
infoList.GetValue<int>(copyCols);
infoList.GetValue<int>(minCopyRow);
infoList.GetValue<int>(minCopyCol);
Reference diffRef(minMarkedRow - minCopyRow,
minMarkedCol - minCopyCol);
int maxCopyRow = minCopyRow + copyRows - 1,
maxCopyCol = minCopyCol + copyCols - 1;
每个粘贴的单元格都是从缓冲区中读取的,然后将源单元格分配给它。目标集被移除,然后由粘贴的单元格添加:
for (int row = minCopyRow; row <= maxCopyRow; ++row) {
for (int col = minCopyCol; col <= maxCopyCol; ++col) {
Cell pastedCell;
pastedCell.ReadCellFromClipboard(infoList);
Reference pastedRef(row, col);
Reference targetRef = pastedRef + diffRef;
RemoveTargetSetMap(targetRef);
Cell& targetCell = cellMatrix[targetRef];
targetCell = pastedCell;
set<Reference> sourceSet;
if (diffRef.IsEmpty()) {
targetCell.GenerateSourceSet(sourceSet);
}
else {
targetCell.UpdateTree(diffRef, sourceSet);
}
AddTargetSetMap(targetRef, sourceSet);
sourceSetMap[targetRef] = sourceSet;
set<Reference> invalidateSet;
EvaluateRecursive(targetRef, invalidateSet);
for (Reference cellRef : invalidateSet) {
InvalidateCell(cellRef);
}
}
}
UpdateWindow();
SetDirty(true);
}
在编辑
模式下,删除菜单项被启用,除非编辑索引位于单元格文本的末尾。在标记
模式下,该选项始终启用,因为总是至少有一个标记的单元格被标记为删除:
bool CalcDocument::DeleteEnable() const {
if (calcMode == Edit) {
const Cell& editCell = cellMatrix[editRef];
return (editIndex < ((int)editCell.GetText().length()));
}
else {
return true;
}
}
OnDelete
方法(菜单项)只是调用OnDeleteKey
(按下的键),因为它们执行相同的操作:
void CalcDocument::OnDelete() {
OnDeleteKey();
}
字体和颜色
OnFont
和 OnBackgroundColor
方法以相同的方式工作–当用户在 格式 菜单中选择 字体 或 背景颜色 项时调用它们。它们应用于编辑或标记的单元格,并且更新窗口和(在编辑情况下)光标。如果至少有一个单元格已被修改,则设置脏标志:
void CalcDocument::OnFont() {
switch (calcMode) {
case Edit: {
Cell& editCell = cellMatrix[editRef];
Font font = editCell.CellFont();
Font previousFont = font;
在 edit
模式下,如果 FontDialog
方法返回 true
(用户已按下 确定 按钮)并且选择了不同的字体,则编辑单元格的字体会改变。请注意,FontDialog
方法还设置字体的颜色:
if (StandardDialog::FontDialog(this, font) &&
(font != previousFont)) {
editCell.CellFont() = font;
editCell.GenerateCaretList(this);
InvalidateCell(editRef);
SetDirty(true);
UpdateCaret();
UpdateWindow();
}
}
break;
在 mark
模式下,如果 FontDialog
方法返回 true
(用户已按下 确定 按钮)并且选择了新的字体,则每个标记单元格的字体设置为新的字体。如果至少有一个单元格的字体被设置(我们一开始并不知道),则设置脏标志:
case Mark: {
Font font = cellMatrix[lastMarkRef].CellFont();
if (StandardDialog::FontDialog(this, font)) {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
Cell& markCell = cellMatrix[markRef];
if (markCell.CellFont() != font) {
markCell.CellFont() = font;
markCell.GenerateCaretList(this);
InvalidateCell(markRef);
SetDirty(true);
}
}
}
UpdateWindow();
}
}
break;
}
}
OnBackgroundColor
方法与 OnFont
方法类似。唯一的区别是 OnBackgroundColor
方法调用 ColorDialog
方法而不是 FontDialog
方法,并且对每个单元格调用 BackgroundColor
而不是 Font
:
void CalcDocument::OnBackgroundColor() {
switch (calcMode) {
case Edit: {
Cell& editCell = cellMatrix[editRef];
Color color = editCell.BackgroundColor();
Color previousColor = color;
if (StandardDialog::ColorDialog(this, color) &&
(color != previousColor)){
editCell.BackgroundColor() = color;
InvalidateCell(editRef);
SetDirty(true);
}
}
break;
case Mark: {
Color color = cellMatrix[lastMarkRef].BackgroundColor();
if (StandardDialog::ColorDialog(this, color)) {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
Cell& markCell = cellMatrix[markRef];
if (markCell.BackgroundColor() != color) {
markCell.BackgroundColor() = color;
InvalidateCell(markRef);
SetDirty(true);
}
}
}
}
}
break;
}
UpdateWindow();
}
对齐
水平和垂直对齐遵循相同的模式。单选方法调用 IsHorizontalAlignment
或 IsVerticalAlignment
方法,如果编辑的单元格或所有标记的单元格保持所询问的对齐方式,则返回 true
。选择方法调用 SetHorizontalAlignment
或 SetVerticalAlignment
方法,这些方法设置编辑单元格或每个标记单元格的对齐方式。如果至少有一个单元格已被修改,则设置脏标志。最后,更新窗口和(在编辑情况下)光标。
HorizontalLeftRadio
、HorizontalCenterRadio
、HorizontalRightRadio
和 HorizontalJustifiedRadio
方法调用 IsHorizontalAlignment
方法,正如你接下来会看到的:
bool CalcDocument::HorizontalLeftRadio() const {
return (IsHorizontalAlignment(Left));
}
bool CalcDocument::HorizontalCenterRadio() const {
return (IsHorizontalAlignment(Center));
}
bool CalcDocument::HorizontalRightRadio() const {
return (IsHorizontalAlignment(Right));
}
bool CalcDocument::HorizontalJustifiedRadio() const {
return (IsHorizontalAlignment(Justified));
}
如果编辑单元格或至少一个标记单元格保持所询问的对齐方式,则 IsHorizontalAlignment
方法返回 true
:
bool CalcDocument::IsHorizontalAlignment(Alignment alignment)
const {
switch (calcMode) {
case Edit:
return cellMatrix[editRef].HorizontalAlignment() ==
alignment;
case Mark: {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
if (cellMatrix[markRef].VerticalAlignment()!=
alignment) {
return true;
}
}
}
return false;
}
}
return true;
}
OnHorizontalLeft
、OnHorizontalCenter
、OnHorizontalRight
和 OnHorizontalJustified
方法调用 SetHorizontalAlignment
方法,如下所示:
void CalcDocument::OnHorizontalLeft() {
SetHorizontalAlignment(Left);
}
void CalcDocument::OnHorizontalCenter() {
SetHorizontalAlignment(Center);
}
void CalcDocument::OnHorizontalRight() {
SetHorizontalAlignment(Right);
}
void CalcDocument::OnHorizontalJustified() {
SetHorizontalAlignment(Justified);
}
SetHorizontalAlignment
方法设置编辑单元格或所有标记单元格的对齐方式:
void CalcDocument::SetHorizontalAlignment(Alignment alignment) {
switch (calcMode) {
case Edit: {
Cell& editCell = cellMatrix[editRef];
editCell.HorizontalAlignment() = alignment;
editCell.GenerateCaretList(this);
InvalidateCell(editRef);
UpdateCaret();
}
break;
case Mark: {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
Cell& markCell = cellMatrix[markRef];
对于每个对齐方式改变的单元格,其光标矩形列表被重新生成,并且单元格变得无效:
if (markCell.HorizontalAlignment() != alignment) {
markCell.HorizontalAlignment() = alignment;
markCell.GenerateCaretList(this);
InvalidateCell(markRef);
}
}
}
}
break;
}
脏标志被设置,因为至少有一个单元格已被修改。否则,对齐菜单项将不会被启用:
UpdateWindow();
SetDirty(true);
}
垂直对齐方法与水平对齐方法类似,正如我们在这里可以看到的:
bool CalcDocument::VerticalTopRadio() const {
return (IsVerticalAlignment(Top));
}
bool CalcDocument::VerticalCenterRadio() const {
return (IsVerticalAlignment(Center));
}
bool CalcDocument::VerticalBottomRadio() const {
return (IsVerticalAlignment(Bottom));
}
bool CalcDocument::IsVerticalAlignment(Alignment alignment) const {
switch (calcMode) {
case Edit:
return cellMatrix[editRef].VerticalAlignment() == alignment;
case Mark: {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
if (cellMatrix[markRef].VerticalAlignment() !=
alignment){
return true;
}
}
}
return false;
}
}
return true;
}
void CalcDocument::OnVerticalTop() {
SetVerticalAlignment(Top);
}
void CalcDocument::OnVerticalCenter() {
SetVerticalAlignment(Center);
}
void CalcDocument::OnVerticalBottom() {
SetVerticalAlignment(Bottom);
}
void CalcDocument::SetVerticalAlignment(Alignment alignment) {
switch (calcMode) {
case Edit: {
Cell& editCell = cellMatrix[editRef];
editCell.VerticalAlignment() = alignment;
editCell.GenerateCaretList(this);
InvalidateCell(editRef);
UpdateCaret();
}
break;
case Mark: {
int minMarkRow = min(firstMarkRef.Row(),
lastMarkRef.Row()),
maxMarkRow = max(firstMarkRef.Row(),
lastMarkRef.Row()),
minMarkCol = min(firstMarkRef.Col(),
lastMarkRef.Col()),
maxMarkCol = max(firstMarkRef.Col(),
lastMarkRef.Col());
for (int row = minMarkRow; row <= maxMarkRow; ++row) {
for (int col = minMarkCol; col <= maxMarkCol; ++col) {
Reference markRef = Reference(row, col);
Cell& markCell = cellMatrix[markRef];
if (markCell.VerticalAlignment() != alignment) {
markCell.VerticalAlignment() = alignment;
markCell.GenerateCaretList(this);
InvalidateCell(markRef);
}
}
}
}
break;
}
UpdateWindow();
SetDirty(true);
}
源和目标集
电子表格中的每个单元格都包含一个数值、一个公式或一个(可能为空)纯文本。如本章开头所述,公式是以等号(=)开头的文本,后跟一个带有单元格引用的数值表达式。如果单元格包含一个值,它可能会影响其他单元格的值(如果它不包含值,它可能会在目标单元格中引起评估错误)。如果单元格包含一个公式,其值可能取决于其他单元格的值。这意味着每个单元格都需要一个它依赖的单元格集合,即其源集合,以及一个依赖它的单元格集合,即其目标集合。
只有公式才有非空源集合,即该公式的所有引用集合。另一方面,目标集合更为复杂——一个单元格不决定自己的目标集合;它是由将其作为源单元格的公式间接决定的。
用数学术语来说,具有其源和目标集合的单元格构成一个有向图。技术上,它们构成两个不同的有向图,一个对应源集合,一个对应目标集合。然而,这两个图互为逆图,所以在所有实际意义上,它们可以被视为同一个图。
例如,在下面的截图之后,a3
的源集合包含a1
和c1
,因为其公式包含a1
和c1
。同样,c3
的源集合包含c1
,因为其公式包含c1
。a1
和c1
的源集合为空,因为它们不包含公式。
由于c1
同时包含在a3
和c3
的公式中,c1
的值会影响a3
和c3
的值。这意味着c1
的目标集合包含a3
和c3
。同样,由于a1
包含在a3
的公式中,a1
的目标集合包含a3
。由于a3
和c3
的值不影响任何其他单元格的值,它们的目标集合为空。
以下是一个截图,显示了同一电子表格,正在编辑的是c3
单元格而不是a3
单元格:
下面的第一个图显示了前面电子表格的源集合的无环图,第二个图显示了目标集合的无环图。如前所述(并由图所示),源集合和目标集合互为逆。技术上,我们可以只用其中一个集合。然而,由于在不同的场合需要使用这两个集合,所以使用两个集合的代码更清晰。
当单元格的值发生变化时,会遍历其目标集合,并更新这些单元格的值。然后遍历这些单元格的目标集合,依此类推。当没有更多单元格需要评估或检测到循环引用时,搜索终止。循环引用是通过深度搜索算法检测的,该算法将在下一节中描述。
图搜索
当用户更改单元格的值时,我们需要找到需要重新评估的单元格。再次注意源集和目标集之间的区别。虽然只有公式单元格可以有非空的源集,但所有类型的单元格(包括空单元格)都可以有非空的目标集。这两个集合之间的另一个区别是,目标集是通过其他单元格中的公式间接定义的。如果另一个单元格的公式包含对特定单元格的引用,则将该公式单元格的引用添加到特定单元格的目标集中。同样,当公式被更改或清除时,对该单元格的引用将从所有源单元格的目标集中删除。当单元格被更新时,所有其目标都会递归地被评估–目标单元格被重新评估,然后它们的目标单元格被重新评估,依此类推。评估总是在没有更多目标或遇到循环引用时终止。由于电子表格中的单元格数量是有限的,我们总会耗尽目标或遇到循环引用。
当用户完成单元格输入时,会调用InterpretEditCell
方法。该方法通过调用InterpretCell
方法来解释单元格,填充sourceSet
方法,但在公式存在语法错误的情况下会抛出异常:
bool CalcDocument::InterpretEditCell() {
try {
Cell& editCell = cellMatrix[editRef];
set<Reference> sourceSet;
editCell.InterpretCell(sourceSet);
然而,如果解析顺利,则删除之前的源集并添加新的源集:
RemoveTargetSetMap(editRef);
AddTargetSetMap(editRef, sourceSet);
sourceSetMap[editRef] = sourceSet;
然后递归评估该单元格,并更新其所有直接或间接的目标单元格:
set<Reference> invalidateSet;
EvaluateRecursive(editRef, invalidateSet);
editCell.GenerateCaretList(this);
最后,所有已评估的单元格都会被使无效,设置脏标志,并返回true
:
for (Reference cellRef : invalidateSet) {
InvalidateCell(cellRef);
}
SetDirty(true);
return true;
}
如果检测到语法错误并抛出异常,将显示错误消息并返回false
。在这种情况下,如果用户已完成输入,则应用程序将保持edit
模式。如果由于粘贴而调用InterpretEditCell
方法,则终止粘贴过程:
catch (Error error) {
MessageBox(error.ErrorText(), TEXT("Syntax Error"), Ok, Stop);
return false;
}
}
InvalidateCell
方法会使给定引用的单元格占用的区域无效:
void CalcDocument::InvalidateCell(Reference cellRef) {
Point topLeft(HeaderWidth + (cellRef.Col() * ColWidth),
HeaderHeight + (cellRef.Row() * RowHeight));
Size cellSize(ColWidth, RowHeight);
Rect cellRect(topLeft, cellSize);
Invalidate(cellRect);
}
源集和目标集可以通过两种方式搜索和评估:深度优先和广度优先。正如其名称所暗示的,深度优先尝试尽可能深入地搜索。当它达到死胡同时,它会回溯并尝试另一种方法,如果有的话。另一方面,广度优先评估从起始单元格相同距离的所有单元格。只有当距离相同的所有单元格都被评估后,才会检查下一个距离的单元格。
当用户添加或修改公式时,我们检测图中潜在的循环引用是至关重要的。IsCircular
方法决定单元格是否是循环引用的一部分,即直接引用其自身单元格或导致其自身单元格的引用链。我们执行深度优先搜索,这比广度优先搜索更容易,因为我们可以利用递归调用。另一方面,广度优先方法在EvaluateRecursive
方法中评估修改后的单元格的目标时是必要的,如下所示:
bool CalcDocument::IsCircular(Reference cellRef,
set<Reference>& targetSet){
for (Reference targetRef : targetSet) {
if ((cellRef == targetRef) ||
IsCircular(cellRef, targetSetMap[targetRef])) {
return true;
}
}
return false;
}
当单元格的值被修改时,必须通知引用该单元格的公式,并重新评估它们的值。EvaluateRecursive
方法通过跟随目标集进行广度优先搜索。
与我们之前看到的循环引用检查不同,我们不能执行深度优先搜索,因为这会引入单元格评估顺序错误的危险:
void CalcDocument::EvaluateCell(Reference cellRef) {
Cell& cell = cellMatrix[cellRef];
if (IsCircular(cellRef, targetSetMap[cellRef])) {
cell.SetText(Error(CircularReference).ErrorText());
}
else {
set<Reference> sourceSet = sourceSetMap[cellRef];
map<Reference, double> valueMap;
for (Reference sourceRef : sourceSet) {
Cell& sourceCell = cellMatrix[sourceRef];
if (sourceCell.HasValue()) {
valueMap[sourceRef] = sourceCell.GetValue();
}
}
cell.Evaluate(valueMap);
}
cell.GenerateCaretList(this);
}
当单元格正在被评估时,它需要其源集中的单元格的值;valueMap
参数持有具有某些值的源单元格的值。每个没有值的源单元格都从映射中省略:
void CalcDocument::EvaluateRecursive(Reference cellRef,
set<Reference>& invalidateSet) {
如果此单元格不是循环引用的一部分,我们将引用单元格的值与值添加到valueMap
参数中。没有值的引用单元格简单地从valueMap
参数中省略:
set<Reference> targetSet, evaluatedSet;
targetSet.insert(cellRef);
while (!targetSet.empty()) {
Reference targetRef = *targetSet.begin();
targetSet.erase(targetRef);
if (evaluatedSet.count(targetRef) == 0) {
EvaluateCell(targetRef);
evaluatedSet.insert(targetRef);
invalidateSet.insert(targetRef);
set<Reference> nextTargetSet = targetSetMap[targetRef];
targetSet.insert(nextTargetSet.begin(),
nextTargetSet.end());
}
}
}
无论单元格是否被正确评估或被发现是循环引用的一部分,我们都需要重新生成其光标矩形列表。它要么得到一个适当的值或错误消息,在两种情况下,文本都会改变:
cell.GenerateCaretList(this);
}
RemoveTargetSetMap
方法遍历单元格矩阵中单元格的源集,并对每个源单元格,将其作为目标移除。同样,AddTargetSetMap
方法遍历单元格矩阵中单元格的源集,并对每个源单元格,将其作为目标添加:
void CalcDocument::RemoveTargetSetMap(Reference cellRef) {
for (Reference sourceRef : sourceSetMap[cellRef]) {
int row = sourceRef.Row(), col = sourceRef.Col();
if ((row >= 0) && (row < Rows) && (col >= 0) && (col < Cols)){
targetSetMap[sourceRef].erase(cellRef);
}
}
}
void CalcDocument::AddTargetSetMap(Reference cellRef,
set<Reference>& sourceSet) {
for (Reference sourceRef : sourceSet) {
int row = sourceRef.Row(), col = sourceRef.Col();
if ((row >= 0) && (row < Rows) && (col >= 0) && (col < Cols)){
targetSetMap[sourceRef].insert(cellRef);
}
}
sourceSetMap[cellRef] = sourceSet;
}
错误处理
评估错误如下:
-
缺失值:当公式中引用的单元格没有值时,会发生此错误
-
引用超出范围:当引用超出工作表的作用域时,会发生此错误
-
循环引用:当单元格直接或间接地引用自身时,会发生此错误
-
除以零:当除法表达式的分母为零时,会发生此错误
用户输入语法不正确的公式时,也会发生语法错误。
Error.h
enum ErrorId {SyntaxError, CircularReference, ReferenceOutOfRange,
DivisionByZero, MissingValue};
class Error : public exception {
public:
Error(ErrorId errorId);
String ErrorText() const;
private:
ErrorId errorId;
};
Error.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Error.h"
Error::Error(ErrorId errorId)
:errorId(errorId) {
// Empty.
}
String Error::ErrorText() const{
switch (errorId) {
case SyntaxError:
return TEXT("Syntax Error.");
case CircularReference:
return TEXT("#Circular reference#");
case DivisionByZero:
return TEXT("#Division by Zero#");
case MissingValue:
return TEXT("#Missing Value#");
case ReferenceOutOfRange:
return TEXT("#Reference out of Range.#");
}
return TEXT("");
}
摘要
在本章中,我们探讨了电子表格程序是如何实现的:鼠标和键盘输入;剪切、复制和粘贴;文件管理;以及字体、颜色和对齐。第九章,公式解释,将介绍单元格处理和公式解释,包括解析、扫描和光标矩形列表生成。
第九章:公式解释
电子表格程序能够处理文本、数值和由四个算术运算符组成的公式。为了做到这一点,我们需要解释公式。我们还需要找到公式的来源(公式中引用的单元格)和单元格的目标(受单元格变化影响的单元格)。
在本章中,我们将探讨以下主题:
-
数值表达式的解释(扫描和解析)
-
解析和语法树
-
公式的评估
-
参考和矩阵
-
绘制单元格
-
单元格的加载和保存
在以下电子表格中,正在编辑的单元格是C3
:
公式解释
电子表格程序的核心是其解释公式的能力。当用户在单元格中输入一个公式时,它会被解释并计算其值。公式解释的过程分为三个独立的步骤。首先,给定输入字符串,扫描器生成标记列表,然后解析器生成语法树,最后评估器确定值。
标记是公式中最不重要的部分。例如,a1
被解释为引用,而1.2
被解释为值。假设根据以下表格,单元格具有以下值,公式解释过程将如下所示。请记住,公式是以等号(**=**
)开头的文本。
标记
扫描器以字符串作为输入,并找到其最不重要的部分——其标记。标记之间的空格被忽略,扫描器对大小写没有区别。Value
标记需要额外信息来跟踪实际值,这被称为属性。同样,Reference
需要一个属性来跟踪引用。在这个应用中,有九种不同的标记:
Token.h
enum TokenId {Plus, Minus, Star, Slash, LeftParenthesis,
RightParenthesis, RefToken, Number, EndOfLine};
Token | 描述 |
---|---|
Plus , Minus , Star , 和 Slash |
这四个是算术运算符:"+ ", "- ", "* ", 和 "/ " |
LeftParenthesis 和 RightParenthesis |
这是左右括号:"( " 和 ") " |
值 | 这是一个数值,例如,124 ,3.14 ,或-0.23 。无论是整数还是小数,都无关紧要。同样,如果存在小数点,它前面或后面是否有数字也无关紧要。然而,值必须至少包含一个数字。这需要一个类型为double 的值作为属性。 |
参考 | 这是一个参考,例如,b8, c6 。这需要一个Reference 对象作为属性。 |
行尾 | 这是行尾,字符串中没有更多(非空格)字符。 |
如前所述,字符串1.2 * (b2 + c3)
生成了下一页表中的令牌。列表末尾添加了行尾令牌。
文本 | 令牌 | 属性 |
---|---|---|
1.2 | 值 | 1.2 |
* | 星号 | |
( | 左括号 | |
b2 | 引用 | 行 1 ,列 1 |
+ | 加号 | |
c3 | 引用 | 行 2 ,列 2 |
) | 右括号 | |
行尾 |
令牌在Token
类中定义。令牌由一个令牌标识符、在值令牌的情况下一个双精度值,以及在引用令牌的情况下一个Reference
对象组成。
Token.h
class Token {
public:
Token(TokenId tokenId);
Token(double value);
Token(Reference reference);
TokenId Id() const {return tokenId;}
double Value() const {return value;}
Reference ReferenceField() const {return reference;}
private:
TokenId tokenId;
double value;
Reference reference;
};
Token.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
Token::Token(TokenId tokenId)
:tokenId(tokenId) {
// Empty.
}
Token::Token(double value)
:tokenId(Number),
value(value) {
// Empty.
}
Token::Token(Reference reference)
:tokenId(RefToken),
reference(reference) {
// Empty.
}
树节点
如前所述,解析器生成一个语法树。更具体地说,它生成一个Tree
类的对象(在第十二章辅助类中描述),这是一个具有节点类型的模板类:TreeNode
。节点有 10 个标识符,类似于Token
,值节点有一个双精度值作为其属性,引用节点有一个引用对象作为属性。
TreeNode.h
enum TreeId {EmptyTree, UnaryAdd, UnarySubtract, BinaryAdd, BinarySubtract,
Multiply, Divide, Parenthesis, RefId, ValueId};
当从文件或剪贴板缓冲区读取值时,使用默认构造函数。
class TreeNode {
public:
TreeNode();
TreeNode(TreeId id);
TreeNode(Reference reference);
TreeNode(double value);
电子表格的一个单元格可以保存到文件中,也可以剪切、复制和粘贴,因此我们包含了以下方法:
bool WriteTreeNodeToStream(ostream& outStream) const;
bool ReadTreeNodeFromStream(istream& inStream);
void WriteTreeNodeToClipboard(InfoList& infoList) const;
void ReadTreeNodeFromClipboard(InfoList& infoList);
节点的标识符和值只能被检查,不能被修改。然而,引用可以被修改,因为它在用户复制单元格并将其粘贴到另一个位置时会被更新:
TreeId Id() const {return id;}
double Value() const {return value;}
Reference ReferenceField() const {return reference;}
Reference& ReferenceField() {return reference;}
private:
TreeId id;
Reference reference;
double value;
};
TreeNode.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "TreeNode.h"
TreeNode::TreeNode()
:id(EmptyTree),
value(0) {
// Empty.
}
TreeNode::TreeNode(TreeId id)
:id(id),
value(0) {
// Empty.
}
TreeNode::TreeNode(Reference reference)
: id(RefId),
value(0),
reference(reference) {
// Empty.
}
TreeNode::TreeNode(double value)
:id(ValueId),
value(value) {
// Empty.
}
节点标识符、值和引用被写入和读取,如下所示:
bool TreeNode::WriteTreeNodeToStream(ostream& outStream) const {
outStream.write((char*) &id, sizeof id);
outStream.write((char*) &value, sizeof value);
reference.WriteReferenceToStream(outStream);
return ((bool) outStream);
}
bool TreeNode::ReadTreeNodeFromStream(istream& inStream) {
inStream.read((char*) &id, sizeof id);
inStream.read((char*) &value, sizeof value);
reference.ReadReferenceFromStream(inStream);
return ((bool) inStream);
}
void TreeNode::WriteTreeNodeToClipboard(InfoList& infoList) const {
infoList.AddValue<TreeId>(id);
infoList.AddValue<double>(value);
reference.WriteReferenceToClipboard(infoList);
}
void TreeNode::ReadTreeNodeFromClipboard(InfoList& infoList) {
infoList.GetValue<TreeId>(id);
infoList.GetValue<double>(value);
reference.ReadReferenceFromClipboard(infoList);
}
Scanner
– 生成令牌列表
Scanner
类的任务是分组字符为令牌。例如,*12.34*
被解释为值*12.34*
。构造函数接受一个字符串作为参数,而Scan
通过重复调用NextToken
直到字符串为空来生成令牌列表。
Scanner.h
class Scanner {
public:
Scanner(String buffer);
list<Token> Scan();
当NextToken
方法遇到字符串的末尾时,它返回EndOfLine
。如果ScanValue
和ScanReference
方法遇到值或引用,则返回true
:
Token NextToken();
bool ScanValue(double& value);
bool ScanReference(Reference& reference);
下一个令牌会不断地从缓冲区中读取,直到它为空:
private:
String buffer;
};
Scanner.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
#include "Error.h"
#include "Scanner.h"
为了简单起见,向字符串中添加了TEXT('\0')
;而不是检查剩余的文本是否为空,我们寻找null
字符:
Scanner::Scanner(String buffer)
:buffer(buffer + TEXT('\0')) {
// Empty.
}
Scan
方法将缓冲区中的令牌添加到tokenList
中,直到遇到EndOfLine
。最后,返回列表:
list<Token> Scanner::Scan() {
list<Token> tokenList;
while (true) {
Token token = NextToken();
tokenList.push_back(token);
if (token.Id() == EndOfLine) {
break;
}
}
return tokenList;
}
NextToken
方法通过在缓冲区中找到下一个令牌来完成扫描器的实际工作。首先,我们跳过空白字符。当涉及到算术符号和括号时,提取令牌相当简单。我们只需检查缓冲区的下一个字符。当涉及到数值或引用时,这会变得稍微困难一些。为此,我们有两个辅助方法:ScanValue
和ScanReference
。看看以下代码:
Token Scanner::NextToken() {
while (buffer[0] == TEXT(' ')) {
buffer.erase(0, 1);
}
switch (buffer[0]) {
case TEXT('\0'):
return Token(EndOfLine);
case TEXT('+'):
buffer.erase(0, 1);
return Token(Plus);
case TEXT('-'):
buffer.erase(0, 1);
return Token(Minus);
case TEXT('*'):
buffer.erase(0, 1);
return Token(Star);
case TEXT('/'):
buffer.erase(0, 1);
return Token(Slash);
case TEXT('('):
buffer.erase(0, 1);
return Token(LeftParenthesis);
case TEXT(')'):
buffer.erase(0, 1);
return Token(RightParenthesis);
如果没有适用任何简单情况,标记可能是一个值或一个引用。ScanValue
和 ScanReference
方法会找出是否是这样。如果不是,扫描器遇到了未知字符,并抛出语法错误异常:
default: {
double value;
Reference reference;
if (ScanValue(value)) {
return Token(value);
}
else if (ScanReference(reference)) {
return Token(reference);
}
else {
throw Error(SyntaxError);
}
}
break;
}
}
ScanValue
使用 _stscanf_s
标准函数,这是 sscanf
的安全通用版本。返回值存储在 fieldCount
中,如果成功读取双精度值,则将其设置为 1
。我们还需要读取的字符数,它存储在 charCount
中,以便从缓冲区中删除正确的字符数:
bool Scanner::ScanValue(double& value) {
int charCount;
int fieldCount = _stscanf_s(buffer.c_str(), TEXT("%lf%n"),
&value, &charCount);
if (fieldCount > 0) {
buffer.erase(0, charCount);
return true;
}
return false;
}
ScanReference
检查前两个字符是否为字母和数字。如果是,它将提取引用的列和行:
bool Scanner::ScanReference(Reference& reference) {
if (isalpha(buffer[0]) && (isdigit(buffer[1]))) {
我们通过从 a 减去小写字母来提取列,这给出第一列的索引为零,并从缓冲区中删除字母。
reference.Col() = tolower(buffer[0]) - TEXT('a');
buffer.erase(0, 1);
与 ScanValue
类似,我们通过调用 _stscanf_s
来提取行,它读取行整数值和字符数,我们使用这些信息来从缓冲区中删除读取的字符:
int row;
int charCount;
_stscanf_s(buffer.c_str(), TEXT("%d%n"), &row, &charCount);
reference.Row() = row - 1;
buffer.erase(0, charCount);
return true;
}
return false;
}
解析器 – 生成语法树
用户输入一个以等号(=)开头的公式。解析器的任务是翻译扫描器的标记列表为语法树。有效公式的语法可以通过语法来定义。让我们从一个处理使用算术运算符的表达式的语法开始:
语法是一组规则。在先前的语法中,有八个规则。公式和表达式被称为非终结符;行尾、值以及字符+、-、、/、(* 和 )被称为终结符。终结符和非终结符统称为符号。其中一条规则是语法的起始规则,在我们的例子中是第一条规则。起始规则左侧的符号被称为语法的起始符号,在我们的例子中是公式。
箭头可以读作“是”,先前的语法可以读作:
公式是一个表达式后跟行尾。表达式是两个表达式的和、差、积、商,或者括号内的表达式、引用或数值。
这是一个好的开始,但有几个问题。让我们测试字符串 1 + 2 * 3 是否被语法接受。我们可以通过进行推导来测试,从起始符号 Formula
开始,应用规则,直到只剩下终结符。以下推导中的数字指的是语法规则:
推导可以通过解析树的发展来表示。
让我们尝试对同一个字符串进行另一种推导,这次按照不同的顺序应用规则。
这个推导生成了一个不同的解析树,如下所示:
如果一个语法可以针对同一个输入字符串生成两个不同的解析树,那么它被认为是歧义的。第二个树显然违反了数学定律,即乘法的优先级高于加法,但语法并不知道这一点。避免歧义的一种方法是为每个优先级级别引入一组新的规则:
新的语法不是歧义的。如果我们用这个语法尝试我们的字符串,我们只能生成一个解析树,无论我们选择应用规则的顺序如何。有正式的方法可以证明语法不是歧义的;然而,这超出了本书的范围。请参阅本章末尾的参考文献。
这个推导给出了以下树。由于不可能从同一个输入字符串推导出两个不同的树,因此语法是无歧义的。
现在我们已经准备好编写解析器了。本质上,有两种类型的解析器:自顶向下解析器和自底向上解析器。正如术语所暗示的,自顶向下解析器从语法的起始符号和输入字符串开始,并尝试应用规则,直到我们只剩下终结符。自底向上解析器从输入字符串开始,并尝试反向应用规则,直到我们达到起始符号。
构建自底向上解析器是一个复杂的问题。通常不是手动完成的;相反,有解析器生成器为给定的语法构建一个解析器表和解析器实现的骨架代码。然而,自底向上解析的理论超出了本书的范围。
构建自顶向下解析器比构建自底向上解析器更容易。构建一个简单但效率低下的自顶向下解析器的一种方法是在随机顺序中应用所有可能的规则。如果我们遇到了死胡同,我们只需回溯并尝试另一条规则。一个更高效但相对简单的解析器是前瞻解析器。给定一个合适的语法,我们只需要查看下一个标记,就可以唯一确定要应用的规则。如果我们遇到了死胡同,我们不需要回溯;我们只需得出结论,即输入字符串根据语法是不正确的——它被称为语法错误。
实现一个前瞻解析器的第一次尝试可能是为语法中的每个规则编写一个函数。不幸的是,我们目前还不能这样做,因为这会导致一个像Expression
这样的函数:
Tree<TreeNode>* Parser::Expression() {
Token token = tokenList.front();
switch (token.Id()) {
case Plus:
Tree<TreeNode>* plusTree = Expression();
// ...
break;
}
}
你看到问题了吗?该方法在未改变输入流的情况下调用自身,这将导致无限次的递归调用。这被称为左递归。然而,我们可以通过简单的转换来解决该问题。
前面的规则可以转换为等价的规则集(其中 epsilon ε表示空字符串):
如果我们将这种转换应用于前面语法中的表达式和项规则,我们将得到以下语法:
让我们用我们的字符串1 + 2 * 3尝试这个新语法。
推导生成了以下解析树:
语法适合前瞻解析器的条件是,具有相同左侧符号的每个规则集必须以不同的终结符开始其右侧。如果没有空规则,它最多只能有一个以非终结符作为右侧第一个符号的规则。我们前面讨论的语法满足这些要求。
现在我们已经准备好编写解析器了。然而,解析器还应生成某种类型的输出,表示字符串。一种这样的表示是语法树,它可以被视为一个抽象的解析树——我们只保留必要的信息。例如,前面的解析树有一个匹配的语法,如下所示:
下面的代码是Parser
类。其思路是,我们为每个具有相同左侧符号的规则集编写一个方法。每个这样的方法生成结果语法树的一部分。构造函数接受要解析的文本,并让扫描器生成一个标记列表。然后,Parse
开始解析过程,并返回生成的语法树。如果在解析过程中发生错误,将抛出语法错误异常。当标记列表被解析后,我们应该确保列表中没有除EndOfLine
之外的额外标记。此外,如果输入缓冲区完全为空(用户只输入了一个等号),列表中仍然有EndOfLine
标记。
解析的结果是一个表示公式的语法树。例如,公式a1 * c3 / 3.6 + 2.4 * (b2 - 2.4)生成了以下语法树,我们利用了第十二章中的Tree
类,辅助类。
如前文在 TreeNode
节中所述,有九种类型的语法树:四个算术运算符、一元加法和减法、括号内的表达式、引用和数值。我们实际上不需要括号来正确存储公式,因为表达式的优先级存储在语法树本身中。然而,我们需要它来从语法树中重新生成原始字符串,当它在单元格中写入时。
Parser.h
class Parser {
public:
Parser(String buffer);
Tree<TreeNode>* Parse();
void Match(int tokenId);
Tree<TreeNode>* Expression();
Tree<TreeNode>* NextExpression(Tree<TreeNode>* leftTermPtr);
Tree<TreeNode>* Term();
Tree<TreeNode>* NextTerm(Tree<TreeNode>* leftFactorPtr);
Tree<TreeNode>* Factor();
private:
list<Token> tokenList;
};
Parse
方法被调用以解释用户输入的文本。它接收来自扫描器的标记列表,其中至少包含 EndOfLine
标记,并解析标记列表并接收指向语法树的指针。当标记列表被解析后,它会检查下一个标记是否为 EndOfLine
以确保缓冲区中没有多余的字符(除了空格):
Parser.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
#include "Error.h"
#include "Scanner.h"
#include "TreeNode.h"
#include "Parser.h"
Parser::Parser(String buffer) {
Scanner scanner(buffer);
tokenList = scanner.Scan();
}
Tree<TreeNode>* Parser::Parse() {
Tree<TreeNode>* resultTreePtr = Expression();
Match(EndOfLine);
return resultTreePtr;
}
Match
方法用于匹配列表中的下一个标记与期望的标记。如果它们不匹配或如果标记列表为空,则抛出一个语法错误异常。否则,下一个标记将从列表中移除:
void Parser::Match(int tokenId) {
if (tokenList.empty() || (tokenList.front().Id() != tokenId)) {
throw Error(SyntaxError);
}
tokenList.pop_front();
}
其余的方法实现了我们之前讨论的语法。对于 Expression
、NextExpression
、Term
、NextTerm
和 Factor
符号,每个都有一个方法:
Tree<TreeNode>* Parser::Expression() {
Tree<TreeNode>* termTreePtr = Term();
return NextExpression(termTreePtr);
}
NextExpression
方法负责处理加法和减法。如果下一个标记是 Plus
或 Minus
,我们将其匹配并解析其右操作数。然后,我们创建并返回一个新的包含运算符的语法树。如果下一个标记既不是 Plus
也不是 Minus
,我们假设适用另一条规则,并返回给定的左语法树:
Tree<TreeNode>* Parser::NextExpression(Tree<TreeNode>*
leftTermTreePtr) {
Token token = tokenList.front();
switch (token.Id()) {
case Plus: {
Match(Plus);
Tree<TreeNode>* rightTermTreePtr = Term();
Tree<TreeNode>* sumTreePtr =
new Tree<TreeNode>(TreeNode(BinaryAdd),
{leftTermTreePtr, rightTermTreePtr});
assert(sumTreePtr != nullptr);
return NextExpression(sumTreePtr);
}
case Minus: {
Match(Minus);
Tree<TreeNode>* rightTermTreePtr = Term();
Tree<TreeNode>* diffTreePtr =
new Tree<TreeNode>(TreeNode(BinarySubtract),
{leftTermTreePtr, rightTermTreePtr});
assert(diffTreePtr != nullptr);
return NextExpression(diffTreePtr);
}
default:
return leftTermTreePtr;
}
}
Tree<TreeNode>* Parser::Term() {
Tree<TreeNode>* pFactorTree = Factor();
return NextTerm(pFactorTree);
}
NextTerm
方法以类似于 NextExpression
的方式处理乘法和除法。记住,我们需要为语法中的每个优先级级别的方法集。
Tree<TreeNode>* Parser::NextTerm(Tree<TreeNode>*leftFactorTreePtr) {
Token token = tokenList.front();
switch (token.Id()) {
case Star: {
Match(Star);
Tree<TreeNode>* rightFactorTreePtr = Factor();
Tree<TreeNode>* productTreePtr =
new Tree<TreeNode>(TreeNode(Multiply),
Tree<TreeNode>* productTreePtr =
new Tree<TreeNode>(TreeNode(Multiply),
{leftFactorTreePtr, rightFactorTreePtr});
assert(productTreePtr != nullptr);
return NextExpression(productTreePtr);
}
case Slash: {
Match(Slash);
Tree<TreeNode>* rightFactorTreePtr = Factor();
Tree<TreeNode>* quotientTreePtr =
new Tree<TreeNode>(TreeNode(Divide),
{leftFactorTreePtr, rightFactorTreePtr});
assert(quotientTreePtr != nullptr);
return NextExpression(quotientTreePtr);
}
default:
return leftFactorTreePtr;
}
}
Factor
方法解析括号内的值、引用和表达式。如果下一个标记是一元运算符(加号或减号),我们解析其表达式并创建一个包含表达式的语法树:
Tree<TreeNode>* Parser::Factor() {
Token token = tokenList.front();
switch (token.Id()) {
case Plus: {
Match(Plus);
Tree<TreeNode>* nextExprTreePtr = Expression();
Tree<TreeNode>* plusTreePtr =
new Tree<TreeNode>(TreeNode(UnaryAdd),
{nextExprTreePtr});
assert(plusTreePtr!= nullptr);
return plusTreePtr;
}
case Minus: {
Match(Minus);
Tree<TreeNode>* nextExprTreePtr = Expression();
Tree<TreeNode>* minusTreePtr =
new Tree<TreeNode>(TreeNode(UnaryAdd),
{nextExprTreePtr});
assert(minusTreePtr!= nullptr);
return minusTreePtr;
}
如果下一个标记是左括号,我们将其匹配,解析随后的表达式,并匹配关闭的右括号:
case LeftParenthesis: {
Match(LeftParenthesis);
Tree<TreeNode>* innerExprTreePtr = Expression();
Match(RightParenthesis);
Tree<TreeNode>* resultTreePtr =
new Tree<TreeNode>(TreeNode(Parenthesis),
{innerExprTreePtr});
assert(resultTreePtr != nullptr);
return resultTreePtr;
}
如果下一个标记是引用,我们接收带有其行和列的引用属性并匹配引用标记。我们创建一个新的包含引用的语法树。请注意,解析器不会检查引用是否有效(是否指向电子表格内的单元格);这是公式值评估的任务:
case RefToken: {
Match(RefToken);
Tree<TreeNode>* resultTreePtr =
new Tree<TreeNode>(TreeNode(token.ReferenceField()));
assert(resultTreePtr != nullptr);
return resultTreePtr;
}
case Number: {
Match(Number);
Tree<TreeNode>* resultTreePtr =
new Tree<TreeNode>(TreeNode(token.Value()));
assert(resultTreePtr != nullptr);
return resultTreePtr;
}
如果前面的任何标记都不适用,则用户输入了一个无效的表达式,并抛出一个语法错误异常:
default:
throw Error(SyntaxError);
}
}
矩阵和引用
当存储电子表格的单元格时使用 Matrix
类,当访问电子表格中的单元格时使用 Reference
类。
引用类
Reference
类在 Matrix
类中持有单元格的行和列,如下一节所示:
Reference.h
namespace SmallWindows {
class Reference;
extern const Reference ZeroReference;
class Reference {
public:
默认构造函数将行和列初始化为零。引用可以通过 new
关键字初始化,并赋值给另一个引用:
Reference();
Reference(int row, int col);
Reference(const Reference& ref);
Reference& operator=(const Reference& ref);
比较运算符首先比较行。如果它们相等,则比较列:
friend bool operator==(const Reference& ref1,
const Reference& ref2);
friend bool operator!=(const Reference& ref1,
const Reference& ref2);
friend bool operator<(const Reference& ref1,
const Reference& ref2);
friend bool operator<=(const Reference& ref1,
const Reference& ref2);
friend bool operator>(const Reference& ref1,
const Reference& ref2);
friend bool operator>=(const Reference& ref1,
const Reference& ref2);
加法运算符分别对行和列进行加法和减法操作:
Reference& operator+=(const Reference& ref);
Reference& operator-=(const Reference& ref);
friend Reference operator+(const Reference& ref1,
const Reference& ref2);
friend Reference operator-(const Reference& ref1,
const Reference& ref2);
Clear
方法将行和列都设置为零,如果行和列为零,则 IsEmpty
返回 true
:
void Clear() {row = 0; col = 0;}
bool IsEmpty() const {return ((row == 0) && (col == 0));}
ToString
方法返回表示引用的字符串:
String ToString() const;
如果一个引用大于或等于最小引用且小于或等于最大引用,则它位于由最小和最大引用定义的引用块内:
bool Inside(Reference minRef, Reference maxRef);
引用可以写入和读取到文件流、剪贴板和注册表中:
bool WriteReferenceToStream(ostream& outStream) const;
bool ReadReferenceFromStream(istream& inStream);
void WriteReferenceToClipboard(InfoList& infoList) const;
void ReadReferenceFromClipboard(InfoList& infoList);
void WriteReferenceToRegistry(String key) const;
void ReadReferenceFromRegistry(String key,
Reference defaultRef = ZeroReference);
行和列通过常量方法进行检查,通过非常量方法进行修改:
int Row() const {return row;}
int Col() const {return col;}
int& Row() {return row;}
int& Col() {return col;}
private:
int row, col;
};
};
Reference.cpp
#include "..\\SmallWindows\\SmallWindows.h"
namespace SmallWindows {
const Reference ZeroReference(0, 0);
Reference::Reference()
:row(0),
col(0) {
// Empty.
}
Reference::Reference(int row, int col)
:row(row),
col(col) {
// Empty.
}
Reference::Reference(const Reference& ref)
:row(ref.row),
col(ref.col) {
// Empty.
}
Reference& Reference::operator=(const Reference& ref) {
if (this != &ref) {
row = ref.row;
col = ref.col;
}
return *this;
}
bool operator==(const Reference& ref1, const Reference& ref2) {
return (ref1.row == ref2.row) && (ref1.col == ref2.col);
}
bool operator!=(const Reference& ref1, const Reference& ref2) {
return !(ref1 == ref2);
}
bool operator<(const Reference& ref1, const Reference& ref2) {
return (ref1.row < ref2.row) ||
((ref1.row == ref2.row) && (ref1.col < ref2.col));
}
bool operator<=(const Reference& ref1, const Reference& ref2) {
return (ref1 < ref2) || (ref1 == ref2);
}
bool operator>(const Reference& ref1, const Reference& ref2) {
return !(ref1 <= ref2);
}
bool operator>=(const Reference& ref1, const Reference& ref2) {
return !(ref1 < ref2);
}
Reference& Reference::operator+=(const Reference& ref) {
row += ref.row;
col += ref.col;
return *this;
}
Reference& Reference::operator-=(const Reference& ref) {
row -= ref.row;
col -= ref.col;
return *this;
}
Reference operator+(const Reference& ref1,
const Reference& ref2) {
return Reference(ref1.row + ref2.row, ref1.col + ref2.col);
}
Reference operator-(const Reference& ref1,
const Reference& ref2) {
return Reference(ref1.row - ref2.row, ref1.col - ref2.col);
}
ToString
方法返回引用作为字符串。我们增加行数,意味着行零对应于 1。列被转换为字符,意味着列零对应于 a。如果行数或列数小于零,则返回 ?
:
String Reference::ToString() const {
String result;
if (row >= 0) {
result.push_back((TCHAR) (col + TEXT('a')));
}
else {
result.push_back(TEXT('?'));
}
if (col >= 0) {
result.append(to_String(row + 1));
}
else {
result.push_back(TEXT('?'));
}
return result;
}
bool Reference::Inside(Reference minRef, Reference maxRef) {
return ((minRef.row <= row) && (row <= maxRef.row) &&
(minRef.col <= col) && (col <= maxRef.col));
}
bool Reference::WriteReferenceToStream(ostream& outStream)const {
outStream.write((char*) &row, sizeof row);
outStream.write((char*) &col, sizeof col);
return ((bool) outStream);
}
bool Reference::ReadReferenceFromStream(istream& inStream) {
inStream.read((char*) &row, sizeof row);
inStream.read((char*) &col, sizeof col);
return ((bool) inStream);
}
void Reference::WriteReferenceToClipboard(InfoList& infoList) const {
infoList.AddValue<int>(row);
infoList.AddValue<int>(col);
}
void Reference::ReadReferenceFromClipboard(InfoList& infoList) {
infoList.GetValue<int>(row);
infoList.GetValue<int>(col);
}
当与注册表通信时,我们使用 WriteBuffer
和 ReadBuffer
静态方法。为了使其工作,我们将行和列值放入 ReferenceStruct
结构体中:
struct ReferenceStruct {int row, col;};
void Reference::WriteReferenceToRegistry(String key) const {
ReferenceStruct writeStruct = {row, col};
Registry::WriteBuffer(key, &writeStruct, sizeof writeStruct);
}
void Reference::ReadReferenceFromRegistry(String key,
Reference defaultRef /* = ZeroReference */){
ReferenceStruct readStruct;
ReferenceStruct defaultStruct =
{defaultRef.row, defaultRef.col};
Registry::ReadBuffer(key, &readStruct, sizeof readStruct,
&defaultStruct);
row = readStruct.row;
col = readStruct.col;
}
}
Matrix
类
Matrix
类包含一组按行和列组织的单元格。
Matrix.h
namespace SmallWindows {
template <int Rows, int Cols, class Type>
class Matrix {
public:
矩阵可以通过 new
关键字初始化或赋值给另一个矩阵;在这两种情况下,它们都调用 Init
来执行实际的初始化:
public:
Matrix();
Matrix(const Matrix& matrix);
Matrix& operator=(const Matrix& matrix);
private:
void Init(const Matrix<Rows,Cols,Type>& matrix);
索引运算符接受一个行或 Reference
对象。在行的情况下,返回一个列数组(技术上,返回其第一个值的地址),可以通过常规索引运算符进一步索引以获取缓冲区中的值。在引用的情况下,通过索引缓冲区的行和列直接访问值。请注意,在这个类中,垂直行坐标持有第一个索引,水平列坐标持有第二个索引:
public:
const Type* operator[](int row) const
{return ((const Type*) buffer[row]);}
Type& operator[](const Reference& ref)
{return buffer[ref.Row()][ref.Col()];}
Type operator[](const Reference& ref) const
{return buffer[ref.Row()][ref.Col()];}
private:
Type buffer[Rows][Cols];
};
由于 Matrix
是一个模板类,我们将其方法的定义放在 header
文件中。默认构造函数允许默认单元格构造函数初始化单元格:
template <int Rows, int Cols, class Type>
Matrix<Rows,Cols,Type>::Matrix() {
// Empty.
}
复制构造函数和赋值运算符通过调用 Init
来复制单元格:
template <int Rows, int Cols, class Type>
Matrix<Rows,Cols,Type>::Matrix(const Matrix<Rows,Cols,Type>&
matrix) {
Init(matrix);
}
template<int Rows, int Cols, class Type>
Matrix<Rows,Cols,Type>& Matrix<Rows,Cols,Type>::operator=
(const Matrix<Rows,Cols,Type>& matrix) {
if (this != &matrix) {
Init(matrix);
}
return *this;
}
template <int Rows, int Cols, class Type>
void Matrix<Rows,Cols,Type>::Init
(const Matrix<Rows,Cols,Type>& matrix) {
for (int row = 0; row < Rows; ++row) {
for (int col = 0; col < Cols; ++col) {
buffer[row][col] = matrix.buffer[row][col];
}
}
}
}
单元格
单元格可以保持三种模式:(可能的空)文本、数值或公式。其模式存储在 cellMode
字段中。它可以保持 TextMode
、ValueMode
或 FormulaMode
的值。类似于本章中的 CalcDocument
和前几章中的 WordDocument
,我们在 文本模式、数值模式 和 公式模式 等表达式中引用 cellMode
的当前值。
HeaderWidth
、HeaderHeight
、ColWidth
和 RowHeight
是电子表格标题和单元格的大小。为了防止单元格文本覆盖单元格的边框,使用 CellMargin
。电子表格由十行和四列组成。
Cell.h
extern const int HeaderWidth, HeaderHeight,
ColWidth, RowHeight, CellMargin;
#define Rows 10
#define Cols 4
单元格可以在水平方向上左对齐、居中对齐、右对齐或两端对齐,并且在垂直方向上可以顶部对齐、居中对齐或底部对齐:
enum Alignment {Left, Center, Right, Justified, Top, Bottom};
class Cell {
public:
Cell();
~Cell();
Cell(const Cell& cell);
Cell& operator=(const Cell& cell);
当用户选择新菜单项时,会调用 Clear
方法,并在调用 Reset
之前清除单元格的字体和背景颜色,Reset
清除文本并将单元格设置为文本模式。Reset
也会在用户删除单元格时被调用,在这种情况下,文本会被清除,但字体或颜色不会被清除:
void Clear();
void Reset();
当用户输入一个字符,该字符插入到当前字符之前或根据 keyboardMode
参数的值覆盖它时,会调用 CharDown
方法。当用户在单元格中的文本上双击时,MouseToIndex
计算被点击字符的索引:
void CharDown(int editIndex, TCHAR tChar,
KeyboardMode keyboardMode);
int MouseToIndex(int x) const;
Text
和 CaretList
方法返回单元格的文本和光标矩形列表。
vector<Rect> CaretList() const {return caretList;}
String GetText() const {return text;}
void SetText(String& t) {text = t;}
bool IsFormula() const {return (cellMode == FormulaMode);}
单元格的字体和背景颜色都可以修改和检查,同样也可以修改水平和垂直对齐方式:
Font CellFont() const {return font;}
Font& CellFont() {return font;}
Color BackgroundColor() const {return backgroundColor;}
Color& BackgroundColor() {return backgroundColor;}
Alignment HorizontalAlignment() const
{return horizontalAlignignment;}
Alignment& HorizontalAlignment()
{return horizontalAlignignment;}
Alignment VerticalAlignment() const
{return verticalAlignignment;}
Alignment& VerticalAlignment() {return verticalAlignignment;}
DrawCell
方法用黑色绘制单元格的边框,用背景色填充单元格,并绘制文本。如果反转参数为 true
,则所有颜色都会反转,这发生在单元格正在编辑或被标记的情况下:
void DrawCell(Graphics& graphics, Reference cellRef,
bool inverse) const;
void GenerateCaretList(Window* windowPtr);
当用户开始编辑单元格时,会调用 DisplayFormula
方法。带有公式的单元格可以显示其值或其公式。当用户编辑单元格时,显示公式。当用户标记它时,显示其值。DisplayFormula
方法将值替换为公式(或错误信息,如果公式不正确):
void DisplayFormula ();
InterpretCell
方法解释单元格的文本,该文本被解释为文本、数值或公式。如果公式包含语法错误,则抛出异常:
void InterpretCell(set<Reference>& sourceSet);
在 formula
模式下,GenerateSourceSet
分析公式并返回所有引用的(可能为空)集合。在 text
或 value
模式下,返回一个空集合:
void GenerateSourceSet(set<Reference>& sourceSet) const;
void GenerateSourceSet(Tree<TreeNode>* syntaxNodePtr,
set<Reference>& sourceSet) const;
在 formula
模式下,TreeToString
返回从语法树转换成字符串的公式,该字符串在编辑单元格时显示在单元格中:
String TreeToString() const;
String TreeToString(Tree<TreeNode>* syntaxNodePtr) const;
当用户剪切、复制和粘贴单元格时,它们的引用会更新。UpdateTree
更新公式模式下的所有引用:
void UpdateTree(Reference diffRef, set<Reference>& sourceSet);
void UpdateTree(Tree<TreeNode>* syntaxNodePtr,
Reference diffRef, set<Reference>& sourceSet);
HasValue
方法返回 true
如果单元格包含一个值:在 value
模式下为 true
,在 text
模式下为 false
,在 formula
模式下如果已评估为值则为 true
,如果发生评估错误(缺少值、引用超出范围、循环引用或除以零)则为 false
:
bool HasValue() const;
double GetValue() const {return value;}
Evaluate
方法评估公式的语法树;valueMap
保存源集合中单元格的值:
void Evaluate(map<Reference,double>& valueMap);
double Evaluate(Tree<TreeNode>* syntaxNodePtr,
map<Reference,double>& valueMap);
单元格可以保存到文件或剪切、复制和粘贴:
bool WriteCellToStream(ostream& outStream) const;
bool ReadCellFromStream(istream& inStream);
void WriteCellToClipboard(InfoList& infoList) const;
void ReadCellFromClipboard(InfoList& infoList);
如本节开头所述,单元格可以保存(可能为空)文本、数值或公式,由cellMode
的值指示:
private:
enum CellMode {TextMode, ValueMode, FormulaMode} cellMode;
单元格中的所有字符都持有相同的字体和背景颜色。单元格可以水平对齐在左、中、右或两端对齐,并且可以垂直对齐在顶部、居中或底部:
Font font;
Color backgroundColor;
Alignment horizontalAlignignment, verticalAlignignment;
text
字段保存单元格中显示的文本。在edit
模式下,它是用户当前输入的文本。在mark
模式下,它是用户输入的文本(在文本模式下),用户输入的数值转换为文本,公式的计算值或错误消息(缺失值、引用超出范围、循环引用或除以零):
String text;
光标列表保存了text
中每个字符的光标矩形。它还保存了最后一个字符之后的索引的矩形,这意味着光标列表的大小总是比文本多一个:
vector<Rect> caretList;
当计算公式的值时,它可能得到一个值或我们之前讨论过的任何错误。如果单元格包含一个值,则hasValue
为true
,value
包含实际值:
bool hasValue;
double value;
当用户以=*
开头输入公式时,它被Scanner
和Parser
类解释为语法树,并存储在syntaxTreePtr
中:
Tree<TreeNode>* syntaxTreePtr;
};
Cell.cpp
#include "..\\SmallWindows\\SmallWindows.h"
#include "Token.h"
#include "Error.h"
#include "Scanner.h"
#include "TreeNode.h"
#include "Parser.h"
#include "Cell.h"
const int CellMargin = 100,
ColWidth = 4000, RowHeight = 1000,
HeaderWidth = 1000, HeaderHeight = 700;
单元格的宽度是列宽减去边距,其高度是行高减去边距:
const int CellWidth = ColWidth - (2 * CellMargin),
CellHeight = RowHeight - (2 * CellMargin);
当创建单元格时,它是空的,它持有文本模式,它在水平和垂直方向上居中对齐,并且它持有系统字体,文本为黑色,背景为白色:
Cell::Cell()
:cellMode(TextMode),
font(SystemFont),
backgroundColor(White),
horizontalAlignignment(Center),
verticalAlignignment(Center),
hasValue(false),
value(0),
syntaxTreePtr(nullptr) {
// Empty.
}
复制构造函数和赋值运算符检查syntaxTreePtr
是否为null
,如果不是null
,则动态复制,其构造函数继续递归地复制其子节点。仅仅复制指针是不够的,因为原始单元格或复制单元格的公式中可能有一个被更改,而另一个没有:
Cell::Cell(const Cell& cell)
:cellMode(cell.cellMode),
font(cell.font),
backgroundColor(cell.backgroundColor),
horizontalAlignignment(cell.horizontalAlignignment),
verticalAlignignment(cell.verticalAlignignment),
text(cell.text),
caretList(cell.caretList),
hasValue(cell.hasValue),
value(cell.value) {
if (cell.syntaxTreePtr != nullptr) {
syntaxTreePtr = new Tree<TreeNode>(*cell.syntaxTreePtr);
assert(syntaxTreePtr != nullptr);
}
else {
syntaxTreePtr = nullptr;
}
}
复制构造函数和赋值运算符之间的一个区别是,在赋值运算符中我们删除了语法树指针,因为它可能指向动态分配的内存,而在复制构造函数中不是这样。如果它指向null
,则delete
运算符不执行任何操作:
Cell& Cell::operator=(const Cell& cell) {
if (this != &cell) {
cellMode = cell.cellMode;
font = cell.font;
backgroundColor = cell.backgroundColor;
horizontalAlignignment = cell.horizontalAlignignment;
verticalAlignignment = cell.verticalAlignignment;
text = cell.text;
caretList = cell.caretList;
hasValue = cell.hasValue;
value = cell.value;
delete syntaxTreePtr;
if (cell.syntaxTreePtr != nullptr) {
syntaxTreePtr = new Tree<TreeNode>(*cell.syntaxTreePtr);
assert(syntaxTreePtr != nullptr);
}
else {
syntaxTreePtr = nullptr;
}
}
return *this;
}
语法树是单元格中唯一的动态分配的内存。再次强调,如果指针为null
,则delete
不执行任何操作:
Cell::~Cell() {
delete syntaxTreePtr;
}
Clear
和Reset
之间的区别是:
-
当用户选择新建菜单项时,会调用
Clear
,此时电子表格应完全清除,并且单元格的字体、颜色和对齐方式也应重置。 -
当用户删除单元格及其模式时,会调用
Reset
,此时其模式和文本应重置。
void Cell::Clear() {
font = SystemFont;
backgroundColor = White;
horizontalAlignignment = Center;
verticalAlignignment = Center;
Reset();
}
void Cell::Reset() {
cellMode = TextMode;
text.clear();
delete syntaxTreePtr;
syntaxTreePtr = nullptr;
}
字符输入
CharDown
方法由WindowProc
(它反过来由 Windows 系统调用)在用户按下图形字符时调用。如果输入索引位于文本的末尾(文本右侧一步),我们只需添加末尾的字符。如果不是文本的末尾,我们必须考虑键盘模式,它可以是插入或覆盖。
在插入的情况下,我们插入字符,在覆盖的情况下,我们覆盖位于编辑索引处的先前字符。与前几章中的文字处理器不同,我们不需要处理字体,因为单元格中的所有字符都有相同的字体:
void Cell::CharDown(int editIndex, TCHAR tChar,
KeyboardMode keyboardMode) {
if (editIndex == text.length()) {
text.append(1, tChar);
}
else {
switch (keyboardMode) {
case InsertKeyboard:
text.insert(editIndex, 1, tChar);
break;
case OverwriteKeyboard:
text[editIndex] = tChar;
break;
}
}
}
当用户双击单元格时,会调用MouseToIndex
方法。首先,我们需要从鼠标位置减去单元格边距,然后遍历光标列表并返回鼠标击中的字符位置。如果用户击中第一个字符的左侧(居中对齐或右对齐),则返回零索引,如果他们击中最后一个字符的右侧(左对齐或居中对齐),则返回文本的大小,这对应于最后一个字符右侧的索引:
int Cell::MouseToIndex(int x) const {
x -= CellMargin;
if (x < caretList[0].Left()) {
return 0;
}
int size = text.length();
for (int index = 0; index < size; ++index) {
if (x < caretList[index].Right()) {
return index;
}
}
return size;
}
绘制
当需要绘制单元格内容时,会调用Draw
方法。文本的绘制相当直接——对于字符列表中的每个字符,我们只需在其光标矩形中绘制该字符。这个特定的单元格可能被标记或正在被编辑,在这种情况下,情况正好相反。在这种情况下,文本、背景和边框颜色被反转。为了不覆盖单元格的边框,我们还要考虑单元格边距:
void Cell::DrawCell(Graphics& graphics, Reference cellRef,
bool inverse) const {
Point topLeft(HeaderWidth + cellRef.Col() * ColWidth,
HeaderHeight + cellRef.Row() * RowHeight);
Size cellSize(ColWidth, RowHeight);
Rect cellRect(topLeft, cellSize);
Color textColor = font.FontColor(),
backColor = backgroundColor, borderColor = Black;
if (inverse) {
textColor = textColor.Inverse();
backColor = backColor.Inverse();
borderColor = borderColor.Inverse();
}
graphics.FillRectangle(cellRect, borderColor, backColor);
Size marginSize(CellMargin, CellMargin);
int size = text.length();
for (int index = 0; index < size; ++index) {
TCHAR tChar = text[index];
Rect caretRect = caretList[index];
Rect charRect = (topLeft + marginSize) + caretRect;
TCHAR text[] = {tChar, TEXT('\0')};
graphics.DrawText(charRect, text, font, textColor, backColor);
}
}
光标矩形列表生成
当用户向单元格的文本中添加或删除字符、更改其字体或对齐方式时,需要重新计算光标矩形。GenerateCaretList
可以被认为是前几章中文字处理器的GenerateParagraph
的简化版本。其任务是计算字符矩形,这些矩形用于设置光标、绘制文本和计算鼠标点击的索引。
首先,我们需要计算每个字符的宽度以及文本的宽度,以便设置其水平起始位置。在两端对齐的情况下,我们计算不带空格的文本宽度并计算空格的数量:
void Cell::GenerateCaretList(Window* windowPtr) {
vector<int> widthList;
int textWidth = 0, spaceCount = 0, noSpaceWidth = 0;
for (const TCHAR tChar : text) {
int charWidth = windowPtr->GetCharacterWidth(font, tChar);
widthList.push_back(charWidth);
textWidth += charWidth;
if (horizontalAlignignment == Justified) {
if (tChar == TEXT(' ')) {
++spaceCount;
}
else {
noSpaceWidth += charWidth;
}
}
}
当我们计算出文本宽度时,我们设置水平起始位置。在左对齐或两端对齐的情况下,起始位置设置为单元格边距。在两端对齐的情况下,我们还设置文本中每个空格的宽度。在右对齐的情况下,我们将单元格宽度与文本宽度的差值加到单元格边距上,以便将文本的最右侧部分放置在单元格的右边界上。在居中对齐的情况下,我们添加一半的差值,以便将文本放置在单元格的中间:
int startPos = 0, spaceWidth, cellWidth = ColWidth - (2 * CellMargin);
switch (horizontalAlignignment) {
case Left:
startPos = CellMargin;
break;
case Justified: {
startPos = CellMargin;
if (spaceCount > 0) {
spaceWidth = max(0,(cellWidth-noSpaceWidth)/spaceCount);
}
}
break;
case Right:
startPos = CellMargin + max(0, cellWidth - textWidth);
break;
case Center:
startPos = CellMargin + max(0, (cellWidth - textWidth) / 2);
break;
}
垂直顶部位置以类似的方式设置。在顶部对齐的情况下,顶部位置设置为单元格边距。在底部对齐的情况下,我们将单元格高度与文本高度的差值加到单元格边距上,以便将文本的底部部分放置在单元格的底部边界。在居中对齐的情况下,我们添加一半的差值,以便将文本放置在单元格的中间:
int topPos = 0,
textHeight = windowPtr->GetCharacterHeight(font),
cellHeight = RowHeight - (2 * CellMargin);
switch (verticalAlignignment) {
case Top:
topPos = CellMargin;
break;
case Bottom:
topPos = CellMargin + max(0, cellHeight - textHeight);
break;
case Center:
topPos = CellMargin + max(0, (cellHeight - textHeight) / 2);
break;
}
当水平起始位置和顶部垂直位置已设置后,我们遍历字符,并将每个字符的矩形添加到caretList
中。请注意,在两端对齐的情况下,我们使用spaceWidth
的值来处理空格:
caretList.clear();
int size = text.size();
for (int index = 0; index < size; ++index) {
int charWidth = widthList[index];
if ((horizontalAlignignment == Justified) &&
(text[index] == TEXT(' '))) {
charWidth = spaceWidth;
}
Point topLeft(startPos, topPos);
Size charSize(charWidth, textHeight);
caretList.push_back(Rect(topLeft, charSize));
startPos += charWidth;
}
当每个矩形被添加时,我们将文本右侧字符的矩形添加到其中。我们将其宽度设置为单元格字体平均字符的宽度:
Point topLeft(startPos, topPos);
int averageWidth = windowPtr->GetCharacterAverageWidth(font);
Size charSize(averageWidth, textHeight);
caretList.push_back(Rect(topLeft, charSize));
}
公式解释
当用户单击或双击单元格时,其文本在文本或值模式下保持不变,但在公式模式下会发生变化。在公式模式下,公式的计算值以标记模式显示,而在编辑模式下,显示公式本身。DisplayFormula
在公式模式下调用TreeToString
,生成公式的文本:
void Cell::DisplayFormula() {
switch (cellMode) {
case TextMode:
case ValueMode:
break;
case FormulaMode:
text = TEXT("=") + TreeToString(syntaxTreePtr);
break;
}
}
当用户通过按Enter或Tab键或单击鼠标来终止文本输入时,会调用InterpretCell
方法。如果用户输入了一个公式(以=*
开头),则对其进行解析。Parse
返回包含公式的语法树或抛出语法错误时的异常。请注意,InterpretCell
仅报告语法错误。所有其他错误(缺失值、引用超出范围、循环引用或除以零)都由下面的Evaluate
处理:
void Cell::InterpretCell(set<Reference>& sourceSet) {
String trimText = Trim(text);
if (IsNumeric(trimText)) {
cellMode = ValueMode;
value = stod(trimText);
}
else if (!trimText.empty() && (trimText[0] == TEXT('='))) {
cellMode = FormulaMode;
Parser parser(trimText.substr(1));
syntaxTreePtr = parser.Parse();
GenerateSourceSet(syntaxTreePtr, sourceSet);
}
else {
cellMode = TextMode;
}
}
GenerateSourceSet
方法遍历语法树,并在公式模式下提取所有引用的(可能为空)集合。在文本或值模式下,集合为空,因为只有公式包含引用:
void Cell::GenerateSourceSet(set<Reference>& sourceSet) const{
if (cellMode == FormulaMode) {
GenerateSourceSet(syntaxTreePtr, sourceSet);
}
}
在一元加法或减法或括号内的表达式中,返回其子节点的源集:
void Cell::GenerateSourceSet(Tree<TreeNode>* syntaxNodePtr,
set<Reference>& sourceSet) const{
DynamicList<Tree<TreeNode>*> childList =
syntaxNodePtr->ChildList();
switch (syntaxNodePtr->NodeValue().Id()) {
case UnaryAdd:
case UnarySubtract:
case Parenthesis:
return GenerateSourceSet(childList[0]);
在二元表达式中,返回两个子集的源集的并集:
case BinaryAdd:
case BinarySubtract:
case Multiply:
case Divide: {
set<Reference> leftSet = GenerateSourceSet(childList[0]),
rightSet = GenerateSourceSet(childList[1]);
leftSet.insert(rightSet.begin(), rightSet.end());
return leftSet;
}
在引用的情况下,如果它位于电子表格中,则返回仅包含引用的集合。集合中不包含电子表格外的任何引用:
case RefId: {
set<Reference> singleSet;
Reference sourceRef =
syntaxNodePtr->NodeValue().ReferenceField();
if ((sourceRef.Row() >= 0) && (sourceRef.Row() < Rows) &&
(sourceRef.Col() >= 0) && (sourceRef.Col() < Cols)) {
singleSet.insert(sourceRef);
}
return singleSet;
}
最后,在值的情况下,返回一个空集:
case ValueId:
return set<Reference>();
}
assert(false);
return set<Reference>();
}
TreeToString
方法遍历语法树并将其转换为字符串。请注意,可能存在具有超出作用域引用的公式。然而,在这种情况下,Reference
类返回?
:
String Cell::TreeToString() const {
if (cellMode == FormulaMode) {
return TEXT("=") + TreeToString(syntaxTreePtr);
}
else {
return text;
}
}
在一元加法或减法的情况下,将+
或-
添加到子节点文本中:
String Cell::TreeToString(Tree<TreeNode>* syntaxNodePtr) const {
DynamicList<Tree<TreeNode>*> childList =
syntaxNodePtr->ChildList();
switch (syntaxNodePtr->NodeValue().Id()) {
case UnaryAdd:
return TEXT("+") + TreeToString(childList[0]);
case UnarySubtract:
return TEXT("-") + TreeToString(childList[0]);
break;
在二元表达式+
、-
、*
或/
之间插入子节点文本:
case BinaryAdd:
return TreeToString(childList[0]) + TEXT("+") +
TreeToString(childList[1]);
case BinarySubtract:
return TreeToString(childList[0]) + TEXT("-") +
TreeToString(childList[1]);
case Multiply:
return TreeToString(childList[0]) + TEXT("*") +
TreeToString(childList[1]);
case Divide:
return TreeToString(childList[0]) + TEXT("/") +
TreeToString(childList[1]);
在括号内的表达式的情况下,返回括号内子节点的文本:
case Parenthesis:
return TEXT("(") + TreeToString(childList[0]) + TEXT(")");
在引用的情况下,返回其文本。再次强调,如果引用超出范围,?
会被返回:
case RefId:
return syntaxNodePtr->
NodeValue().ReferenceField().ToString();
在值的情况下,返回其转换后的文本:
case ValueId:
return to_String(syntaxNodePtr->NodeValue().Value());
}
assert(false);
return TEXT("");
}
当用户复制粘贴一组单元格时,每个公式的引用是相对的,并且会更新。UpdateTree
会在语法树中查找并更新引用。在所有其他情况下,它会遍历子列表,并对每个子项递归调用UpdateTree
(一元表达式和括号表达式各有一个子项,二元表达式有两个子项,值或引用没有子项):
void Cell::UpdateTree(Reference diffRef,set<Reference>&sourceSet) {
if (cellMode == FormulaMode) {
UpdateTree(syntaxTreePtr, diffRef, sourceSet);
}
}
void Cell::UpdateTree(Tree<TreeNode>* syntaxNodePtr,
Reference diffRef, set<Reference>& sourceSet) {
if (syntaxNodePtr->NodeValue().Id() == RefId) {
syntaxNodePtr->NodeValue().ReferenceField() += diffRef;
sourceSet.insert(syntaxNodePtr->NodeValue().ReferenceField());
}
else {
for (Tree<TreeNode>* childNodePtr :
syntaxNodePtr->ChildList()) {
UpdateTree(childNodePtr, diffRef, sourceSet);
}
}
}
当公式的值被评估时,它可能返回一个有效值,在这种情况下,hasValue
被设置为true
。然而,如果在评估过程中发生错误(值缺失、引用超出范围、循环引用或除以零),hasValue
被设置为false
。当评估另一个单元格的公式值时,会调用hasValue
。如果它返回false
,评估将导致缺失值错误:
bool Cell::HasValue() const{
switch (cellMode) {
case TextMode:
return false;
case ValueMode:
return true;
case FormulaMode:
return hasValue;
}
assert(false);
return false;
}
在公式模式下,公式正在被评估为值。如果发生错误(值缺失、引用超出范围、循环引用或除以零),Evaluate
会抛出异常,并将单元格文本设置为错误消息文本。请注意,可以输入超出范围的引用,InterpretCell
可以接受这些引用。然而,Evaluate
会抛出一个包含错误消息的异常,该错误消息会在单元格中显示。
此外,完全有可能剪切、复制和粘贴一个单元格,使其引用超出范围,然后再次剪切、复制和粘贴,使引用变得有效。然而,如果用户编辑超出范围的引用的公式,Reference
类的ToString
方法会返回?
,因为很难用负列表示引用:
void Cell::Evaluate(map<Reference,double>& valueMap) {
if (cellMode == FormulaMode) {
try {
value = Evaluate(syntaxTreePtr, valueMap);
text = to_String(value);
hasValue = true;
}
catch (Error error) {
text = error.ErrorText();
hasValue = false;
}
}
}
Evaluate
方法通过查找公式引用的单元格的值来找到单元格的当前值:
double Cell::Evaluate(Tree<TreeNode>* syntaxNodePtr,
map<Reference,double>& valueMap) {
DynamicList<Tree<TreeNode>*> childList =
syntaxNodePtr->ChildList();
在一元或二元表达式的情况下,值会被计算(一元加法只是为了完整性,不会改变值):
switch (syntaxNodePtr->NodeValue().Id()) {
case UnaryAdd:
return Evaluate(childList[0], valueMap);
case UnarySubtract:
return -Evaluate(childList[0], valueMap);
case BinaryAdd:
return Evaluate(childList[0], valueMap) +
Evaluate(childList[1], valueMap);
case BinarySubtract:
return Evaluate(childList[0], valueMap) -
Evaluate(childList[1], valueMap);
case Multiply:
return Evaluate(childList[0], valueMap) *
Evaluate(childList[1], valueMap);
在除以零的情况下,会抛出异常。
case Divide: {
double remainder = Evaluate(childList[1], valueMap);
if (remainder != 0) {
return Evaluate(childList[0], valueMap) / remainder;
}
else {
throw Error(DivisionByZero);
}
}
break;
在括号内的表达式的情况下,我们只需返回其评估值:
case Parenthesis:
return Evaluate(childList[0], valueMap);
在引用的情况下,我们在valueMap
中查找源单元格。在源单元格缺失值(不在valueMap
中)或引用超出范围(引用工作表外的单元格)的情况下,会抛出异常:
case RefId: {
Reference sourceRef =
syntaxNodePtr->NodeValue().ReferenceField();
if ((sourceRef.Row() >= 0) && (sourceRef.Row() < Rows) &&
(sourceRef.Col() >= 0) && (sourceRef.Col() < Cols)) {
if (valueMap.find(sourceRef) != valueMap.end()) {
return valueMap[sourceRef];
}
else {
throw Error(MissingValue);
}
}
else {
throw Error(ReferenceOutOfRange);
}
}
break;
在值的情况下,我们直接返回该值:
case ValueId:
return syntaxNodePtr->NodeValue().Value();
}
assert(false);
return 0;
}
文件管理
每次用户从文件菜单中选择保存或另存为菜单项时,CalcDocument
都会调用WriteDocumentToStream
方法。在公式模式下,我们在语法树上调用WriteTreeToStream
:
bool Cell::WriteCellToStream(ostream& outStream) const {
outStream.write((char*) &cellMode, sizeof cellMode);
outStream.write((char*) &horizontalAlignignment,
sizeof horizontalAlignignment);
outStream.write((char*) &verticalAlignignment,
sizeof verticalAlignignment);
outStream.write((char*) &hasValue, sizeof hasValue);
outStream.write((char*) &value, sizeof value);
backgroundColor.WriteColorToStream(outStream);
font.WriteFontToStream(outStream);
int charListSize = text.size();
outStream.write((char*) &charListSize, sizeof charListSize);
for (const TCHAR tChar : text) {
outStream.write((char*) &tChar, sizeof tChar);
}
for (const Rect caretRect : caretList) {
caretRect.WriteRectToStream(outStream);
}
if (cellMode == FormulaMode) {
syntaxTreePtr->WriteTreeToStream(outStream);
}
return ((bool) outStream);
}
在ReadCellFromStream
中,我们动态地在公式模式下创建和读取语法树:
bool Cell::ReadCellFromStream(istream& inStream) {
inStream.read((char*) &cellMode, sizeof cellMode);
inStream.read((char*) &horizontalAlignignment,
sizeof horizontalAlignignment);
inStream.read((char*) &verticalAlignignment,
sizeof verticalAlignignment);
inStream.read((char*) &hasValue, sizeof hasValue);
inStream.read((char*) &value, sizeof value);
backgroundColor.ReadColorFromStream(inStream);
font.ReadFontFromStream(inStream);
int charListSize;
inStream.read((char*) &charListSize, sizeof charListSize);
for (int count = 0; count < charListSize; ++count) {
TCHAR tChar;
inStream.read((char*) &tChar, sizeof tChar);
text.append(1, tChar);
}
for (int count = 0; count < (charListSize + 1); ++count) {
Rect caretRect;
caretRect.ReadRectFromStream(inStream);
caretList.push_back(caretRect);
}
if (cellMode == FormulaMode) {
syntaxTreePtr = new Tree<TreeNode>();
assert(syntaxTreePtr != nullptr);
syntaxTreePtr->ReadTreeFromStream(inStream);
}
else {
syntaxTreePtr = nullptr;
}
return ((bool) inStream);
}
当用户剪切、复制和粘贴单元格时,CalcDocument
会调用WriteCellToClipboard
和ReadCellFromClipboard
方法。它的工作方式与之前我们看到的WriteDocumentToStream
和ReadCellFromStream
相同:
void Cell::WriteCellToClipboard(InfoList& infoList) const {
infoList.AddValue<CellMode>(cellMode);
infoList.AddValue<Alignment>(horizontalAlignignment);
infoList.AddValue<Alignment>(verticalAlignignment);
infoList.AddValue<double>(value);
infoList.AddValue<bool>(hasValue);
font.WriteFontToClipboard(infoList);
backgroundColor.WriteColorToClipboard(infoList);
infoList.AddValue<int>(text.size());
for (const TCHAR tChar : text) {
infoList.AddValue<TCHAR>(tChar);
}
if (cellMode == FormulaMode) {
syntaxTreePtr->WriteTreeToClipboard(infoList);
}
}
void Cell::ReadCellFromClipboard(InfoList& infoList) {
infoList.GetValue<CellMode>(cellMode);
infoList.GetValue<Alignment>(horizontalAlignignment);
infoList.GetValue<Alignment>(verticalAlignignment);
infoList.GetValue<double>(value);
infoList.GetValue<bool>(hasValue);
font.ReadFontFromClipboard(infoList);
backgroundColor.ReadColorFromClipboard(infoList);
int listSize;
infoList.GetValue<int>(listSize);
for (int count = 0; count < listSize; ++count) {
TCHAR tChar;
infoList.GetValue<TCHAR>(tChar);
text.push_back(tChar);
}
for (int count = 0; count < (listSize + 1); ++count) {
Rect caretRect;
caretRect.ReadRectFromClipboard(infoList);
caretList.push_back(caretRect);
}
if (cellMode == FormulaMode) {
syntaxTreePtr = new Tree<TreeNode>();
assert(syntaxTreePtr != nullptr);
syntaxTreePtr->ReadTreeFromClipboard(infoList);
}
else {
syntaxTreePtr = nullptr;
}
}
进一步阅读
如果本章的扫描器和解析器让你对编译器产生了兴趣,我建议你参考 A. V. Aho 等人所著的《编译原理、技术和工具》(第二版,Addison Wesley,2007)。这是经典之作《龙书》的第二版。作者从扫描和解析到高级优化,解释了编译器的理论和实践。
如果图的概念引起了你的兴趣,我推荐 D. B. West 所著的《图论导论》(Prentice Hall,2000),它从数学的角度对图进行推理。
摘要
在本章中,我们介绍了电子表格程序的实施。本章结束了本书的第一部分:如何使用小窗口开发应用程序。第十章,《框架》,介绍了第二部分:小窗口的实现。
第十章。框架
本书剩余章节解释了 Small Windows 实现的细节。本章涵盖以下主题:
-
Small Windows 类的概述
-
我们在本书开头介绍的 Hello World 应用程序的示例,使用 Win32 API 编写
-
MainWindow
和WinMain
函数 -
Small Windows 主要类的实现:
Application
、Window
和Graphics
Small Windows 的概述
这里是 Small Windows 类的简要描述:
章节 | 类 | 描述 |
---|---|---|
10 | Application |
这是 Small Windows 的main 类。它管理消息循环和 Windows 类的注册。 |
10 | Window |
这是根Window 类。它创建单个窗口并提供基本的窗口功能,如鼠标、触摸和键盘输入、绘图、缩放、计时器、焦点、大小和坐标系。 |
10 | Graphics |
这是用于在窗口客户端区域绘制线条、矩形、椭圆和文本的类。 |
11 | Document 扩展 Window |
这扩展了窗口以包含文档功能,如滚动、光标处理和拖放文件。 |
11 | Menu |
这处理菜单栏、菜单、菜单项和菜单分隔符。 |
11 | Accelerator |
这从菜单项文本中提取加速器信息。 |
11 | StandardDocument 扩展 Document |
这提供了一个基于文档的框架,包含常见的文件、编辑和帮助菜单项。 |
12 | Size Point Rect |
这些是处理二维点(x 和 y)、大小(宽度和高度)或矩形四个角的辅助类。 |
12 | Font |
这封装了LOGFONT 结构,该结构包含有关字体名称、大小以及是否为粗体或斜体的信息。 |
12 | Cursor |
这设置光标并提供一组标准光标。 |
12 | DynamicList 模板 |
这是一个动态大小的列表和一组回调方法。 |
12 | Tree 模板 |
这是一个树结构,其中每个节点都有一个(可能为空)子节点列表。 |
12 | InfoList |
这是一个通用信息列表,可以转换成和从内存缓冲区。 |
13 | Registry |
这提供了一个与 Windows 注册表的接口。 |
13 | Clipboard |
这提供了一个与 Windows 剪贴板的接口。 |
13 | StandardDialog |
这显示保存和打开文件、选择字体或颜色以及打印的标准对话框。 |
13 | PreviewDocument 扩展 Document |
这设置了一个逻辑大小固定(无论其物理大小如何)的文档。 |
14 | Dialog 扩展 Window |
这提供了一个模态对话框。下面的控件被添加到对话框中。 |
14 | Control 抽象 |
这是对话框控件的基础类。 |
14 | ButtonControl 扩展 Control |
这是按钮控件的基础类。 |
14 | GroupBox 、PushButton 、CheckBox 、RadioButton 扩展 ButtonControl |
这些是用于分组框、按钮、复选框和单选按钮的类。 |
14 | ListControl 扩展 Control |
这是列表控件的基础类。 |
14 | ListBox 、MultipleListBox 扩展 ListControl |
这些是用于单选和复选列表框的类。 |
14 | ComboBox 扩展 Control |
这是一个组合(下拉)框的类。 |
14 | Label 扩展 Control |
这是一个简单的标签类,通常用作 TextField 的提示。 |
14 | TextField 模板扩展 Control |
这是一个可编辑字段的类,其中转换器可以在字符串和任何类型之间进行转换。 |
14 | Converter 模板 |
这是一个可以指定为任何类型的转换器类。 |
14 | PageSetupDialog 扩展 Dialog |
这是一个用于页面设置设置的对话框,例如页边距、页眉和页脚文本。 |
14 | PageSetupInfo |
这包含页面设置信息,我们之前已经看到过。 |
"Hello" 窗口用于 Win32 API
首先,让我们看看这本书第一章中的 Hello 应用程序。以下代码片段是使用 Win32 API 直接编写的相同应用程序,没有使用 Small Windows。请注意,代码是用 C 编写的,而不是 C++,因为 Win32 API 是一个 C 函数库,而不是 C++ 类库。正如你所看到的,与第一章中的应用程序相比,代码要复杂得多。
如果看起来很复杂,请不要担心。它的目的实际上是演示 Win32 API 的复杂性;我们将在本章和下一章中讨论细节。
MainWindow.c
#include <Windows.h>
#include <Assert.h>
#include <String.h>
#include <TChar.h>
LRESULT CALLBACK WindowProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam);
当应用程序开始执行时,会调用 WinMain
方法。它对应于标准 C 中的 main
。
int WINAPI WinMain(HINSTANCE instanceHandle,
HINSTANCE prevInstanceHandle,
char* commandLine, int commandShow) {
首先,我们需要为我们的窗口注册 Windows
类。请注意,Windows
类不是 C++ 类:
WNDCLASS windowClass;
memset(&windowClass, 0, sizeof windowClass);
windowClass.hInstance = instanceHandle;
当窗口在水平和垂直方向上改变大小的时候,Windows
类的样式将被重新绘制:
windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
窗口的图标是标准应用程序图标,光标是标准箭头光标,客户端区域的背景是白色。
windowClass.hIcon = LoadIcon(NULL, IDI_APPLICATION);
windowClass.hCursor = LoadCursor(NULL, IDC_ARROW);
windowClass.hbrBackground =
(HBRUSH) GetStockObject(WHITE_BRUSH);
WindowProc
函数是一个回调函数,每次窗口收到消息时都会被调用:
windowClass.lpfnWndProc = WindowProc;
Windows
类的名称是 window
,在这里用于 CreateWindowEx
调用中:
windowClass.lpszClassName = TEXT("window");
RegisterClass(&windowClass);
CreateWindowEx
方法创建一个具有默认位置和大小的窗口。请注意,我们可以使用相同的 Windows
类创建许多窗口:
HWND windowHandle =
CreateWindowEx(0, TEXT("window"), NULL, WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT,
CW_USEDEFAULT, NULL, CreateMenu(),
instanceHandle, NULL);
assert(windowHandle != NULL);
ShowWindow(windowHandle, commandShow);
RegisterTouchWindow(windowHandle, 0);
SetWindowText(windowHandle, TEXT("Hello Window"));
GetMessage
方法等待下一个消息,该消息被翻译并发送到具有输入焦点的窗口。GetMessage
方法对所有消息返回 true
,除了退出消息,该消息在用户关闭窗口时最终发送:
MSG message;
while (GetMessage(&message, NULL, 0, 0)) {
TranslateMessage(&message);
DispatchMessage(&message);
}
return ((int) message.wParam);
}
LRESULT CALLBACK WindowProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam){
switch (message) {
case WM_PAINT: {
在绘制客户端区域时,我们需要创建一个绘图结构和设备上下文,这是通过 BeginPaint
创建的:
PAINTSTRUCT paintStruct;
HDC deviceContextHandle =
BeginPaint(windowHandle, &paintStruct);
SetMapMode(deviceContextHandle, MM_ISOTROPIC);
由于我们想使用逻辑单位(毫米的百倍),我们需要通过调用 SetWindowExtEx
和 SetViewportExtEx
来设置设备上下文:
int horizontalSize =
100 * GetDeviceCaps(deviceContextHandle, HORZSIZE),
verticalSize =
100 * GetDeviceCaps(deviceContextHandle,VERTSIZE);
SetWindowExtEx(deviceContextHandle, horizontalSize,
verticalSize, NULL);
int horizontalResolution =
(int) GetDeviceCaps(deviceContextHandle,HORZRES),
verticalResolution =
(int) GetDeviceCaps(deviceContextHandle,VERTRES);
SetViewportExtEx(deviceContextHandle,horizontalResolution,
verticalResolution, NULL);
由于我们还想考虑滚动动作,所以我们也会调用 SetWindowOrgEx
:
int horizontalScroll =
GetScrollPos(windowHandle, SB_HORZ),
verticalScroll = GetScrollPos(windowHandle, SB_VERT);
SetWindowOrgEx(deviceContextHandle, horizontalScroll,
verticalScroll, NULL);
此外,由于我们想考虑滚动动作,我们调用 SetWindowOrgEx
来设置客户端区域的逻辑原点:
RECT clientRect;
GetClientRect(windowHandle, &clientRect);
POINT bottomRight = {clientRect.right, clientRect.bottom};
DPtoLP(deviceContextHandle, &bottomRight, 1);
clientRect.right = bottomRight.x;
clientRect.top = bottomRight.y;
我们需要设置一个 LOGFONT
结构来创建 12 磅粗体的 Times New Roman
字体:
LOGFONT logFont;
memset(&logFont, 0, sizeof logFont);
_tcscpy_s(logFont.lfFaceName, LF_FACESIZE,
TEXT("Times New Roman"));
int fontSize = 12;
由于我们使用的是毫米级的逻辑单位,一个排版点等于 1 英寸除以 72,1 英寸等于 25.4 毫米。我们将字体大小乘以 2540 然后除以 72:
logFont.lfHeight = (int) ((2540.0 * fontSize) / 72);
logFont.lfWeight = FW_BOLD;
logFont.lfItalic = FALSE;
当我们在客户端区域使用字体写入文本时,我们需要间接创建字体并将其添加为图形对象。我们还需要保存先前的对象以便稍后恢复:
HFONT fontHandle = CreateFontIndirect(&logFont);
HFONT oldFontHandle =
(HFONT) SelectObject(deviceContextHandle, fontHandle);
文本颜色为黑色,背景颜色为白色。RGB
是一个宏,它将颜色的红色、绿色和蓝色部分转换为一个 COLORREF
值:
COLORREF black = RGB(0, 0, 0), white = RGB(255, 255, 255);
SetTextColor(deviceContextHandle, black);
SetBkColor(deviceContextHandle, white);
最后,DrawText
在客户端区域的中间绘制文本:
TCHAR* textPtr = TEXT("Hello, Small Windows!");
DrawText(deviceContextHandle, textPtr, _tcslen(textPtr),
&clientRect, DT_SINGLELINE|DT_CENTER|DT_VCENTER);
由于字体是系统资源,我们需要恢复先前的字体对象并删除新的字体对象。我们还需要恢复绘图结构:
SelectObject(deviceContextHandle, oldFontHandle);
DeleteObject(fontHandle);
EndPaint(windowHandle, &paintStruct);
}
由于我们已经处理了 WM_PAINT
消息,我们返回零。
break;
}
对于除 WM_PAINT
以外的所有消息,我们调用 DefWindowProc
来处理消息:
return DefWindowProc(windowHandle, message,
wordParam, longParam);
}
主窗口函数
在常规 C 和 C++ 中,应用程序的执行从 main
函数开始。然而,在小型 Windows 中,main
被替换为 MainWindow
。MainWindow
由小型 Windows 的用户为每个项目实现。其任务是定义应用程序名称并创建主窗口对象。
MainWindow.h
void MainWindow(vector<String> argumentList,
SmallWindows::WindowShow windowShow);
WinMain 函数
在 Win32 API 中,WinMain
是与 main
等效的函数。每个应用程序都必须包含 WinMain
函数的定义。为了使小型 Windows 工作,WinMain
作为小型 Windows 的一部分实现,而 MainWindow
必须由小型 Windows 的用户为每个项目实现。总结一下,这里有三种主函数:
常规 C/C++ | Win32 API | 小型 Windows |
---|---|---|
main | WinMain | MainWindow |
WinMain
函数由 Windows 系统调用,并接受以下参数:
-
instanceHandle
:这包含应用程序的句柄 -
prevInstanceHandle
:由于向后兼容性,它存在,但始终为null
-
commandLine
:这是一个以空字符终止的字符(char
,不是TCHAR
)数组,包含应用程序的参数,由空格分隔 -
commandShow
:这包含主窗口的首选外观
WinMain.cpp
#include "SmallWindows.h"
int WINAPI WinMain(HINSTANCE instanceHandle,
HINSTANCE /* prevInstanceHandle */,
char* commandLine, int commandShow) {
WinMain
函数执行以下任务:
-
它通过调用
GenerateArgumentList
将命令行中空格分隔的单词划分为一个String
列表。请参阅第十二章,辅助类,以了解CharPtrToGenericString
和Split
的定义。 -
它实例化一个
Application
对象。 -
它调用
MainWindow
函数,该函数创建应用程序的主窗口并设置其名称。 -
它调用
Application
的RunMessageLoop
方法,该方法继续处理 Windows 消息,直到收到退出消息。
Application::RegisterWindowClasses(instanceHandle);
vector<String> argumentList =
Split(CharPtrToGenericString(commandLine));
MainWindow(argumentList, (WindowShow) commandShow);
return Application::RunMessageLoop();
}
Application
类
Application
类处理应用程序的消息循环。消息循环等待从 Windows 系统接收下一个消息并将其发送到正确的窗口。Application
类还定义了 Window
、Document
、StandardDocument
和 Dialog
C++ 类的 Windows
类(它们不是 C++ 类)。由于 Application
不打算实例化,因此类的字段是静态的。
从 Small Windows 的这个点开始,Small Windows 实现的每一部分都包含在 SmallWindows
命名空间中。命名空间是 C++ 的一个特性,用于封装类和函数。我们之前看到的 MainWindow
的声明不包括在 Smallwindows
命名空间中,因为 C++ 语言规则规定它不能包含在命名空间中。WinMain
的定义也不包含在命名空间中,因为它需要放在命名空间外部才能被 Windows 系统调用。
Application.h
namespace SmallWindows {
class Application {
public:
RegisterWindowClasses
方法为 Window
、Document
、StandardDocument
和 Dialog
C++ 类定义 Windows 类。RunMessageLoop
方法运行 Windows 消息系统的消息循环。它等待下一个消息并将其发送到正确的窗口。当接收到特殊的退出消息时,它会中断消息循环,从而导致 Application
类的终止:
static void RegisterWindowClasses(HINSTANCE instanceHandle);
static int RunMessageLoop();
在 Windows 中,每个应用程序都持有应用程序实例的 句柄。句柄在 Win32 API 中很常见,用于访问 Windows 系统的对象。它们类似于指针,但提供标识而不透露任何位置信息。
在创建以下 Window
类的构造函数中以及在第十四章对话框、控件和页面设置的“标准对话框”部分显示标准对话框时,使用实例句柄(HINSTANCE
类型):
static HINSTANCE& InstanceHandle() {return instanceHandle;}
应用程序名称由每个应用程序设置,并通过标准 文件、帮助 和 关于 菜单、打开 和 保存 对话框以及注册表进行引用:
static String& ApplicationName() {return applicationName;}
当用户关闭窗口时,会引用应用程序主窗口的指针。如果是主窗口,则应用程序退出。此外,当用户选择 退出 菜单项时,在应用程序退出之前会关闭主窗口:
static Window*& MainWindowPtr() {return mainWindowPtr;}
private:
static HINSTANCE instanceHandle;
static String applicationName;
static Window* mainWindowPtr;
};
};
Application.cpp
#include "SmallWindows.h"
namespace SmallWindows {
HINSTANCE Application::instanceHandle;
String Application::applicationName;
Window* Application::mainWindowPtr;
Win32 API 的 Windows 类
Windows
类在Application
中注册。一个 Windows 类只需要注册一次。注册后,可以为每个Windows
类创建多个窗口。再次注意,窗口类不是 C++类。每个Windows
类通过其名称:lpszClassName
存储。lpfnWndProc
字段定义了接收来自消息循环的窗口消息的独立函数。每个窗口都允许双击以及水平和垂直重绘样式,这意味着每次用户更改窗口大小时,都会向窗口发送WM_PAINT
消息并调用OnPaint
方法。此外,每个窗口在其右上角都有标准的应用程序图标和标准的箭头光标。客户端区域为白色,除了对话框,其客户端区域为浅灰色:
void Application::RegisterWindowClasses(HINSTANCE
instanceHandle) {
Application::instanceHandle = instanceHandle;
assert(instanceHandle != nullptr);
WNDCLASS windowClass;
memset(&windowClass, 0, sizeof windowClass);
windowClass.hInstance = instanceHandle;
windowClass.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS;
windowClass.hIcon = LoadIcon(nullptr, IDI_APPLICATION);
windowClass.hCursor = LoadCursor(nullptr, IDC_ARROW);
windowClass.hbrBackground =
(HBRUSH) GetStockObject(WHITE_BRUSH);
windowClass.lpfnWndProc = WindowProc;
windowClass.lpszClassName = TEXT("window");
::RegisterClass(&windowClass);
windowClass.lpfnWndProc = DocumentProc;
windowClass.lpszClassName = TEXT("document");
::RegisterClass(&windowClass);
windowClass.lpfnWndProc = DocumentProc;
windowClass.lpszClassName = TEXT("standarddocument");
::RegisterClass(&windowClass);
}
消息循环
RunMessageLoop
方法保留了经典的 Windows 消息循环。有两种情况:如果主窗口指针指向Window
类的对象,我们只需使用 Win32 API 函数GetMessage
、TranslateMessage
和DispatchMessage
处理消息队列,而不关心加速器。然而,如果它指向Document
或其任何子类的对象,消息循环变得更加复杂,因为我们需要考虑加速器:
int Application::RunMessageLoop() {
assert(!applicationName.empty());
assert(mainWindowPtr != nullptr);
MSG message;
if (dynamic_cast<Document*>(mainWindowPtr) == nullptr) {
while (::GetMessage(&message, nullptr, 0, 0)) {
::TranslateMessage(&message);
::DispatchMessage(&message);
}
}
如果主窗口指针指向Document
对象或其任何子类,我们将在Document
中定义的加速器表中设置一个缓冲区,我们在消息循环中使用它。Win32 API 的TranslateAccelerator
函数查找加速器并决定是否将按键消息视为与加速器关联的菜单项:
else {
Document* documentPtr = (Document*) mainWindowPtr;
int size = documentPtr->AcceleratorSet().size(), index = 0;
TranslateAccelerator
方法需要一个 ACCEL 结构的数组,因此我们将加速器集转换为数组:
ACCEL* acceleratorTablePtr = new ACCEL[size];
assert(acceleratorTablePtr != nullptr);
for (ACCEL accelerator : documentPtr->AcceleratorSet()) {
acceleratorTablePtr[index++] = accelerator;
}
HACCEL acceleratorTable =
::CreateAcceleratorTable(acceleratorTablePtr, size);
while (::GetMessage(&message, nullptr, 0, 0)) {
if (!::TranslateAccelerator(mainWindowPtr->WindowHandle(),
acceleratorTable, &message)) {
::TranslateMessage(&message);
::DispatchMessage(&message);
}
}
当使用加速器数组时,它将被删除:
delete [] acceleratorTablePtr;
}
当消息循环完成后,我们返回最后一条消息:
return ((int) message.wParam);
}
Window
类
Window
类是文档类的根类;它处理基本窗口功能,如计时器、输入焦点、坐标变换、窗口大小和位置、文本度量以及消息框以及鼠标、键盘和触摸屏输入。此外,Window
定义了窗口样式和外观、按钮、图标和坐标系统的枚举:
Window.h
namespace SmallWindows {
extern map<HWND,Window*> WindowMap;
存在大量的窗口样式。窗口可能配备有边框、厚边框、滚动条或最小化和最大化框:
enum WindowStyle {NoStyle = 0, Border = WS_BORDER,
ThickFrame = WS_THICKFRAME,
Caption = WS_CAPTION, Child = WS_CHILD,
ClipChildren = WS_CLIPCHILDREN,
ClipSibling = WS_CLIPSIBLINGS,
Disabled = WS_DISABLED,
DialogFrame = WS_DLGFRAME, Group = WS_GROUP,
HScroll = WS_HSCROLL, Minimize = WS_MINIMIZE,
Maximize = WS_MAXIMIZE,
MaximizeBox = WS_MAXIMIZEBOX,
MinimizeBox = WS_MINIMIZEBOX,
Overlapped = WS_OVERLAPPED,
OverlappedWindow = WS_OVERLAPPEDWINDOW,
Popup = WS_POPUP,PopupWindow = WS_POPUPWINDOW,
SystemMenu = WS_SYSMENU,
Tabulatorstop = WS_TABSTOP,
Thickframe = WS_THICKFRAME,
Tiled = WS_TILED, Visible = WS_VISIBLE,
VScroll = WS_VSCROLL};
窗口可以以最小化、最大化或正常模式显示:
enum WindowShow {Restore = SW_RESTORE, Default = SW_SHOWDEFAULT,
Maximized = SW_SHOWMAXIMIZED,
Minimized = SW_SHOWMINIMIZED,
MinNoActive = SW_SHOWMINNOACTIVE,
NoActive = SW_SHOWNA,
NoActivate = SW_SHOWNOACTIVATE,
Normal = SW_SHOWNORMAL,
Show = SW_SHOW, Hide = SW_HIDE};
鼠标可以按下左键、中键和右键。鼠标滚轮可以向上或向下滚动:
enum MouseButton {NoButton = 0x00, LeftButton = 0x01,
MiddleButton = 0x02, RightButton = 0x04};
enum WheelDirection {WheelUp, WheelDown};
有四种类型的坐标系如下:
-
LogicalWithScroll
:在这种情况下,每个单位是毫米的一百分之一,无论物理屏幕分辨率如何,都考虑当前的滚动条设置 -
LogicalWithoutScroll
:这与LogicalWithScroll
相同,只是忽略了滚动条设置 -
PreviewCoordinate
:在这种情况下,窗口客户端区域始终保持特定的逻辑大小,这意味着当窗口大小改变时,逻辑单位的大小也会改变
enum CoordinateSystem {LogicalWithScroll, LogicalWithoutScroll,
PreviewCoordinate};
消息框配备了按钮组合、图标和答案。请注意,对应于OK按钮的答案在Answer
枚举中命名为OkAnswer
,以避免与ButtonGroup
枚举中的OK
按钮名称冲突:
enum ButtonGroup {Ok = MB_OK, OkCancel = MB_OKCANCEL,
YesNo = MB_YESNO,
YesNoCancel = MB_YESNOCANCEL,
RetryCancel = MB_RETRYCANCEL,
CancelTryContinue = MB_CANCELTRYCONTINUE,
AbortRetryIgnore = MB_ABORTRETRYIGNORE};
enum Icon {NoIcon = 0, Information = MB_ICONINFORMATION,
Stop = MB_ICONSTOP, Warning = MB_ICONWARNING,
Question = MB_ICONQUESTION};
enum Answer {OkAnswer = IDOK, Cancel = IDCANCEL, Yes = IDYES,
No = IDNO, Retry = IDRETRY, Continue = IDCONTINUE,
Abort = IDABORT, Ignore = IDIGNORE} const;
OnPaint
和OnPrint
的默认定义都调用OnDraw
。为了区分这两种情况,OnDraw
参数的值为Paint
或Print
:
enum DrawMode {Paint, Print};
第一个Window
构造函数是公开的,用于直接创建窗口时使用。pageSize
字段指的是窗口客户端区域的大小。构造函数还接受指向窗口父窗口的指针(如果没有父窗口则为null
),窗口的基本样式和扩展样式,以及其初始外观、位置和大小。如果位置或大小为零,窗口将根据系统的默认设置定位或调整尺寸。
注意在PreviewCoordinate
中文档和窗口大小之间的区别:文档大小是窗口坐标系定义的单位中的客户端区域大小,而窗口的大小和位置是在父窗口的坐标系中给出,如果没有父窗口,则使用设备单位。此外,文档大小指的是客户端区域的大小,而窗口大小指的是整个窗口的大小:
class Application;
class Window {
public:
Window(CoordinateSystem system, Size pageSize = ZeroSize,
Window* parentPtr = nullptr,
WindowStyle style = OverlappedWindow,
WindowStyle extendedStyle = NoStyle,
WindowShow windowShow = Normal,
Point topLeft = ZeroPoint, Size windowSize=ZeroSize);
第二个构造函数是受保护的,用于由子类的构造函数调用。与第一个构造函数相比,它的区别在于它将window
类的名称作为其第一个参数。根据Application
类的定义,类名可以是Window
、Document
、StandardDocument
或Dialog
:
protected:
Window(Window* parentPtr = nullptr);
Window(String className, CoordinateSystem system,
Size pageSize = ZeroSize,
Window* parentPtr = nullptr,
WindowStyle style = OverlappedWindow,
WindowStyle extendedStyle = NoStyle,
WindowShow windowShow = Normal,
Point windowTopLeft = ZeroPoint,
Size windowSize = ZeroSize);
在绘制客户端区域、在逻辑单位和设备单位之间转换以及计算文本大小时使用设备上下文。它是连接到窗口的客户端区域或打印机的连接。然而,由于它附带了一套用于绘制图形对象文本的函数,它也可以被视为一个绘图工具箱。但是,在使用之前,它需要根据当前坐标系进行准备和调整:
void PrepareDeviceContext(HDC deviceContextHandle) const;
析构函数会销毁窗口并退出应用程序,如果窗口是应用程序的主窗口:
public:
virtual ~Window();
窗口可以是可见的或不可见的;它也可以被启用,以便捕获鼠标、触摸和键盘输入:
void ShowWindow(bool visible);
void EnableWindow(bool enable);
当用户更改窗口大小或移动窗口时,会调用OnSize
和OnMove
方法。大小和位置以逻辑坐标给出。当用户在消息框中按下帮助按钮的F1键时,会调用OnHelp
方法。这些方法旨在被子类覆盖,并且它们的默认行为是不做任何事情:
virtual void OnSize(Size windowSize) {/* Empty. */}
virtual void OnMove(Point topLeft) {/* Empty. */}
virtual void OnHelp() {/* Empty. */}
WindowHandle
方法返回 Win32 API 窗口句柄,它被标准对话框函数使用。ParentWindowPtr
方法返回父窗口的指针,它是null
,表示没有父窗口。SetHeader
方法设置窗口的标题,该标题在窗口的上边框中可见:
HWND WindowHandle() const {return windowHandle;}
HWND& WindowHandle() {return windowHandle;}
Window* ParentWindowPtr() const {return parentPtr;}
Window*& ParentWindowPtr() {return parentPtr;}
void SetHeader(String headerText);
窗口的客户端区域根据缩放因子进行缩放;1.0 对应于正常大小:
double GetZoom() const {return zoom;}
void SetZoom(double z) {zoom = z;}
只要timerId
参数的值不同,就可以设置或删除多个计时器。OnTimer
方法根据毫秒间隔被调用;它的默认行为是不做任何事情。
void SetTimer(int timerId, unsigned int interval);
void DropTimer(int timerId);
virtual void OnTimer(int timerId) {/* Empty. */}
SetFocus
方法将输入焦点设置到这个窗口。输入焦点将键盘输入和剪贴板指向窗口。然而,鼠标指针可能指向另一个窗口。之前拥有输入焦点的窗口会失去焦点;在给定时间内只能有一个窗口拥有焦点。HasFocus
方法返回true
,如果窗口有输入焦点。
void SetFocus() const;
bool HasFocus() const;
当窗口获得或失去输入焦点时,会调用OnGainFocus
和OnLoseFocus
方法。它们旨在被子类覆盖,并且它们的默认行为是不做任何事情。
virtual void OnGainFocus() {/* Empty. */}
virtual void OnLoseFocus() {/* Empty. */}
在 Windows 中,鼠标被视为有三个按钮,即使它实际上没有这样做。可以按下或释放鼠标按钮,并且可以移动鼠标。当用户按下或释放鼠标按钮或至少按下一个按钮移动鼠标时,会调用OnMouseDown
、OnMouseUp
和OnMouseMove
方法。用户可以同时按下Shift或Ctrl键,在这种情况下shiftPressed
或controlPressed
为true
:
virtual void OnMouseDown(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed) {/* Empty. */}
virtual void OnMouseUp(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed) {/* Empty. */}
virtual void OnMouseMove(MouseButton mouseButtons,
Point mousePoint,
bool shiftPressed,
bool controlPressed) {/* Empty. */}
用户还可以双击鼠标按钮,在这种情况下会调用OnDoubleClick
。双击的定义由 Windows 系统决定,可以在控制面板中设置。当用户单击按钮时,会调用OnMouseDown
,如果可能发生鼠标移动,则随后调用OnMouseMove
,最后调用OnMouseUp
。然而,在双击的情况下,不会调用OnMouseDown
,其调用被OnDoubleClick
所取代:
virtual void OnDoubleClick(MouseButton mouseButtons,
Point mousePoint, bool shiftPressed,
bool controlPressed) {/* Empty. */}
当用户向上或向下滚动鼠标滚轮一步时,会调用OnMouseWheel
方法。
virtual void OnMouseWheel(WheelDirection direction,
bool shiftPressed,
bool controlPressed){/* Empty. */}
当用户触摸屏幕时,会调用OnTouchDown
、OnTouchMove
和OnTouchUp
方法。与鼠标点击不同,用户可以同时触摸屏幕的多个位置。因此,参数是点的列表而不是单个点。这些方法打算由子类重写。它们的默认行为是模拟每个触摸点的一个鼠标点击,没有按钮按下,且没有按下Shift或Ctrl键:
virtual void OnTouchDown(vector<Point> pointList);
virtual void OnTouchMove(vector<Point> pointList);
virtual void OnTouchUp(vector<Point> pointList);
当用户按下和释放键时,会调用OnKeyDown
和OnKeyUp
方法。如果键是一个图形字符(ASCII 编号在 32 到 127 之间,包括 127),则在之间会调用OnChar
。OnKeyDown
和OnKeyUp
方法返回bool
;其思路是,如果使用了键,则方法返回true
。如果没有使用,则返回false
,调用方法可以自由使用该键,例如,控制滚动操作:
virtual bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed) {return false;}
virtual void OnChar(TCHAR tChar) {/* Empty. */}
virtual bool OnKeyUp(WORD key, bool shiftPressed,
bool controlPressed) {return false;}
当窗口的客户区域需要部分或完全重绘时,会调用OnPaint
方法,而当用户选择打印菜单项时,会调用OnPrint
方法。在这两种情况下,默认定义都会调用OnDraw
,它执行实际的绘制;当由OnPaint
调用时,drawMode
为Paint
,当由OnPrint
调用时,drawMode
为Print
。其思路是我们让OnPaint
和OnPrint
执行与绘画和打印相关的特定操作,并调用OnDraw
进行共同绘制。Graphics
类将在下一节中描述:
virtual void OnPaint(Graphics& graphics) const
{OnDraw(graphics, Paint);}
virtual void OnPrint(Graphics& graphics, int page,
int copy, int totalPages) const
{OnDraw(graphics, Print);}
virtual void OnDraw(Graphics& graphics,
DrawMode drawMode) const {/* Empty. */}
Invalidate
方法使客户区域无效,部分或全部;也就是说,它准备由OnPaint
或OnDraw
重绘的区域。如果clear
为true
,则首先清除该区域(用窗口客户端颜色绘制)。UpdateWindow
方法强制重绘客户区域中被无效化的部分:
void Invalidate(bool clear = true) const;
void Invalidate(Rect areaRect, bool clear = true) const;
void UpdateWindow();
当用户尝试关闭窗口时,会调用OnClose
方法;其默认行为是调用TryClose
。如果TryClose
返回true
(在其默认定义中确实如此),则窗口将被关闭。如果发生这种情况,会调用OnDestroy
,其默认行为是不做任何操作:
virtual bool TryClose() {return true;}
virtual void OnClose();
virtual void OnDestroy() {/* Empty. */}
以下方法在设备单位和逻辑单位之间转换Point
、Rectangle
或Size
对象。它们是受保护的,因为它们打算只由子类调用:
protected:
Point DeviceToLogical(Point point) const;
Rect DeviceToLogical(Rect rect) const;
Size DeviceToLogical(Size size) const;
Point LogicalToDevice(Point point) const;
Rect LogicalToDevice(Rect rect) const;
Size LogicalToDevice(Size size) const;
以下方法在设备单位中获取或设置窗口和客户区域的大小和位置:
public:
Point GetWindowDevicePosition() const;
void SetWindowDevicePosition(Point topLeft);
Size GetWindowDeviceSize() const;
void SetWindowDeviceSize(Size windowSize);
Size GetClientDeviceSize() const;
Rect GetWindowDeviceRect() const;
void SetWindowDeviceRect(Rect windowRect);
以下方法根据窗口的坐标系,在逻辑单位中获取或设置窗口和客户区域的逻辑大小和位置:
Point GetWindowPosition() const;
void SetWindowPosition(Point topLeft);
Size GetWindowSize() const;
void SetWindowSize(Size windowSize);
Size GetClientSize() const;
Rect GetWindowRect() const;
void SetWindowRect(Rect windowRect) ;
CreateTextMetric
方法初始化并返回一个 Win32 API TEXTMETRIC
结构,然后由文本度量方法使用,以计算文本的逻辑大小。它是私有的,因为它打算只由Window
方法调用:
private:
TEXTMETRIC CreateTextMetric(Font font);
以下方法计算并返回具有给定字体的字符或文本的宽度、高度、上升或平均宽度,单位为逻辑单位:
public:
int GetCharacterAverageWidth(Font font) const;
int GetCharacterHeight(Font font) const;
int GetCharacterAscent(Font font) const;
int GetCharacterWidth(Font font, TCHAR tChar) const;
MessageBox
方法显示一个包含消息、标题、一组按钮、图标以及可选的 帮助 按钮的消息框:
Answer MessageBox(String message,
String caption = TEXT("Error"),
ButtonGroup buttonGroup = Ok,
Icon icon = NoIcon, bool help = false) const;
pageSize
字段存储窗口客户端在 PreviewCoordinate
坐标系中的逻辑大小,该坐标系用于在逻辑坐标和设备坐标之间转换坐标。在 LogicalWithScroll
和 LogicalWithoutScroll
坐标系中,pageSize
存储文档的逻辑大小,这不一定等于客户端区域的大小,且在窗口大小调整时不会改变。它是受保护的,因为它在下一章的 Document
和 StandardDocument
子类中也被使用:
protected:
const Size pageSize;
在上一节中,有一个应用程序实例的句柄。windowHandle
是一个类型为 HWND
的 Win32 API 窗口句柄;parentPtr
是父窗口的指针,如果没有父窗口,则为 null
:
HWND windowHandle;
Window* parentPtr;
窗口选择的坐标系存储在 system
中。zoom
字段存储窗口的缩放因子,其中 1.0 是默认值:
private:
CoordinateSystem system;
double zoom = 1.0;
每次窗口接收消息时都会调用 WindowProc
方法。它是 Window
的朋友,因为它需要访问其私有成员:
friend LRESULT CALLBACK WindowProc(HWND windowHandle,
UINT message, WPARAM wordParam,
LPARAM longParam);
};
最后,WindowMap
将 HWND
句柄映射到 Window
指针,这些指针在 WindowProc
中如下使用:
extern map<HWND,Window*> WindowMap;
};
Window.cpp
#include "SmallWindows.h"
namespace SmallWindows {
map<HWND,Window*> WindowMap;
初始化
第一个构造函数只是用类名 window
调用第二个构造函数:
Window::Window(CoordinateSystem system, Size pageSize
/* = ZeroSize */, Window* parentPtr /*=nullptr*/,
WindowStyle style /* = OverlappedWindow */,
WindowStyle extendedStyle /* = NoStyle */,
WindowShow windowShow /* = Normal */,
Point windowTopLeft /* = ZeroPoint */,
Size windowSize /* = ZeroSize */)
:Window(TEXT("window"), system, pageSize, parentPtr, style,
extendedStyle, windowShow, windowTopLeft, windowSize) {
// Empty.
}
第二个构造函数初始化 parentPtr
、system
和 pageSize
字段:
Window::Window(String className, CoordinateSystem system,
Size pageSize /* = ZeroSize */,
Window* parentPtr /* = nullptr */,
WindowStyle style /* = OverlappedWindow */,
WindowStyle extendedStyle /* = NoStyle */,
WindowShow windowShow /* = Normal */,
Point windowTopLeft /* = ZeroPoint */,
Size windowSize /* = ZeroSize */)
:parentPtr(parentPtr),
system(system),
pageSize(pageSize) {
如果窗口是子窗口(父指针不是 null
),则将其坐标转换为父窗口的坐标系:
if (parentPtr != nullptr) {
windowTopLeft = parentPtr->LogicalToDevice(windowTopLeft);
windowSize = parentPtr->LogicalToDevice(windowSize);
}
Win32 API 窗口创建过程分为两个步骤。首先,需要注册一个 Windows 类,这已经在之前的 Application
构造函数中完成。然后,使用 Windows
类名调用 Win32 API 的 CreateWindowEx
函数,该函数返回窗口的句柄。如果大小或位置为零,则使用默认值:
int left, top, width, height;
if (windowTopLeft != ZeroPoint) {
left = windowTopLeft.X();
top = windowTopLeft.Y();
}
else {
left = CW_USEDEFAULT;
top = CW_USEDEFAULT;
}
if (windowSize != ZeroSize) {
width = windowSize.Width();
height = windowSize.Height();
}
else {
width = CW_USEDEFAULT;
height = CW_USEDEFAULT;
}
HWND parentHandle = (parentPtr != nullptr) ?
parentPtr->windowHandle : nullptr;
windowHandle =
CreateWindowEx(extendedStyle, className.c_str(),
nullptr, style, left, top, width, height,
parentHandle,::CreateMenu(),
Application::InstanceHandle(), this);
assert(windowHandle != nullptr);
为了使 WindowProc
能够接收消息并识别接收窗口,句柄被存储在 WindowMap
中:
WindowMap[windowHandle] = this;
调用 Win32 API 函数 ShowWindow
和 RegisterTouchWindow
以根据 windowShow
参数使窗口可见,并使窗口能够响应触摸移动:
::ShowWindow(windowHandle, windowShow);
::RegisterTouchWindow(windowHandle, 0);
}
析构函数调用 OnDestroy
并从 windowMap
中删除窗口。如果窗口有父窗口,它将接收输入焦点:
Window::~Window() {
OnDestroy();
WindowMap.erase(windowHandle);
if (parentPtr != nullptr) {
parentPtr->SetFocus();
}
如果窗口是应用程序的主窗口,则调用 Win32 API 的 PostQuitMessage
函数。它发布一个退出消息,该消息最终由 Application
类中的 RunMessageLoop
捕获,从而终止执行。最后,销毁窗口:
if (this == Application::MainWindowPtr()) {
::PostQuitMessage(0);
}
WindowMap.erase(windowHandle);
::DestroyWindow(windowHandle);
}
标题和可见性
ShowWindow
和 EnableWindow
方法使用窗口句柄作为第一个参数调用 Win32 API 的 ShowWindow
和 EnableWindow
函数:
void Window::ShowWindow(bool visible) {
::ShowWindow(windowHandle, visible ? SW_SHOW : SW_HIDE);
}
注意,EnableWindow
的第二个参数是 Win32 API 类型 BOOL
的值,这不一定与 C++ 类型 bool
相同。因此,由于 enable
持有类型 bool
,我们需要将其转换为 BOOL
:
void Window::EnableWindow(bool enable) {
::EnableWindow(windowHandle, enable ? TRUE : FALSE);
}
SetHeader
方法通过调用 Win32 API 函数 SetWindowText
来设置窗口的标题。由于 headerText
是一个 String
对象,而 SetWindowText
需要一个 C 字符串(一个以零终止的字符指针)作为参数,因此我们需要调用 c_str
函数:
void Window::SetHeader(String headerText) {
::SetWindowText(windowHandle, headerText.c_str());
}
SetTimer
和 DropTimer
方法通过调用 Win32 API 函数 SetTimer
和 KillTimer
来开启和关闭具有给定标识符的计时器。SetTimer
调用中的间隔以毫秒为单位给出:
void Window::SetTimer(int timerId, unsigned int interval) {
::SetTimer(windowHandle, timerId, interval, nullptr);
}
void Window::DropTimer(int timerId) {
::KillTimer(windowHandle, timerId);
}
SetFocus
方法通过调用相应的 Win32 API 函数 SetFocus
来设置焦点。HasFocus
方法通过调用 GetFocus
Win32 API 函数返回 true
,如果窗口通过该函数获得了输入焦点,该函数返回窗口句柄,与窗口句柄进行比较:
void Window::SetFocus() const {
::SetFocus(windowHandle);
}
bool Window::HasFocus() const {
return (::GetFocus() == windowHandle);
}
触摸屏
OnTouchDown
、OnTouchMove
和 OnTouchUp
的默认行为是调用每个触摸点的相应鼠标输入方法,没有按钮按下,也没有 Shift 或 Ctrl 键被按下:
void Window::OnTouchDown(vector<Point> pointList) {
for (Point touchPoint : pointList) {
OnMouseDown(NoButton, touchPoint, false, false);
}
}
void Window::OnTouchMove(vector<Point> pointList) {
for (Point touchPoint : pointList) {
OnMouseMove(NoButton, touchPoint, false, false);
}
}
void Window::OnTouchUp(vector<Point> pointList) {
for (Point touchPoint : pointList) {
OnMouseUp(NoButton, touchPoint, false, false);
}
}
在现代屏幕上,用户可以以类似于鼠标点击的方式触摸屏幕。然而,用户可以同时触摸屏幕的几个位置,并且其位置存储在一个点列表中。OnTouch
方法是一个辅助方法,当用户触摸屏幕时调用 OnTouchDown
、OnTouchMove
和 OnTouchUp
。它创建一个逻辑坐标中的点列表:
void OnTouch(Window* windowPtr, WPARAM wordParam,
LPARAM longParam, Point windowTopLeft) {
UINT inputs = LOWORD(wordParam);
HTOUCHINPUT touchInputHandle = (HTOUCHINPUT) longParam;
TOUCHINPUT* inputArray = new TOUCHINPUT[inputs];
assert(inputArray != nullptr);
if (::GetTouchInputInfo(touchInputHandle, inputs,
inputArray, sizeof(TOUCHINPUT))){
vector<Point> pointList;
for (UINT index = 0; index < inputs; ++index) {
Point touchPoint
((inputArray[index].x / 100) - windowTopLeft.X(),
(inputArray[index].y / 100) - windowTopLeft.Y());
pointList.push_back(touchPoint);
}
如果触摸标识符不等于输入数组中的第一个值,我们有一个触摸下事件;如果它相等,我们有一个触摸移动事件:
static DWORD touchId = -1;
if (touchId != inputArray[0].dwID) {
touchId = inputArray[0].dwID;
windowPtr->OnTouchDown(pointList);
}
else {
windowPtr->OnTouchMove(pointList);
}
::CloseTouchInputHandle(touchInputHandle);
}
delete [] inputArray;
}
无效化和窗口更新
当窗口的客户区域需要(部分或全部)重绘时,会调用 Invalidate
方法之一。Invalidate
方法调用 Win32 API 函数 InvalicateRect
,该函数在调用 UpdateWindow
时发送一个消息,导致调用 OnPaint
。clear
参数指示在重绘之前是否应该清除(用窗口客户区域的颜色重绘)无效区域,这通常是情况。类似于我们之前看到的 EnableWindow
方法,我们需要将 clear
从类型 bool
转换为 BOOL
:
void Window::Invalidate(bool clear /* = true */) const {
::InvalidateRect(windowHandle, nullptr, clear ? TRUE : FALSE);
}
Invalidate
方法在调用 Win32 API 函数 InvalidateRect
之前将区域从逻辑坐标转换为设备坐标,并将大小存储在 RECT
结构中:
void Window::Invalidate(Rect areaRect, bool clear /* = true */)
const {
RECT rect = (RECT) LogicalToDevice(areaRect);
::InvalidateRect(windowHandle, &rect, clear ? TRUE : FALSE);
}
UpdateWindow
方法调用 Win32 API 函数 UpdateWindow
,这最终导致调用 OnPaint
:
void Window::UpdateWindow() {
::UpdateWindow(windowHandle);
}
准备设备上下文
当绘制窗口的客户端区域时,我们需要一个设备上下文,我们需要根据坐标系来准备它,以便使用逻辑坐标进行绘制。Win32 API 函数 SetMapMode
设置逻辑坐标系统的映射模式。MISOTROPIC
强制 x 和 y 轴具有相同的单位长度(导致非椭圆形的圆),这对于 LogicalWithScroll
和 LogicalWithoutScroll
系统是合适的,而 MANISOTROPIC
允许不同的单位长度,这对于 PreviewCoordinate
系统是合适的。我们通过调用 SetWindowExtEx
函数建立逻辑和设备系统之间的映射,它接受客户端区域的逻辑大小,以及调用 SetViewportExtEx
函数,它接受其物理(设备)大小。
在 PreviewCoordinate
坐标系的情况下,我们只需将客户端区域(pageSize
)的逻辑大小与由 Win32 API 函数 GetClientRect
给出的设备大小(clientDeviceRect
)相匹配,从而使得客户端区域始终具有相同的逻辑大小,无论其实际大小如何:
void Window::PrepareDeviceContext(HDC deviceContextHandle)const{
switch (system) {
case PreviewCoordinate: {
RECT clientDeviceRect;
::GetClientRect(windowHandle, &clientDeviceRect);
::SetMapMode(deviceContextHandle, MM_ANISOTROPIC);
::SetWindowExtEx(deviceContextHandle, pageSize.Width(),
pageSize.Height(), nullptr);
::SetViewportExtEx(deviceContextHandle,
clientDeviceRect.right,
clientDeviceRect.bottom, nullptr);
}
break;
在逻辑坐标系的情况下,我们需要找到逻辑坐标(数百毫米)和设备坐标(像素)之间的比率。换句话说,我们需要确定像素的逻辑大小。我们可以通过调用带有 HORZSIZE
和 VERTSIZE
的 Win32 API 函数 GetDeviceCaps
来找到屏幕上的像素数,以及使用 HORZRES
和 VERTRES
的毫米级屏幕大小。由于我们的逻辑单位是数百毫米,我们需要将逻辑大小乘以 100。我们还需要考虑窗口的缩放因子,这通过将物理大小乘以 zoom
来实现。
注意,只有在 PreviewCoordinate
系统中,客户端区域始终具有相同的逻辑大小。在其他系统中,当窗口大小改变时,逻辑大小也会改变。在 LogicalWithScroll
和 LogicalWithoutScroll
中,逻辑单位始终相同:数百毫米:
case LogicalWithScroll:
case LogicalWithoutScroll:
::SetMapMode(deviceContextHandle, MM_ISOTROPIC);
{ int horizontalSize =
100 * GetDeviceCaps(deviceContextHandle,HORZSIZE),
verticalSize =
100 * GetDeviceCaps(deviceContextHandle,VERTSIZE);
::SetWindowExtEx(deviceContextHandle, horizontalSize,
verticalSize, nullptr);
}
{ int horizontalResolution = (int)
(zoom*GetDeviceCaps(deviceContextHandle, HORZRES)),
verticalResolution = (int)
(zoom*GetDeviceCaps(deviceContextHandle, VERTRES));
::SetViewportExtEx(deviceContextHandle,
horizontalResolution, verticalResolution, nullptr);
}
在 LogicalWithScroll
逻辑坐标系的情况下,我们还需要通过调用 Win32 API 函数 SetWindowOrg
来根据当前的滚动设置调整窗口的原点:
if (system == LogicalWithScroll) {
int horizontalScroll =
::GetScrollPos(windowHandle, SB_HORZ),
verticalScroll =
::GetScrollPos(windowHandle, SB_VERT);
::SetWindowOrgEx(deviceContextHandle, horizontalScroll,
verticalScroll, nullptr);
}
break;
}
}
单位转换
DeviceToLogical
方法通过准备设备上下文并调用 Win32 API 函数 DPtoLP
(设备点到逻辑点)将一个点、矩形或大小的设备坐标转换为逻辑坐标。请注意,我们通过调用 Win32 API 函数 GetDC
来建立设备上下文,并且需要通过调用 ReleaseDC
来返回它。另外,请注意,我们需要将 Point
对象转换为 POINT
结构,然后再转换回来,因为 DPtoLP
接受一个指向 POINT
的指针:
Point Window::DeviceToLogical(Point point) const {
HDC deviceContextHandle = ::GetDC(windowHandle);
PrepareDeviceContext(deviceContextHandle);
POINT pointStruct = (POINT) point;
::DPtoLP(deviceContextHandle, &pointStruct, 1);
::ReleaseDC(windowHandle, deviceContextHandle);
return Point(pointStruct);
}
在转换矩形时,我们使用点方法转换其左上角和右下角。在转换大小时,我们创建一个矩形,调用矩形方法,然后将矩形转换为大小:
Rect Window::DeviceToLogical(Rect rect) const {
return Rect(DeviceToLogical(rect.TopLeft()),
DeviceToLogical(rect.BottomRight()));
}
Size Window::DeviceToLogical(Size size) const {
return ((Size) DeviceToLogical(Rect(ZeroPoint, size)));
}
LogicalToDevice
方法将点、矩形或大小从逻辑坐标转换为设备坐标,调用 Win32 API 函数 LPtoDP
(逻辑点到设备点)的方式与早期方法相同。唯一的区别是它们调用 LPtoDP
而不是 DPtoLP
:
Point Window::LogicalToDevice(Point point) const {
HDC deviceContextHandle = ::GetDC(windowHandle);
PrepareDeviceContext(deviceContextHandle);
POINT pointStruct = (POINT) point;
::LPtoDP(deviceContextHandle, &pointStruct, 1);
::ReleaseDC(windowHandle, deviceContextHandle);
return Point(pointStruct);
}
Rect Window::LogicalToDevice(Rect rect) const {
return Rect(LogicalToDevice(rect.TopLeft()),
LogicalToDevice(rect.BottomRight()));
}
Size Window::LogicalToDevice(Size size) const {
return ((Size) LogicalToDevice(Rect(ZeroPoint, size)));
}
窗口大小和位置
GetWindowDevicePosition
、SetWindowDevicePosition
、GetWindowDeviceSize
、SetWindowDeviceSize
和 GetClientDeviceSize
方法调用相应的 Win32 API 函数 GetWindowRect
、GetClientRect
和 SetWindowPos
:
Point Window::GetWindowDevicePosition() const {
return GetWindowDeviceRect().TopLeft();
}
void Window::SetWindowDevicePosition(Point topLeft) {
::SetWindowPos(windowHandle, nullptr, topLeft.X(),
topLeft.Y(), 0, 0, SWP_NOSIZE);
}
Size Window::GetWindowDeviceSize() const {
return GetWindowDeviceRect().GetSize();
}
void Window::SetWindowDeviceSize(Size windowSize) {
::SetWindowPos(windowHandle, nullptr, 0, 0,
windowSize.Width(),windowSize.Height(),SWP_NOMOVE);
}
Size Window::GetClientDeviceSize() const {
RECT rectStruct;
::GetClientRect(windowHandle, &rectStruct);
return Size(rectStruct.right, rectStruct.bottom);
}
Rect Window::GetWindowDeviceRect() const {
RECT windowRect;
::GetWindowRect(windowHandle, &windowRect);
POINT topLeft = {windowRect.left, windowRect.top},
bottomRight = {windowRect.right, windowRect.bottom};
if (parentPtr != nullptr) {
::ScreenToClient(parentPtr->windowHandle, &topLeft);
::ScreenToClient(parentPtr->windowHandle, &bottomRight);
}
return Rect(Point(topLeft), Point(bottomRight));
}
void Window::SetWindowDeviceRect(Rect windowRect) {
SetWindowDevicePosition(windowRect.TopLeft());
SetWindowDeviceSize(windowRect.GetSize());
}
GetWindowPosition
、SetWindowPosition
、GetWindowSize
、SetWindowSize
和 GetClientSize
方法与 LogicalToDevice
或 DeviceToLogical
一起调用相应的设备方法:
Point Window::GetWindowPosition() const {
return DeviceToLogical(GetWindowDevicePosition());
}
void Window::SetWindowPosition(Point topLeft) {
SetWindowDevicePosition(LogicalToDevice(topLeft));
}
Size Window::GetWindowSize() const {
return DeviceToLogical(GetWindowDeviceSize());
}
void Window::SetWindowSize(Size windowSize) {
SetWindowDeviceSize(LogicalToDevice(windowSize));
}
Size Window::GetClientSize() const {
return DeviceToLogical(GetClientDeviceSize());
}
Rect Window::GetWindowRect() const {
return DeviceToLogical(GetWindowDeviceRect());
}
void Window::SetWindowRect(Rect windowRect) {
SetWindowDeviceRect(LogicalToDevice(windowRect));
}
文本度量
给定一个字体,CreateTextMetric
创建一个包含字体字符的高度、基线上升线和平均宽度的度量结构。CreateFontIndirect
和 SelectObject
方法为 GetTextExtentPoint
准备字体:
TEXTMETRIC Window::CreateTextMetric(Font font) const {
font.PointsToLogical();
HDC deviceContextHandle = ::GetDC(windowHandle);
PrepareDeviceContext(deviceContextHandle);
HFONT fontHandle = ::CreateFontIndirect(&font.LogFont());
HFONT oldFontHandle =
(HFONT) ::SelectObject(deviceContextHandle, fontHandle);
TEXTMETRIC textMetric;
::GetTextMetrics(deviceContextHandle, &textMetric);
注意,CreateFontIndirect
必须与 DeleteObject
匹配,并且第一个 SelectObject
调用必须与第二个 SelectObject
调用匹配以重新安装原始对象:
::SelectObject(deviceContextHandle, oldFontHandle);
::DeleteObject(fontHandle);
此外,请注意,从 GetDC
收到的设备上下文必须使用 ReleaseDC
释放:
::ReleaseDC(windowHandle, deviceContextHandle);
return textMetric;
}
GetCharacterHeight
、GetCharacterAscent
和 GetCharacterAverageWidth
方法调用 CreateTextMetric
并返回相关信息:
int Window::GetCharacterHeight(Font font) const {
return CreateTextMetric(font).tmHeight;
}
int Window::GetCharacterAscent(Font font) const {
return CreateTextMetric(font).tmAscent;
}
int Window::GetCharacterAverageWidth(Font font) const {
return CreateTextMetric(font).tmAveCharWidth;
}
GetCharacterWidth
方法调用 GetTextExtentPoint
以确定给定字体的字符宽度。由于字体高度是以排版点(1 点 = 1/72 英寸 = 1/72 * 25.4 毫米 ≈≈ 0.35 毫米)给出的,并且需要以毫米为单位给出,我们调用 PointsToLogical
。类似于我们在 CreateTextMetric
、CreateFontIndirect
和 SelectObject
中所做的,CreateFontIndirect
和 SelectObject
方法为 GetTextExtentPoint
准备字体:
int Window::GetCharacterWidth(Font font, TCHAR tChar) const {
font.PointsToLogical();
HDC deviceContextHandle = ::GetDC(windowHandle);
PrepareDeviceContext(deviceContextHandle);
HFONT fontHandle = ::CreateFontIndirect(&font.LogFont());
HFONT oldFontHandle =
(HFONT) ::SelectObject(deviceContextHandle, fontHandle);
SIZE szChar;
::GetTextExtentPoint(deviceContextHandle, &tChar, 1, &szChar);
::SelectObject(deviceContextHandle, oldFontHandle);
::DeleteObject(fontHandle);
::ReleaseDC(windowHandle, deviceContextHandle);
return szChar.cx;
}
关闭窗口
当用户尝试关闭窗口时,如果 TryClose
返回 true
,则删除 Window
对象(this
):
void Window::OnClose() {
if (TryClose()) {
delete this;
}
}
MessageBox
方法
MessageBox
方法显示一个包含标题、消息、按钮组合(确定、确定-取消、重试-取消、是-否、是-否-取消、取消-重试-继续或中止-重试-忽略)、可选图标(信息、停止、警告或问题)和可选的 帮助 按钮的消息框。它返回 确定答案(因为 确定 已经被 ButtonGroup
枚举占用)、取消、是、否、重试、继续、中止 或 忽略:
Answer Window::MessageBox(String message,
String caption /*=TEXT("Error")*/,
ButtonGroup buttonGroup /* = Ok */,
Icon icon /* = NoIcon */,
bool help /* = false */) const {
return (Answer) ::MessageBox(windowHandle, message.c_str(),
caption.c_str(), buttonGroup |
icon | (help ? MB_HELP : 0));
}
当通过 Window
类构造函数中的 CreateWindowEx
调用创建窗口时,由 Application
类构造函数先前给出的 Windows
类的名称被包含在内。当类被注册时,还会提供一个独立的函数。对于 Window
类,该函数是 WindowProc
,因此每当窗口收到消息时都会调用它:
wordParam
和 longParam
参数(WPARAM
和 LPARAM
都是 4 字节)包含消息特定的信息,这些信息可能被分为低字和高字(2 字节),使用 LOWORD
和 HIWORD
宏来区分:
LRESULT CALLBACK WindowProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam){
首先,我们需要通过在静态字段 WindowMap
中查找句柄来找到与窗口句柄关联的 Window
对象:
if (WindowMap.count(windowHandle) == 1) {
Window* windowPtr = WindowMap[windowHandle];
当接收到 WSETFOCUS
、WKILLFOCUS
和 WTIMER
消息时,Window
中的相应方法被简单地调用。当消息被处理完毕后,它们不需要进一步处理;因此,返回零:
switch (message) {
case WM_SETFOCUS:
windowPtr->OnGainFocus();
return 0;
case WM_KILLFOCUS:
windowPtr->OnLoseFocus();
return 0;
计时器的身份(SetTimer
和 DropTimer
中的 timerId
参数)存储在 wordParam
中:
case WM_TIMER:
windowPtr->OnTimer((int) wordParam);
return 0;
当接收到 WMOVE
和 WSIZE
消息时,存储在 longParam
中的 Point
值是以设备单位给出的,需要通过在 Window
中的 OnMove
和 OnSize
调用中调用 DeviceToLogical
来转换为逻辑单位:
case WM_MOVE: {
Point windowTopLeft =
{LOWORD(longParam), HIWORD(longParam)};
windowPtr->OnMove
(windowPtr->DeviceToLogical(windowTopLeft));
}
return 0;
case WM_SIZE: {
Size clientSize =
{LOWORD(longParam), HIWORD(longParam)};
windowPtr->
OnSize(windowPtr->DeviceToLogical(clientSize));
}
return 0;
如果用户在消息框中按下 F1 键或 帮助 按钮,则发送 WM_HELP
消息。我们在 Window
中调用 OnHelp
:
case WM_HELP:
windowPtr->OnHelp();
break;
在处理鼠标或键盘输入消息时,决定用户是否同时按下 Shift 或 Ctrl 键是有用的。这可以通过调用 Win32 API 函数 GetKeyState
来实现,如果使用 VK_SHIFT
或 VK_CONTROL
调用,则当键被按下时,它返回一个小于零的整数值:
case WM_KEYDOWN: {
WORD key = wordParam;
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
如果 OnKeyDown
返回 true
,则表示键消息已被处理,我们返回零。如果它返回 false
,则将调用如这里所示的 Win32 API 函数 DefWindowProc
,该函数将进一步处理消息:
if (windowPtr->OnKeyDown(wordParam, shiftPressed,
controlPressed)) {
return 0;
}
}
break;
如果按下的键是一个图形字符(ASCII 码在 32 到 127 之间,包括 127),则调用 OnChar
:
case WM_CHAR: {
int asciiCode = (int) wordParam;
if ((asciiCode >= 32) && (asciiCode <= 127)) {
windowPtr->OnChar((TCHAR) asciiCode);
return 0;
}
}
break;
case WM_KEYUP: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
if (windowPtr->OnKeyUp(wordParam, shiftPressed,
controlPressed)) {
return 0;
}
}
break;
所有存储在 longParam
中的鼠标输入点都是以设备坐标给出的,需要通过 DeviceToLogical
转换为逻辑坐标。鼠标按下消息通常随后是相应的鼠标抬起消息。不幸的是,如果用户在一个窗口中按下鼠标按钮并在另一个窗口中释放它,那么鼠标抬起消息将被发送到另一个窗口。然而,可以通过 Win32 API 函数 SetCapture
解决这个问题,该函数确保在调用 ReleaseCapture
之前将每个鼠标消息发送到窗口:
case WM_LBUTTONDOWN: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
::SetCapture(windowPtr->windowHandle);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseDown(LeftButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
case WM_MBUTTONDOWN: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
::SetCapture(windowPtr->windowHandle);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseDown(MiddleButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
case WM_RBUTTONDOWN: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
::SetCapture(windowPtr->windowHandle);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseDown(RightButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
当用户移动鼠标时,他们可能同时按下按钮组合,这些按钮存储在 buttonMask
中:
case WM_MOUSEMOVE: {
MouseButton buttonMask = (MouseButton)
(((wordParam & MK_LBUTTON) ? LeftButton : 0) |
((wordParam & MK_MBUTTON) ? MiddleButton : 0) |
((wordParam & MK_RBUTTON) ? RightButton : 0));
if (buttonMask != NoButton) {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL)<0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseMove(buttonMask,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
}
return 0;
注意,ReleaseCapture
是在鼠标抬起方法结束时被调用的,目的是释放窗口的鼠标消息,并使鼠标消息能够发送到其他窗口:
case WM_LBUTTONUP: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseUp(LeftButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
::ReleaseCapture();
}
return 0;
case WM_MBUTTONUP: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseUp(MiddleButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
::ReleaseCapture();
}
return 0;
case WM_RBUTTONUP: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnMouseUp(RightButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
::ReleaseCapture();
}
return 0;
case WM_LBUTTONDBLCLK: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnDoubleClick(LeftButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
case WM_MBUTTONDBLCLK: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnDoubleClick(MiddleButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
case WM_RBUTTONDBLCLK: {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL) < 0);
Point mousePoint =
Point({LOWORD(longParam), HIWORD(longParam)});
windowPtr->OnDoubleClick(RightButton,
windowPtr->DeviceToLogical(mousePoint),
shiftPressed, controlPressed);
}
return 0;
当发送触摸消息时,会调用OnTouch
,这需要窗口在设备单位中的位置:
case WM_TOUCH:
OnTouch(windowPtr, wordParam, longParam,
windowPtr->GetWindowDevicePosition());
return 0;
在响应绘图消息创建设备上下文时,我们使用 Win32 API 函数BeginPaint
和EndPaint
而不是GetDC
和ReleaseDC
来处理设备上下文。然而,设备上下文仍然需要为窗口的坐标系统做好准备,这是通过PrepareDeviceContext
完成的:
case WM_PAINT: {
PAINTSTRUCT paintStruct;
HDC deviceContextHandle =
::BeginPaint(windowHandle,&paintStruct);
windowPtr->PrepareDeviceContext(deviceContextHandle);
Graphics graphics(windowPtr, deviceContextHandle);
windowPtr->OnPaint(graphics);
::EndPaint(windowHandle, &paintStruct);
}
return 0;
当用户尝试通过点击右上角的关闭框来关闭窗口时,会调用OnClose
。它调用TryClose
,如果TryClose
返回 true,则关闭窗口:
case WM_CLOSE:
windowPtr->OnClose();
return 0;
}
}
如果我们达到这一点,Win32 API 函数DefWindowProc
会被调用,它执行默认的消息处理:
return DefWindowProc(windowHandle, message, wordParam, longParam);
}
};
Graphics
类
Graphics
类是一个设备上下文的包装类。它还提供了绘制线条、矩形和椭圆;写入文本;保存和恢复图形状态;设置设备上下文的起点;以及裁剪绘图区域的功能。构造函数是私有的,因为Graphics
对象旨在仅由小窗口内部创建。
Graphics.h
namespace SmallWindows {
在绘制线条时,可以是实线、虚线、点线、点划线,以及点划双线:
class Window;
enum PenStyle {Solid = PS_SOLID, Dash = PS_DASH, Dot = PS_DOT,
DashDot = PS_DASHDOT, DashDotDot =PS_DASHDOTDOT};
class Graphics {
private:
Graphics(Window* windowPtr, HDC deviceContextHandle);
Save
方法保存Graphics
对象当前的状态,而Restore
恢复它:
public:
int Save();
void Restore(int saveId);
SetOrigin
方法设置坐标系统的原点,而IntersectClip
限制要绘制的区域:
void SetOrigin(Point centerPoint);
void IntersectClip(Rect clipRect);
以下方法绘制线条、矩形、椭圆,并写入文本:
void DrawLine(Point startPoint, Point endPoint,
Color penColor, PenStyle penStyle = Solid);
void DrawRectangle(Rect rect, Color penColor,
PenStyle = Solid);
void FillRectangle(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle=Solid);
void DrawEllipse(Rect rect, Color penColor,
PenStyle = Solid);
void FillEllipse(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle=Solid);
void DrawText(Rect areaRect, String text, Font font,
Color textColor, Color backColor,
bool pointsToMeters = true);
GetDeviceContextHandle
方法返回由Graphics
对象包装的设备上下文:
HDC GetDeviceContextHandle() const
{return deviceContextHandle;}
windowPtr
字段持有指向要绘制客户端区域的窗口的指针,而deviceContextHandle
持有设备上下文的句柄,类型为HDC
:
private:
Window* windowPtr;
HDC deviceContextHandle;
WindowProc
和DialogProc
函数是Graphics
类的朋友,因为它们需要访问其私有成员。对于StandardDialog
类的PrintDialog
方法也是如此:
friend LRESULT CALLBACK
WindowProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam);
friend Graphics* StandardDialog::PrintDialog
(Window*parentPtr,int totalPages,
int& firstPage, int& lastPage,
int& copies, bool& sorted);
};
};
Graphics.cpp
#include "SmallWindows.h"
构造函数初始化窗口指针和设备上下文:
namespace SmallWindows {
Graphics::Graphics(Window* windowPtr, HDC deviceContextHandle)
:windowPtr(windowPtr),
deviceContextHandle(deviceContextHandle) {
// Empty.
}
有时,可能希望使用Save
保存Graphics
对象的当前状态,它返回一个可以用来使用Restore
恢复Graphics
对象的身份号码:
int Graphics::Save() {
return ::SaveDC(deviceContextHandle);
}
void Graphics::Restore(int saveId) {
::RestoreDC(deviceContextHandle, saveId);
}
坐标系的默认原点(x = 0 和 y = 0)是窗口客户端区域的左上角。这可以通过SetOrigin
来改变,它接受新的原点在逻辑单位中。Win32 API 函数SetWindowOrgEx
设置新的原点:
void Graphics::SetOrigin(Point centerPoint) {
::SetWindowOrgEx(deviceContextHandle, centerPoint.X(),
centerPoint.Y(), nullptr);
}
可以使用IntersectClip
限制要绘制的客户端区域的部分,结果是在给定矩形外的区域不受影响。Win32 API 函数IntersectClip
设置限制区域:
void Graphics::IntersectClip(Rect clipRect) {
::IntersectClipRect(deviceContextHandle, clipRect.Left(),
clipRect.Top(),clipRect.Right(),clipRect.Bottom());
}
可以使用笔绘制线条、矩形和椭圆,笔是通过 Win32 API 函数 CreatePen
和 SelectObject
获取的。请注意,我们保存了上一个对象以便稍后恢复:
void Graphics::DrawLine(Point startPoint, Point endPoint,
Color color, PenStyle penStyle/* = Solid */){
HPEN penHandle = ::CreatePen(penStyle, 0, color.ColorRef());
HPEN oldPenHandle =
(HPEN) ::SelectObject(deviceContextHandle,penHandle);
顺便说一下,使用 MoveToEx
和 LineTo
将笔移动到起点并绘制到终点的技术被称为海龟图形,指的是笔在客户端区域内上提或放下移动的乌龟:
::MoveToEx(deviceContextHandle, startPoint.X(),
startPoint.Y(), nullptr);
::LineTo(deviceContextHandle, endPoint.X(), endPoint.Y());
与 Window
中的 CreateTextMetrics
和 GetCharacterWidth
类似,我们需要选择上一个对象并恢复笔:
::SelectObject(deviceContextHandle, oldPenHandle);
::DeleteObject(penHandle);
}
在绘制矩形时,我们需要一个实心笔和一个空心画刷,我们使用带有 LOGBRUSH
结构参数的 Win32 API 函数 CreateBrushIndirect
创建它们:
void Graphics::DrawRectangle(Rect rect, Color penColor,
PenStyle penStyle /* = Solid */) {
HPEN penHandle =
::CreatePen(penStyle, 0, penColor.ColorRef());
LOGBRUSH lbBrush;
lbBrush.lbStyle = BS_HOLLOW;
HBRUSH brushHandle = ::CreateBrushIndirect(&lbBrush);
HPEN oldPenHandle =
(HPEN) ::SelectObject(deviceContextHandle,penHandle);
HBRUSH oldBrushHandle =
(HBRUSH) ::SelectObject(deviceContextHandle, brushHandle);
::Rectangle(deviceContextHandle, rect.Left(), rect.Top(),
rect.Right(), rect.Bottom());
::SelectObject(deviceContextHandle, oldBrushHandle);
::DeleteObject(brushHandle);
::SelectObject(deviceContextHandle, oldPenHandle);
::DeleteObject(penHandle);
}
在填充矩形时,我们还需要一个实心画刷,我们使用 Win32 API 函数 CreateSolidBrush
创建它:
void Graphics::FillRectangle(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle /* = Solid */){
HPEN penHandle =
::CreatePen(penStyle, 0, penColor.ColorRef());
HBRUSH brushHandle =
::CreateSolidBrush(brushColor.ColorRef());
HPEN oldPenHandle =
(HPEN)::SelectObject(deviceContextHandle,penHandle);
HBRUSH oldBrushHandle =
(HBRUSH) ::SelectObject(deviceContextHandle, brushHandle);
::Rectangle(deviceContextHandle, rect.Left(), rect.Top(),
rect.Right(), rect.Bottom());
::SelectObject(deviceContextHandle, oldBrushHandle);
::DeleteObject(brushHandle);
::SelectObject(deviceContextHandle, oldPenHandle);
::DeleteObject(penHandle);
}
DrawEllipse
和 FillEllipse
方法与 DrawRectangle
和 FillRectangle
类似。唯一的区别是它们调用 Win32 API 函数 Ellipse
而不是 Rectangle
:
void Graphics::DrawEllipse(Rect rect, Color penColor,
PenStyle penStyle /* = Solid */) {
HPEN penHandle =
::CreatePen(penStyle, 0, penColor.ColorRef());
LOGBRUSH lbBrush;
lbBrush.lbStyle = BS_HOLLOW;
HBRUSH brushHandle = ::CreateBrushIndirect(&lbBrush);
HPEN oldPenHandle =
(HPEN)::SelectObject(deviceContextHandle,penHandle);
HBRUSH oldBrushHandle =
(HBRUSH) ::SelectObject(deviceContextHandle, brushHandle);
::Ellipse(deviceContextHandle, rect.Left(), rect.Top(),
rect.Right(), rect.Bottom());
::SelectObject(deviceContextHandle, oldBrushHandle);
::DeleteObject(brushHandle);
::SelectObject(deviceContextHandle, oldPenHandle);
::DeleteObject(penHandle);
}
void Graphics::FillEllipse(Rect rect, Color penColor,
Color brushColor, PenStyle penStyle /* = Solid */){
HPEN penHandle =
::CreatePen(penStyle, 0, penColor.ColorRef());
HBRUSH brushHandle =
::CreateSolidBrush(brushColor.ColorRef());
HPEN oldPenHandle =
(HPEN) ::SelectObject(deviceContextHandle,penHandle);
HBRUSH oldBrushHandle =
(HBRUSH) ::SelectObject(deviceContextHandle, brushHandle);
::Ellipse(deviceContextHandle, rect.Left(), rect.Top(),
rect.Right(), rect.Bottom());
::SelectObject(deviceContextHandle, oldBrushHandle);
::DeleteObject(brushHandle);
::SelectObject(deviceContextHandle, oldPenHandle);
::DeleteObject(penHandle);
}
在绘制文本时,我们首先需要检查字体是否以排版点给出并需要转换为逻辑单位(如果 pointToMeters
为真),这在 LogicalWithScroll
和 LogicalWithoutScroll
坐标系中是这种情况。然而,在 PreviewCoordinate
系统中,文本的大小已经以逻辑单位给出,不应进行转换。此外,在我们写入文本之前,我们需要创建并选择一个字体对象,并设置文本和背景颜色。Win32 的 DrawText
函数在给定的矩形内居中文本:
void Graphics::DrawText(Rect areaRect, String text, Font font,
Color textColor, Color backColor,
bool pointsToMeters /* = true */) {
if (pointsToMeters) {
font.PointsToLogical();
}
HFONT fontHandle = ::CreateFontIndirect(&font.LogFont());
HFONT oldFontHandle =
(HFONT) ::SelectObject(deviceContextHandle, fontHandle);
::SetTextColor(deviceContextHandle, textColor.ColorRef());
::SetBkColor(deviceContextHandle, backColor.ColorRef());
RECT rectStruct = (RECT) areaRect;
::DrawText(deviceContextHandle, text.c_str(), text.length(),
&rectStruct, DT_SINGLELINE |DT_CENTER |DT_VCENTER);
::SelectObject(deviceContextHandle, oldFontHandle);
::DeleteObject(fontHandle);
}
};
摘要
在本章中,我们探讨了小型窗口的核心:MainWindow
函数以及 Application
、Window
和 Graphics
类。在第十一章《文档》中,我们探讨了小型窗口的文档类:Document
、Menu
、Accelerator
和 StandardDocument
。
第十一章。文档
在上一章中,我们探讨了 Application
和 Window
类的实现,这些类对通用 Windows 应用程序很有用。在本章中,我们将探讨 Document
、StandardDocument
、Menu
和 Accelerator
类的实现,这些类对基于文档的 Windows 应用程序很有用。
文档类
在这本书中,文档是一个用于通用文档应用程序的窗口,例如这本书的绘图程序、电子表格程序和文字处理程序。Document
类实现了之前描述的文档,并且是 Window
类的直接子类。它支持光标、脏标志、键盘状态、菜单、快捷键、鼠标滚轮、滚动条和拖放文件。
Document.h
namespace SmallWindows {
extern const Size USLetterPortrait, LineSize;
键盘处于 insert
或 overwrite
模式之一。
enum KeyboardMode {InsertKeyboard, OverwriteKeyboard};
与 Window
类类似,Document
类有一个公共构造函数用于实例化,还有一个受保护的构造函数用于子类。Document
类的文档可以接受拖放文件,并且滚动条方法使用行大小:
class Document : public Window {
public:
Document(CoordinateSystem system, Size pageSize,
Window* parentPtr = nullptr,
WindowStyle style=OverlappedWindow,
WindowShow windowShow = Normal,
bool acceptDropFiles = true,
Size lineSize = LineSize);
protected:
Document(String className, CoordinateSystem system,
Size pageSize, Window* parentPtr = nullptr,
WindowStyle style = OverlappedWindow,
WindowShow windowShow = Normal,
bool acceptDropFiles = true,
Size lineSize = LineSize);
如果窗口已被修改并且需要在关闭前保存(文档已被 dirty),则会设置一个脏标志。文档的内容可以根据缩放因子进行缩放;默认值为 1.0。文档的名称通过 GenerateHeader
显示在文档标题中,同时显示缩放因子(以百分比表示),如果脏标志为 true
,则显示一个星号(****)。然而,如果缩放因子为 100%,则不会显示:
public:
~Document();
String GetName() const;
void SetName(String name);
void SetZoom(double zoom);
bool IsDirty() const;
void SetDirty(bool dirty);
private:
void GenerateHeader();
OnSize
方法被重写以根据客户端大小修改滚动条的大小。请注意,OnSize
的参数是客户端区域的逻辑大小,而不是窗口的大小:
public:
virtual void OnSize(Size clientSize);
OnMouseWheel
方法被重写,以便每次滚轮点击滚动垂直滚动条一行:
virtual void OnMouseWheel(WheelDirection direction,
bool shiftPressed, bool controlPressed);
Document
类支持光标,并且重写了 OnGainFocus
和 OnLoseFocus
方法以显示或隐藏光标。SetCaret
和 ClearCaret
方法创建和销毁光标:
void OnGainFocus();
void OnLoseFocus();
void SetCaret(Rect caretLogicalRect);
void ClearCaret();
当光标需要修改时,会调用 UpdateCaret
方法,它旨在被重写,并且其默认行为是不做任何事情:
virtual void UpdateCaret() {/* Empty. */}
SetMenuBar
方法设置窗口的菜单栏。每次用户选择菜单项或按下快捷键时,都会调用 OnCommand
方法,并且在菜单可见之前调用 CommandInit
以在菜单项上设置勾选标记或单选按钮,或者启用或禁用它:
void SetMenuBar(Menu& menuBar);
void OnCommand(WORD commandId);
void OnCommandInit();
如果构造函数中的 acceptDropFiles
参数为 true
,则文档接受拖放文件。如果用户移动一个或多个文件并将它们拖放到文档窗口中,则会用路径名列表作为参数调用 OnDropFile
。它旨在被子类重写,并且其默认行为是不做任何事情:
virtual void OnDropFile(vector<String> pathList)
{/* Empty. */}
GetKeyboardMode
和SetKeyboardMode
方法设置和获取keyboard
模式。当keyboard
模式改变时,会调用OnKeyboardMode
方法;它旨在被重写,并且默认行为是不做任何事情:
KeyboardMode GetKeyboardMode() const {return keyboardMode;}
void SetKeyboardMode(KeyboardMode mode)
{keyboardMode = mode;}
virtual void OnKeyboardMode(KeyboardMode mode)
{/* Empty. */}
OnHorizontalScroll
和OnVerticalScroll
方法处理滚动消息。滚动条根据消息设置进行设置:
virtual void OnHorizontalScroll(WORD flags,WORD thumbPos=0);
virtual void OnVerticalScroll(WORD flags, WORD thumbPos =0);
KeyToScroll
方法接受一个键,并根据键以及是否按下Shift或Ctrl键执行适当的滚动条操作。例如,Page Up键将垂直滚动条向上移动一页:
virtual bool KeyToScroll(WORD key, bool shiftPressed,
bool controlPressed);
以下方法设置或获取逻辑位置、行大小、页面大小以及水平和垂直滚动条的总大小:
void SetHorizontalScrollPosition(int scrollPos);
int GetHorizontalScrollPosition() const;
void SetVerticalScrollPosition(int scrollPos);
int GetVerticalScrollPosition() const;
void SetHorizontalScrollLineWidth(int lineWidth);
int GetHorizontalScrollLineHeight() const;
void SetVerticalScrollLineHeight(int lineHeight);
int GetVerticalScrollLineHeight() const;
void SetHorizontalScrollPageWidth(int pageWidth);
int GetHorizontalScrollPageWidth() const;
void SetVerticalScrollPageHeight(int pageHeight);
int GetVerticalScrollPageHeight() const;
void SetHorizontalScrollTotalWidth(int scrollWidth);
int GetHorizontalScrollTotalWidth() const;
void SetVerticalScrollTotalHeight(int scrollHeight);
int GetVerticalScrollTotalHeight() const;
命令映射存储文档的菜单项;对于每个菜单项,存储选择、启用、检查和单选按钮监听器:
public:
map<WORD,Command>& CommandMap() {return commandMap;}
加速器集合包含文档的加速器,无论它是常规键还是虚拟键(例如,F2,Home,或Delete)以及是否按下Ctrl,Shift或Alt键。该集合由Application
中的消息循环使用:
list<ACCEL>& AcceleratorSet() {return acceleratorSet;}
private:
map<WORD, Command> commandMap;
list<ACCEL> acceleratorSet;
name
字段是显示在窗口顶部的文档名称;当光标可见时,caretPresent
为true
:
String name;
bool caretPresent = false;
当用户按下箭头键之一时,会调用OnKeyDown
。然而,如果OnKeyDown
返回false
,则滚动条会改变;在这种情况下,我们需要lineSize
来定义要滚动的行的大小:
Size lineSize;
当用户在未保存的情况下更改文档时,dirtyFlag
字段为true
,导致保存菜单项被启用,并在关闭窗口或退出应用程序时询问用户是否保存文档:
bool dirtyFlag = false;
menuBarHandle
方法是处理文档窗口菜单栏的 Win32 API 函数:
HMENU menuBarHandle;
键盘可以保持insert
或overwrite
模式,该模式存储在keyboardMode
中:
KeyboardMode keyboardMode = InsertKeyboard;
};
当文档窗口接收到消息时,会调用DocumentProc
方法,类似于Window
类中的WindowProc
方法:
LRESULT CALLBACK DocumentProc(HWND windowHandle, UINT message,
WPARAM wordParam,LPARAM longParam);
当窗口接收到WM_DROPFILES
消息时,ExtractPathList
方法会提取拖放文件的路径:
vector<String> ExtractPathList(WORD wordParam);
};
初始化
第一个Document
构造函数接受坐标系、页面大小、父窗口、样式、外观、文档是否接受拖放文件以及行大小作为其参数。在竖直模式(站立)下,美国信函纸张的大小为 215.9 * 279.4 毫米。一行(在滚动行时由KeyToScroll
使用)在水平和垂直方向上都是 5 毫米。由于逻辑单位是毫米的一百分之一,我们将每个度量乘以一百。
Document.cpp
#include "SmallWindows.h"
namespace SmallWindows {
const Size USLetterPortrait(21590, 27940), LineSize(500, 500);
第一个构造函数使用名为Document
的Windows
类作为第一个参数调用第二个构造函数:
Document::Document(CoordinateSystem system, Size pageSize,
Window* parentPtr /* = nullptr */,
WindowStyle style /* = OverlappedWindow */,
WindowShow windowShow /* = Normal */,
bool acceptDropFiles /* = true */,
Size lineSize /* = LineSize */)
:Document::Document(TEXT("document"), system, pageSize,
parentPtr, style, windowShow,
acceptDropFiles, lineSize) {
// Empty.
}
第二个构造函数与第一个构造函数具有相同的参数,除了它将 Windows
类名作为其第一个参数插入:
Document::Document(String className, CoordinateSystem system,
Size pageSize, Window* parentPtr/*=nullptr*/,
WindowStyle style /* = OverlappedWindow */,
WindowShow windowShow /* = Normal */,
bool acceptDropFiles /* = true */,
Size lineSize /* = LineSize */)
:Window(className, system, pageSize, parentPtr,
style, NoStyle, windowShow),
滚动条的范围和页面大小存储在窗口的滚动条设置中。但是,行的尺寸需要存储在 lineSize
中:
lineSize(lineSize) {
标题出现在文档窗口的顶部栏上:
GenerateHeader();
滚动条的默认位置是 0
:
SetHorizontalScrollPosition(0);
SetVerticalScrollPosition(0);
滚动条的大小是页面的逻辑宽度和高度:
SetHorizontalScrollTotalWidth(pageSize.Width());
SetVerticalScrollTotalHeight(pageSize.Height());
滚动条的页面大小表示文档的可见部分,即客户端区域的逻辑大小:
Size clientSize = GetClientSize();
SetHorizontalScrollPageWidth(clientSize.Width());
SetVerticalScrollPageHeight(clientSize.Height());
Win32 API 函数 DragAcceptFiles
使窗口接受拖放文件。请注意,我们需要将 C++ 的 bool
类型 acceptDropFiles
转换为 Win32 API 的 BOOL
类型的 TRUE
或 FALSE
值:
::DragAcceptFiles(windowHandle,
acceptDropFiles ? TRUE : FALSE);
}
析构函数如果存在,会销毁光标:
Document::~Document() {
if (caretPresent) {
::DestroyCaret();
}
}
文档标题
GetName
方法简单地返回名称。然而,SetName
设置名称并重新生成文档窗口的标题。同样,SetZoom
和 SetDirty
:它们设置缩放因子和脏标志,然后重新生成标题:
String Document::GetName() const {
return name;
}
void Document::SetName(String name) {
this->name = name;
GenerateHeader();
}
void Document::SetZoom(double zoom) {
Window::SetZoom(zoom);
GenerateHeader();
}
bool Document::IsDirty() const {
return dirtyFlag;
}
void Document::SetDirty(bool dirty) {
dirtyFlag = dirty;
GenerateHeader();
}
文档的标题包括其名称,是否设置了脏标志(由星号表示),以及缩放状态(以百分比表示),除非它是 100%。
void Document::GenerateHeader() {
String headerName = name.empty() ? TEXT("[No Name]") : name,
dirtyText = dirtyFlag ? TEXT("*") : TEXT("");
int zoomPerCent = (int) (100 * GetZoom());
if (zoomPerCent!= 100) {
String zoomText =
TEXT(" ") + to_String(zoomPerCent) + TEXT("%");
SetHeader(headerName + dirtyText + zoomText);
}
else {
SetHeader(headerName + dirtyText);
}
}
OnSize
方法根据新的客户端大小修改水平和垂直滚动条的页面大小:
void Document::OnSize(Size clientSize) {
SetHorizontalScrollPageWidth(clientSize.Width());
SetVerticalScrollPageHeight(clientSize.Height());
}
光标
如第一章中所述,简介,光标是表示下一个输入字符位置的标记。它在 insert
模式下是一个细长的垂直条,在 overwrite
模式下是一个块。OnGainFocus
和 OnLoseFocus
方法显示和隐藏光标(如果存在):
void Document::OnGainFocus() {
if (caretPresent) {
::ShowCaret(windowHandle);
}
}
void Document::OnLoseFocus() {
if (caretPresent) {
::HideCaret(windowHandle);
}
}
SetCaret
方法显示具有给定尺寸的光标。如果已经存在光标,则将其销毁:
void Document::SetCaret(Rect caretLogicalRect) {
if (caretPresent) {
::DestroyCaret();
}
光标的大小必须以设备单位给出;存在风险,即 LogicalToDevice
调用将宽度四舍五入为零(在垂直条的情况下),在这种情况下,宽度设置为 1:
Rect deviceCaretRect = LogicalToDevice(caretLogicalRect);
if (deviceCaretRect.Width() == 0) {
deviceCaretRect.Right() = deviceCaretRect.Left() + 1;
}
新的光标是通过 Win32 API 函数 CreateCaret
、SetCaretPos
和 ShowCaret
创建的:
::CreateCaret(windowHandle, nullptr, deviceCaretRect.Width(),
deviceCaretRect.Height());
::SetCaretPos(deviceCaretRect.Left(), deviceCaretRect.Top());
::ShowCaret(windowHandle);
caretPresent = true;
}
如果存在,ClearCaret
方法会销毁光标:
void Document::ClearCaret() {
if (caretPresent) {
::DestroyCaret();
}
caretPresent = false;
}
鼠标滚轮
当用户移动鼠标滚轮时,垂直滚动条向上或向下移动一行(如果他们没有按 Ctrl 键):
void Document::OnMouseWheel(WheelDirection wheelDirection,
bool shiftPressed, bool controlPressed){
if (controlPressed) {
switch (wheelDirection) {
case WheelUp:
OnVerticalScroll(SB_LINEUP);
break;
case WheelDown:
OnVerticalScroll(SB_LINEDOWN);
break;
}
}
如果用户按下 Ctrl 键,则客户端区域将被缩放。允许的范围是 10% 到 1,000%:
else {
switch (wheelDirection) {
case WheelUp:
SetZoom(min(10.0, 1.11 * GetZoom()));
break;
case WheelDown:
SetZoom(max(0.1, 0.9 * GetZoom()));
break;
}
}
由于垂直滚动条位置已修改,我们需要重新绘制整个客户端区域:
Invalidate();
UpdateWindow();
UpdateCaret();
}
菜单栏
文档的菜单栏通过调用 Win32 API 函数 SetMenu
来设置,该函数处理文档窗口和菜单栏;menuBarHandle
在 OnCommandInit
中启用或标记菜单项时使用,如下所示:
void Document::SetMenuBar(Menu& menuBar) {
menuBarHandle = menuBar.menuHandle;
::SetMenu(windowHandle, menuBarHandle);
}
当用户选择菜单项或加速键时,会调用 OnCommand
方法。它查找并调用与给定命令标识符关联的选择监听器:
void Document::OnCommand(WORD commandId) {
Command command = commandMap[commandId];
command.Selection()(this);
}
在菜单变得可见之前会调用OnCommandInit
方法。它会遍历每个菜单项,并为每个菜单项决定是否应该用勾选标记或单选按钮进行标注,或者启用或禁用:
void Document::OnCommandInit() {
for (pair<WORD,Command> pair : commandMap) {
WORD commandId = pair.first;
Command command = pair.second;
如果启用监听器不为空,我们调用它并将启用标志设置为MF_ENABLED
或MF_GRAYED
(禁用):
if (command.Enable() != nullptr) {
UINT enableFlag = command.Enable()(this) ?
MF_ENABLED : MF_GRAYED;
::EnableMenuItem(menuBarHandle, commandId,
MF_BYCOMMAND | enableFlag);
}
如果勾选或单选按钮监听器不为空,我们调用它们并将checkflag
或radioFlag
设置为:
{ bool checkFlag = false;
if (command.Check() != nullptr) {
BoolListener checkListener = command.Check();
checkFlag = checkListener(this);
}
bool radioFlag = false;
if (command.Radio() != nullptr) {
BoolListener radioListener = command.Radio();
radioFlag = radioListener(this);
}
如果checkFlag
或radioFlag
中的任何一个为true
,我们检查菜单项。菜单项是否因此被标注为勾选标记或单选按钮,是在将菜单项添加到菜单时决定的,这在下一节的Menu
类中描述。Menu
还指出,至少有一个勾选标记和单选按钮监听器必须为空,因为不可能同时用勾选标记和单选按钮标注菜单项:
UINT checkFlags = (checkFlag | radioFlag) ?
MF_CHECKED : MF_UNCHECKED;
::CheckMenuItem(menuBarHandle, commandId,
MF_BYCOMMAND | checkFlags);
}
}
}
滚动条
每次用户通过点击滚动条箭头、滚动条本身或拖动滚动滑块进行滚动时,都会调用OnHorizontalScroll
和OnVerticalScroll
方法。
scrollPos
字段存储当前的滚动条设置。scrollLine
变量是行的大小,scrollPage
是页面的大小(表示文档可见部分的逻辑大小,等于客户端区域的大小),而scrollSize
是滚动条的总大小(表示文档的逻辑大小):
void Document::OnHorizontalScroll(WORD flags,
WORD thumbPos /*= 0 */) {
int scrollPos = GetHorizontalScrollPosition(),
scrollLine = GetHorizontalScrollLineHeight(),
scrollPage = GetHorizontalScrollPageWidth(),
scrollSize = GetHorizontalScrollTotalWidth();
switch (flags) {
case SB_LEFT:
SetHorizontalScrollPosition(0);
break;
在向左移动的情况下,我们需要验证新的滚动位置是否不低于零:
case SB_LINELEFT:
SetHorizontalScrollPosition(max(0, scrollPos -
scrollLine));
break;
case SB_PAGELEFT:
SetHorizontalScrollPosition(max(0, scrollPos -
scrollPage));
break;
在向右移动的情况下,我们需要验证滚动位置是否不超过滚动条大小:
case SB_LINERIGHT:
SetHorizontalScrollPosition(min(scrollPos + scrollLine,
scrollSize - scrollLine));
break;
case SB_PAGERIGHT:
SetHorizontalScrollPosition(min(scrollPos + scrollLine,
scrollSize - scrollPage));
break;
case SB_RIGHT:
SetHorizontalScrollPosition(scrollSize - scrollPage);
break;
如果用户拖动滚动条滑块,我们只需设置新的滚动位置。消息之间的区别在于,当用户拖动滑块时,会持续发送SB_THUMBTRACK
,而SB_THUMBPOSITION
是在用户释放鼠标按钮时发送的:
case SB_THUMBTRACK:
case SB_THUMBPOSITION:
SetHorizontalScrollPosition(thumbPos);
break;
}
}
垂直滚动条的运动方式与水平滚动条的运动方式相同:
void Document::OnVerticalScroll(WORD flags,
WORD thumbPos /* = 0 */) {
int scrollPos = GetVerticalScrollPosition(),
scrollLine = GetVerticalScrollLineHeight(),
scrollPage = GetVerticalScrollPageHeight(),
scrollSize = GetVerticalScrollTotalHeight();
switch (flags) {
case SB_TOP:
SetVerticalScrollPosition(0);
break;
case SB_LINEUP:
SetVerticalScrollPosition(max(0, scrollPos - scrollLine));
break;
case SB_PAGEUP:
SetVerticalScrollPosition(max(0, scrollPos - scrollPage));
break;
case SB_LINEDOWN:
SetVerticalScrollPosition(min(scrollPos + scrollLine,
scrollSize - scrollLine));
break;
case SB_PAGEDOWN:
SetVerticalScrollPosition(min(scrollPos + scrollLine,
scrollSize - scrollPage));
break;
case SB_BOTTOM:
SetVerticalScrollPosition(scrollSize - scrollPage);
break;
case SB_THUMBTRACK:
case SB_THUMBPOSITION:
SetVerticalScrollPosition(thumbPos);
break;
}
}
当用户按下键时调用KeyToScroll
函数。它检查按键,执行适当的滚动操作,如果使用了该键,则返回true
,表示这一点:
bool Document::KeyToScroll(WORD key, bool shiftPressed,
bool controlPressed) {
switch (key) {
case KeyUp:
OnVerticalScroll(SB_LINEUP);
return true;
case KeyDown:
OnVerticalScroll(SB_LINEDOWN);
return true;
case KeyPageUp:
OnVerticalScroll(SB_PAGEUP);
return true;
case KeyPageDown:
OnVerticalScroll(SB_PAGEDOWN);
return true;
case KeyLeft:
OnHorizontalScroll(SB_LINELEFT);
return true;
case KeyRight:
OnHorizontalScroll(SB_LINERIGHT);
return true;
case KeyHome:
OnHorizontalScroll(SB_LEFT);
if (controlPressed) {
OnVerticalScroll(SB_TOP);
}
return true;
case KeyEnd:
OnHorizontalScroll(SB_RIGHT);
if (controlPressed) {
OnVerticalScroll(SB_BOTTOM);
}
return true;
}
return false;
}
如果滚动位置已更改,我们通过调用 Win32 API 函数SetScrollPos
来设置新的滚动位置,并更新窗口和光标:
void Document::SetHorizontalScrollPosition(int scrollPos) {
if (scrollPos != GetHorizontalScrollPosition()) {
::SetScrollPos(windowHandle, SB_HORZ, scrollPos, TRUE);
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
Win32 API 函数GetScrollPos
返回当前的滚动条位置:
int Document::GetHorizontalScrollPosition() const {
return ::GetScrollPos(windowHandle, SB_HORZ);
}
垂直滚动位置的方法与水平滚动条的方法工作方式相同:
void Document::SetVerticalScrollPosition(int scrollPos) {
if (scrollPos != GetVerticalScrollPosition()) {
::SetScrollPos(windowHandle, SB_VERT, scrollPos, TRUE);
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
int Document::GetVerticalScrollPosition() const {
return ::GetScrollPos(windowHandle, SB_VERT);
}
SetHorizontalScrollLineWidth
、GetHorizontalScrollLineHeight
、SetVerticalScrollLineHeight
和GetVerticalScrollLineHeight
方法没有 Win32 API 的对应方法。相反,我们在lineSize
字段中存储滚动行的尺寸:
void Document::SetHorizontalScrollLineWidth(int lineWidth) {
lineSize.Width() = lineWidth;
}
int Document::GetHorizontalScrollLineHeight() const {
return lineSize.Width();
}
void Document::SetVerticalScrollLineHeight(int lineHeight) {
lineSize.Height() = lineHeight;
}
int Document::GetVerticalScrollLineHeight() const {
return lineSize.Height();
}
SetHorizontalScrollPageWidth
、GetHorizontalScrollPageWidth
、SetVerticalScrollPageHeight
和 GetVerticalScrollPageHeight
方法没有直接的 Win32 API 对应函数。然而,GetScrollInfo
和 SetScrollInfo
函数处理一般的滚动信息,我们可以设置和提取页面信息:
void Document::SetHorizontalScrollPageWidth(int pageWidth) {
SCROLLINFO scrollInfo = {sizeof(SCROLLINFO), SIF_PAGE};
scrollInfo.nPage = pageWidth;
::SetScrollInfo(windowHandle, SB_HORZ, &scrollInfo, TRUE);
}
int Document::GetHorizontalScrollPageWidth() const {
SCROLLINFO scrollInfo = {sizeof(SCROLLINFO), SIF_PAGE};
::GetScrollInfo(windowHandle, SB_HORZ, &scrollInfo);
return scrollInfo.nPage;
}
void Document::SetVerticalScrollPageHeight(int pageHeight) {
SCROLLINFO scrollInfo = {sizeof(SCROLLINFO), SIF_PAGE};
scrollInfo.nPage = pageHeight;
::SetScrollInfo(windowHandle, SB_VERT, &scrollInfo, TRUE);
}
int Document::GetVerticalScrollPageHeight() const {
SCROLLINFO scrollInfo = {sizeof(SCROLLINFO), SIF_PAGE};
::GetScrollInfo(windowHandle, SB_VERT, &scrollInfo);
return scrollInfo.nPage;
}
SetHorizontalScrollTotalWidth
、GetHorizontalScrollTotalWidth
、SetVerticalScrollTotalHeight
和 GetVerticalScrollTotalHeight
方法调用 Win32 API 函数 SetScrollRange
和 GetScrollRange
,这些函数设置和获取滚动值的最小和最大值。然而,我们忽略最小值,因为它始终为 0:
void Document::SetHorizontalScrollTotalWidth(int scrollWidth) {
::SetScrollRange(windowHandle, SB_HORZ, 0, scrollWidth, TRUE);
}
int Document::GetHorizontalScrollTotalWidth() const {
int minRange, maxRange;
::GetScrollRange(windowHandle, SB_HORZ, &minRange, &maxRange);
return maxRange;
}
void Document::SetVerticalScrollTotalHeight(int scrollHeight) {
::SetScrollRange(windowHandle, SB_VERT, 0, scrollHeight,TRUE);
}
int Document::GetVerticalScrollTotalHeight() const {
int minRange, maxRange;
::GetScrollRange(windowHandle, SB_VERT, &minRange, &maxRange);
return maxRange;
}
DocumentProc
方法
每当文档(Document
类的文档)收到消息时,都会调用 DocumentProc
方法。如果它使用该消息,则返回 0;否则,调用上一章中描述的 WindowProc
方法来进一步处理该消息:
LRESULT CALLBACK DocumentProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam){
我们在 Window
类的 WindowMap
中查找窗口,并且只有当窗口是 Document
对象时才采取行动:
if ((windowHandle != nullptr) &&
(WindowMap.count(windowHandle) == 1)) {
Document* documentPtr =
dynamic_cast<Document*>(WindowMap[windowHandle]);
if (documentPtr != nullptr) {
switch (message) {
如果单词参数的第九位被设置,鼠标滚轮的方向向下:
case WM_MOUSEWHEEL: {
bool down = (HIWORD(wordParam) & 0x0100) != 0;
WheelDirection wheelDirection =
down ? WheelDown : WheelUp;
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed = (::GetKeyState(VK_CONTROL)<0);
documentPtr->OnMouseWheel(wheelDirection,
shiftPressed, controlPressed);
}
return 0;
按键消息同时检查 Insert 键并调用 OnKeyDown
和 KeyToScroll
,如果其中一个使用该键则返回 0:
case WM_KEYDOWN: {
WORD key = wordParam;
如果用户按下 Insert 键,则键盘模式在插入和覆盖模式之间切换。SetKeyboardMode
设置键盘模式并调用 OnKeyboardMode
,该函数旨在被子类覆盖以通知应用程序变化:
if (key == KeyInsert) {
switch (documentPtr->GetKeyboardMode()) {
case InsertKeyboard:
documentPtr->
SetKeyboardMode(OverwriteKeyboard);
documentPtr->
OnKeyboardMode(OverwriteKeyboard);
break;
case OverwriteKeyboard:
documentPtr->SetKeyboardMode(InsertKeyboard);
documentPtr->OnKeyboardMode(InsertKeyboard);
break;
}
return 0;
}
如果用户没有按下 Insert 键,我们检查 OnKeyDown
是否使用该键(并因此返回 true
)。如果没有,我们则检查 KeyToScroll
是否使用该键。如果 OnKeyDown
或 KeyToScroll
返回 true
,则返回 0:
else {
bool shiftPressed = (::GetKeyState(VK_SHIFT) < 0);
bool controlPressed=(::GetKeyState(VK_CONTROL)<0);
if (documentPtr->OnKeyDown(wordParam,shiftPressed,
controlPressed) ||
documentPtr->KeyToScroll(key, shiftPressed,
controlPressed)) {
return 0;
}
}
}
break;
当用户选择菜单项时,会发送 WM_COMMAND
事件,在菜单可见之前发送 WM_INITMENUPOPUP
事件。通过调用 OnCommand
来处理消息,该函数执行与菜单项连接的选项监听器,以及调用 OnCommandInit
,在它们变得可见之前使用复选标记或单选按钮启用或注释菜单项:
case WM_COMMAND:
documentPtr->OnCommand(LOWORD(wordParam));
return 0;
case WM_INITMENUPOPUP:
documentPtr->OnCommandInit();
return 0;
当用户将一组文件拖放到窗口中时,在调用 OnDropFile
之前,我们需要提取它们的路径。ExtractPath
方法从拖放中提取文件的路径并返回路径列表,该列表被发送到 OnDropFile
:
case WM_DROPFILES: {
vector<String> pathList =
ExtractPathList(wordParam);
documentPtr->OnDropFile(pathList);
}
return 0;
通过调用相应的匹配方法来处理 WM_HSCROLL
和 WM_VSCROLL
消息:
case WM_HSCROLL: {
WORD flags = LOWORD(wordParam),
thumbPos = HIWORD(wordParam);
documentPtr->OnHorizontalScroll(flags, thumbPos);
}
return 0;
case WM_VSCROLL: {
WORD flags = LOWORD(wordParam),
thumbPos = HIWORD(wordParam);
documentPtr->OnVerticalScroll(flags, thumbPos);
}
return 0;
}
}
}
最后,如果消息没有被 DocumentProc
捕获,则调用上一章中描述的 WindowProc
方法来进一步处理该消息:
return WindowProc(windowHandle, message,
wordParam, longParam);
}
ExtractPathList
方法通过调用 Win32 API 函数 DragQueryFile
提取拖放文件的路径并返回路径列表:
vector<String> ExtractPathList(WORD wordParam) {
vector<String> pathList;
HDROP dropHandle = (HDROP) wordParam;
DragQueryFile
方法在第二个参数为 0xFFFFFFFF
时返回文件数量:
int size =
::DragQueryFile(dropHandle, 0xFFFFFFFF, nullptr, 0);
for (int index = 0; index < size; ++index) {
当第二个参数是一个基于零的索引,第三个参数为 null 时,DragQueryFile
方法返回路径字符串的大小:
int bufferSize =
::DragQueryFile(dropHandle, index, nullptr, 0) + 1;
TCHAR* path = new TCHAR[bufferSize];
assert(path!= nullptr);
当第三个参数是指向文本缓冲区的指针而不是 null 时,DragQueryFile
方法会复制路径本身:
assert(::DragQueryFile(dropHandle, index,
path, bufferSize) != 0);
pathList.push_back(String(path));
delete [] path;
}
return pathList;
}
};
Menu
类
Menu
类处理一个菜单,由菜单项列表、分隔条或子菜单组成。当添加菜单项时,其命令信息存储在文档的命令映射中,以便在接收WM_COMMAND
和WM_INITCOMMAND
消息时使用。如果菜单项文本包含一个快捷键,它将被添加到文档的加速器集中。Command
类是一个辅助类,持有指向菜单项的指针:选择、启用、检查和单选监听器。
Command.h
namespace SmallWindows {
typedef void (*VoidListener)(void* sourcePtr);
typedef bool (*BoolListener)(void* sourcePtr);
class Command {
public:
Command();
Command(VoidListener selection, BoolListener enable,
BoolListener check, BoolListener radio);
VoidListener Selection() const {return selection;}
BoolListener Enable() const {return enable;}
BoolListener Check() const {return check;}
BoolListener Radio() const {return radio;}
private:
VoidListener selection;
BoolListener enable, check, radio;
};
};
Command.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Command::Command()
:selection(nullptr),
enable(nullptr),
check(nullptr),
radio(nullptr) {
// Empty.
}
Command::Command(VoidListener selection, BoolListener enable,
BoolListener check, BoolListener radio)
:selection(selection),
enable(enable),
check(check),
radio(radio) {
// Empty.
}
};
菜单和加速器监听器不是常规方法。它们通过DECLARE_BOOL_LISTENER
和DECLARE_VOID_LISTENER
宏声明(它们不需要定义)。这是因为我们无法直接在未知类中调用非静态方法。因此,我们让宏声明一个不带参数的非静态方法,并定义一个带有void
指针参数的静态方法,该方法调用非静态方法。宏不定义非静态方法。这项任务留给 Small Windows 的用户来完成。
当用户添加一个带有监听器的菜单项时,会创建一个Command
对象。实际上,这是添加到Command
对象中的具有void
指针参数的静态方法。此外,当用户选择一个菜单项时,调用的是静态方法。静态方法反过来调用用户定义的非静态方法。
宏接受当前类和监听器的名称作为参数。请注意,bool
监听器是常量,而void
监听器不是常量。这是因为bool
监听器旨在查找类的字段之一或多个字段的值,而void
监听器还修改字段。
Menu.h
#define DEFINE_BOOL_LISTENER(SubClass, Listener) \
virtual bool Listener() const; \
static bool SubClass::Listener(void* voidPtr) { \
return ((SubClass*) voidPtr)->Listener(); \
}
#define DEFINE_VOID_LISTENER(SubClass, Listener) \
virtual void Listener(); \
static void SubClass::Listener(void* voidPtr) { \
((SubClass*) voidPtr)->Listener(); \
}
namespace SmallWindows {
class Document;
class Menu {
public:
Menu(Document* documentPtr, String text = TEXT(""));
Menu(const Menu& menu);
void AddMenu(Menu& menu);
void AddSeparator();
void AddItem(String text, VoidListener selection,
BoolListener enable = nullptr,
BoolListener check = nullptr,
BoolListener radio = nullptr);
访问文档的命令映射和加速器集时需要文档指针。除了菜单栏之外,每个菜单都有在文档窗口中显示的文本;menuHandle
是这个类包装的 Win32 API 菜单句柄:
private:
Document* documentPtr;
String text;
HMENU menuHandle;
friend class Document;
friend class StandardDocument;
};
};
Menu.cpp
#include "SmallWindows.h"
构造函数初始化指针文档和文本。它还通过调用 Win32 API 函数CreateMenu
创建菜单。由于菜单栏不需要文本,text
参数默认为空:
namespace SmallWindows {
Menu::Menu(Document* documentPtr, String text /* = TEXT("") */)
:documentPtr(documentPtr),
text(text),
menuHandle(::CreateMenu()) {
// Empty.
}
复制构造函数复制菜单的字段。请注意,我们复制menuHandle
字段而不是创建一个新的菜单句柄。
Menu::Menu(const Menu& menu)
:documentPtr(menu.documentPtr),
text(menu.text),
menuHandle(menu.menuHandle) {
// Empty.
}
AddMenu
方法将菜单(不是菜单项)作为子菜单添加到菜单中,而AddSeparator
将分隔符(水平条)添加到菜单中:
void Menu::AddMenu(Menu& menu) {
::AppendMenu(menuHandle, MF_STRING | MF_POPUP,
(UINT) menu.menuHandle, menu.text.c_str());
}
void Menu::AddSeparator() {
::AppendMenu(menuHandle, MF_SEPARATOR, 0, nullptr);
}
AddItem
方法将菜单项(不是菜单)添加到菜单中,带有选择、启用、检查和单选监听器:
void Menu::AddItem(String text, VoidListener selection,
BoolListener enable /* = nullptr */,
BoolListener check /* = nullptr */,
BoolListener radio /* = nullptr */) {
选择监听器不允许为空,并且至少有一个复选框和单选按钮监听器必须为空,因为不可能同时用复选框和单选按钮注释菜单项:
assert((selection != nullptr) &&
((check == nullptr) || (radio == nullptr)));
每个菜单项都有一个唯一的标识符,我们通过命令映射的当前大小来获取:
map<WORD,Command>& commandMap = documentPtr->CommandMap();
int itemId = commandMap.size();
我们使用 Win32 API 函数 AppendMenu
将一个 Command
对象添加到命令映射中,并添加菜单项,该函数需要菜单句柄、标识符和文本:
commandMap[itemId] = Command(listener, enable, check, radio);
::AppendMenu(menuHandle, MF_STRING,
(UINT) itemId, text.c_str());
如果单选按钮监听器不为空,我们需要调用 Win32 API 函数 SetMenuItemInfo
以使单选按钮与菜单项一起出现:
if (radio != nullptr) {
MENUITEMINFO menuItemInfo;
menuItemInfo.cbSize = sizeof menuItemInfo;
menuItemInfo.fMask = MIIM_FTYPE;
menuItemInfo.fType = MFT_RADIOCHECK;
::SetMenuItemInfo(menuHandle, (UINT) itemId,
FALSE, &menuItemInfo);
}
最后,我们在 Accelerator
(在下一节中描述)中调用 TextToAccelerator
,如果存在,则将其添加到文档的加速器集中,该加速器集由 Application
的消息循环使用:
Accelerator::TextToAccelerator(text, itemId,
documentPtr->AcceleratorSet());
}
};
加速器类
可以将一个加速器添加到菜单项中。加速器文本前面有一个制表符字符 (\t
),文本由可选的前缀 Ctrl+
、Shift+
或 Alt+
后跟一个字符(例如,&Open\tCtrl+O
)或虚拟键的名称(例如,&Save\tAlt+F2
)组成。
Accelerator.h
namespace SmallWindows {
Win32 API 包含一组以 VK_
开头的虚拟键。在小窗口中,它们被赋予了其他名称,希望更容易理解。可用的虚拟键有:F1 - F12、Insert、Delete、Backspace、Tab、Home、End、Page Up、Page Down、Left、Right、Up、Down、Space、Escape 和 Return:
enum Keys {KeyF1 = VK_F1, KeyF2 = VK_F2, KeyF3 = VK_F3,
KeyF4 = VK_F4, KeyF5 = VK_F5, KeyF6 = VK_F6,
KeyF7 = VK_F7, KeyF8 = VK_F8, KeyF9 = VK_F9,
KeyF10 = VK_F10, KeyF11 = VK_F11, KeyF12 = VK_F12,
KeyInsert = VK_INSERT, KeyDelete = VK_DELETE,
KeyBackspace = VK_BACK, KeyTabulator = VK_TAB,
KeyHome = VK_HOME, KeyEnd = VK_END,
KeyPageUp = VK_PRIOR, KeyPageDown = VK_NEXT,
KeyLeft = VK_LEFT, KeyRight = VK_RIGHT,
KeyUp = VK_UP, KeyDown = VK_DOWN,
KeySpace = VK_SPACE, KeyEscape = VK_ESCAPE,
KeyReturn = VK_RETURN};
Accelerator
类只包含 TextToAccelerator
方法,该方法接受文本,提取加速器,如果存在,则将其添加到加速器集中:
class Accelerator {
public:
static void TextToAccelerator(String& text, int idemId,
list<ACCEL>& acceleratorSet);
};
};
Accelerator.cpp
#include "SmallWindows.h"
TextToVirtualKey
是一个辅助函数,它接受文本并返回相应的虚拟键。keyTable
数组持有文本和可用虚拟键之间的映射:
namespace SmallWindows {
WORD TextToVirtualKey(String& text) {
static const struct {
TCHAR* textPtr;
WORD key;
} keyTable[] = {
{TEXT("F1"), KeyF1}, {TEXT("F2"), KeyF2},
{TEXT("F3"), KeyF3}, {TEXT("F4"), KeyF4},
{TEXT("F5"), KeyF5}, {TEXT("F6"), KeyF6},
{TEXT("F7"), KeyF7}, {TEXT("F8"), KeyF8},
{TEXT("F9"), KeyF9}, {TEXT("F10"), KeyF10},
{TEXT("F11"), KeyF11}, {TEXT("F12"), KeyF12},
{TEXT("Insert"), KeyInsert}, {TEXT("Delete"), KeyDelete},
{TEXT("Back"), KeyBackspace}, {TEXT("Tab"), KeyTabulator},
{TEXT("Home"), KeyHome}, {TEXT("End"), KeyEnd},
{TEXT("Page Up"), KeyPageUp},
{TEXT("Page Down"), KeyPageDown},
{TEXT("Left"), KeyLeft}, {TEXT("Right"), KeyRight},
{TEXT("Up"), KeyUp}, {TEXT("Down"), KeyDown},
{TEXT("Space"), KeySpace}, {TEXT("Escape"), KeyEscape},
{TEXT("Return"), KeyReturn}, {nullptr, 0}};
我们遍历表格,直到找到虚拟键:
for (int index = 0; keyTable[index].textPtr != nullptr;
++index) {
if (text == keyTable[index].textPtr) {
return keyTable[index].key;
}
}
如果我们没有找到与文本匹配的键,将发生断言:
assert(false);
return 0;
}
在 TextToAccelerator
中,我们将 Control、Shift、Alt 和虚拟键状态与键一起存储在一个 Win32 API ACCEL
结构中:
void Accelerator::TextToAccelerator(String& text, int itemId,
list<ACCEL>&acceleratorSet){
首先,我们检查文本是否包含一个 Tab 键(\t)。如果包含,我们使用 itemId
初始化 ACCEL
结构,并提取文本的加速器部分:
int tabulatorIndex = text.find(TEXT("\t"));
if (tabulatorIndex != -1) {
ACCEL accelerator;
accelerator.fVirt = 0;
accelerator.cmd = itemId;
String acceleratorText = text.substr(tabulatorIndex + 1);
如果加速器文本包含前缀 Ctrl+
、Alt+
或 Shift+
,我们将 FCONTROL
、FALT
或 FSHIFT
遮罩到 fVirt
字段,并移除前缀:
{ String controlText = TEXT("Ctrl+");
int controlIndex = acceleratorText.find(controlText);
if (controlIndex != -1) {
accelerator.fVirt |= FCONTROL;
acceleratorText.erase(controlIndex,
controlText.length());
}
}
{ String altText = TEXT("Alt+");
int altIndex = acceleratorText.find(altText);
if (altIndex != -1) {
accelerator.fVirt |= FALT;
acceleratorText.erase(altIndex, altText.length());
}
}
{ String shiftText = TEXT("Shift+");
int shiftIndex = acceleratorText.find(shiftText);
if (shiftIndex != -1) {
accelerator.fVirt |= FSHIFT;
acceleratorText.erase(shiftIndex, shiftText.length());
}
}
在移除 Ctrl+
、Shift+
和 Alt+
前缀后,我们查看加速器文本的剩余部分。如果只有一个字符(长度为 1),我们将其保存到 key
字段。但是,我们不保存 ASCII 编号。相反,我们保存字母编号,对于 a
或 A
从 1 开始:
if (acceleratorText.length() == 1) {
accelerator.key =
(WORD) ((tolower(acceleratorText[0]) - ''a'') + 1);
}
如果加速器文本的剩余部分由多个字符组成,我们假设它是一个虚拟键,并调用 TextToVirtualKey
来查找它,并将 FVIRTKEY
常量屏蔽到 fVirt
字段:
else {
accelerator.fVirt |= FVIRTKEY;
accelerator.key = TextToVirtualKey(acceleratorText);
}
如果 fVirt
仍然为零,则加速器不包含 Ctrl+
、Shift+
、Alt+
或虚拟键,这是不允许的:
assert(accelerator.fVirt != 0);
最后,我们将加速器添加到加速器集合中:
acceleratorSet.push_back(accelerator);
}
注意,如果文本不包含制表符,则不会向加速器集合中添加任何加速器:
}
};
StandardDocument
类
StandardDocument
类是 Document
的直接子类;它处理 File、Edit 和 Help 菜单,并实现文件处理、剪切、复制和粘贴、拖放文件和打印。此类没有特定的消息函数;所有消息都发送到之前覆盖的 Document
部分的 DocumentProc
。文档名称和脏标志由框架自动更新。StandardDocument
还处理页面设置对话框,这在 第十二章 辅助类 中有更详细的描述。
StandardDocument.h
namespace SmallWindows {
class StandardDocument : public Document {
public:
大多数构造函数参数都发送到 Document
构造函数。对于 StandardDocument
来说,特定的是文件描述文本和复制粘贴格式列表。文件描述由标准保存和打开对话框使用。复制粘贴列表用于在应用程序和全局剪贴板之间复制粘贴信息:
StandardDocument(CoordinateSystem system, Size pageSize,
String fileDescriptionsText,
Window* parentPtr=nullptr,
WindowStyle style = OverlappedWindow,
WindowShow windowShow = Normal,
initializer_list<unsigned int>
copyFormatList = {},
initializer_list<unsigned int>
pasteFormatList = {},
bool acceptDropFiles = true,
Size lineSize = LineSize);
private:
void InitializeFileFilter(String fileDescription);
StandardFileMenu
、StandardEditMenu
和 StandardHelpMenu
方法创建并返回标准菜单。如果 StandardFileMenu
中的 print
为 true
,则包括 Page Setup、Print 和 Print Preview 菜单项:
protected:
Menu StandardFileMenu(bool print);
Menu StandardEditMenu();
Menu StandardHelpMenu();
当文档不需要保存时(脏标志为 false
),Save 菜单项被禁用。在 Save 菜单项可见之前调用 SaveEnable
方法,如果脏标志为 true
,则启用它。
private:
DEFINE_VOID_LISTENER(StandardDocument, OnNew);
DEFINE_VOID_LISTENER(StandardDocument, OnOpen);
DEFINE_BOOL_LISTENER(StandardDocument, SaveEnable);
DEFINE_VOID_LISTENER(StandardDocument, OnSave);
DEFINE_VOID_LISTENER(StandardDocument, OnSaveAs);
OnSave
方法根据文档是否已命名调用 SaveFileWithName
或 SaveFileWidhoutName
。然而,OnSaveAs
总是调用 SaveFileWithoutName
,无论文档是否有名称。
private:
void SaveFileWithName(String name);
void SaveFileWithoutName();
当用户选择 New、Save、Save As 或 Open 菜单项时,会调用 ClearDocument
、WriteDocumentToStream
和 ReadDocumentFromStream
方法,这些方法旨在由子类覆盖以清除、写入和读取文档:
protected:
void ClearPageSetupInfo();
bool ReadPageSetupInfoFromStream(istream &inStream);
bool WritePageSetupInfoToStream(ostream &outStream) const;
virtual void ClearDocument() {/* Empty. */}
virtual bool WriteDocumentToStream(String name,
ostream& outStream) const {return true;}
virtual bool ReadDocumentFromStream(String name,
istream& inStream) {return true;}
当用户在 Edit 菜单中选择相应的菜单项时,会调用 OnCut
、OnCopy
、OnPaste
和 OnDelete
方法。OnCut
的默认行为是先调用 OnCopy
,然后调用 OnDelete
:
DEFINE_VOID_LISTENER(StandardDocument, OnCut);
DEFINE_VOID_LISTENER(StandardDocument, OnCopy);
DEFINE_VOID_LISTENER(StandardDocument, OnPaste);
DEFINE_VOID_LISTENER(StandardDocument, OnDelete);
CutEnable
、CopyEnable
、PasteEnable
和 DeleteEnable
方法是监听器,用于决定菜单项是否启用。CutEnable
和 DeleteEnable
的默认行为是调用 CopyEnable
:
DEFINE_BOOL_LISTENER(StandardDocument, CutEnable);
DEFINE_BOOL_LISTENER(StandardDocument, CopyEnable);
DEFINE_BOOL_LISTENER(StandardDocument, PasteEnable);
DEFINE_BOOL_LISTENER(StandardDocument, DeleteEnable);
IsCopyAsciiReady
、IsCopyUnicodeReady
和 IsCopyGenericReady
方法由 CopyEnable
调用。它们旨在被重写,并在应用程序准备好以 ASCII、Unicode 或通用格式进行复制时返回 true
。它们的默认行为是返回 false
:
virtual bool IsCopyAsciiReady() const {return false;}
virtual bool IsCopyUnicodeReady() const {return false;}
virtual bool IsCopyGenericReady(int format)
const {return false;}
当用户选择 复制 菜单项时,OnCopy
会调用 CopyAscii
、CopyUnicode
和 CopyGeneric
方法。它们旨在被子类重写,并按照构造函数中的复制格式列表和复制就绪方法进行调用:
virtual void CopyAscii(vector<String>& textList) const
{/* Empty. */}
virtual void CopyUnicode(vector<String>& textList) const
{/* Empty. */}
virtual void CopyGeneric(int format, InfoList& infoList)
const {/* Empty. */}
IsPasteAsciiReady
、IsPasteUnicodeReady
和 IsPasteGenericReady
方法由 PasteEnable
调用,如果至少有一个方法返回 true
,则返回 true
。它们旨在被重写,并在应用程序准备好以 ASCII、Unicode 或通用格式粘贴时返回 true
。它们的默认行为是返回 true
:
virtual bool IsPasteAsciiReady
(const vector<String>&textList) const {return true;}
virtual bool IsPasteUnicodeReady
(const vector<String>&textList) const {return true;}
virtual bool IsPasteGenericReady(int format,
InfoList& infoList) const {return true;}
当用户选择 粘贴 菜单项时,OnPaste
会调用 PasteAscii
、PasteUnicode
和 PasteGeneric
方法。它们旨在被子类重写,并按照构造函数中的粘贴格式列表和粘贴就绪方法进行调用。复制和粘贴之间有一个区别,即复制在所有可用格式中执行,而粘贴仅在第一个可用格式中执行:
virtual void PasteAscii(const vector<String>& textList)
{/* Empty. */}
virtual void PasteUnicode(const vector<String>& textList)
{/* Empty. */}
virtual void PasteGeneric(int format, InfoList& infoList)
{/* Empty. */}
当用户在窗口的客户区域中拖放一组文件时,会调用 OnDropFile
方法。如果路径列表中恰好有一个文件具有构造函数中给出的后缀,则该文件将以与用户在标准打开对话框中选择它相同的方式读取。然而,如果没有文件或列表中有多个具有后缀的文件,则显示错误消息:
void OnDropFile(vector<String> pathList);
PageOuterSize
方法根据页面设置设置返回页面在纵向或横向模式下的逻辑大小,不考虑边距,而 PageInnerSize
、PageInnerWidth
和 PageInnerHeight
返回减去边距后的页面大小:
private:
Size PageOuterSize() const;
Size PageInnerSize() const;
protected:
int PageInnerWidth() const{return PageInnerSize().Width();}
int PageInnerHeight()const{return PageInnerSize().Height();}
当用户选择 页面设置、打印 和 打印预览 菜单项时,会调用 OnPageSetup
、OnPrintPreview
和 OnPrintItem
方法。它们显示 页面设置对话框、打印预览窗口 和 打印对话框:
public:
DEFINE_VOID_LISTENER(StandardDocument, OnPageSetup);
DEFINE_VOID_LISTENER(StandardDocument, OnPrintPreview);
DEFINE_VOID_LISTENER(StandardDocument, OnPrintItem);
PrintPage
方法由 OnPrintItem
调用,并打印文档的一页:
bool PrintPage(Graphics* graphicsPtr, int page,
int copy, int totalPages);
当用户选择 页面设置 菜单项并更改页面设置信息时,会调用 OnPageSetup
方法来通知应用程序。它旨在被子类重写,并且其默认行为是不执行任何操作:
virtual void OnPageSetup(PageSetupInfo info) {/* Empty. */}
GetTotalPages
方法返回要打印的页数;默认值为 1。它旨在被子类重写:
virtual int GetTotalPages() const {return 1;}
OnPrint
方法由OnPrintItem
对每一页和副本调用一次。它的默认行为是按照页面设置对话框中的设置写入页眉和页脚,然后调用OnDraw
以显示文档的应用程序特定内容:
virtual void OnPrint(Graphics& graphics, int page,
int copy, int totalPages) const;
当用户选择退出菜单项并退出应用程序时,会调用OnExit
方法,如果TryClose
返回true
,则应用程序会退出。如果脏标志为true
,TryClose
会显示一个消息框,询问用户是否允许关闭窗口:
DEFINE_VOID_LISTENER(StandardDocument, OnExit);
virtual bool TryClose();
OnAbout
方法显示一个包含应用程序名称的简单消息框:
DEFINE_VOID_LISTENER(StandardDocument, OnAbout);
文件字段由打开和保存标准对话框使用,而fileSuffixList
用于检查拖放文件的文件后缀:
private:
TCHAR fileFilter[MAX_PATH];
vector<String> fileSuffixList;
当用户选择页面设置菜单项时,会使用pageSetupInfo
字段。它存储有关页眉和页脚文本和字体、页面方向(纵向或横向)、页边距以及页面是否被框架包围的信息。请参阅下一章以获取更详细的描述。
PageSetupInfo pageSetupInfo;
copyFormatList
和pasteFormatList
字段包含可用于剪切、复制和粘贴的格式:
list<unsigned int> copyFormatList, pasteFormatList;
};
};
初始化
第一个StandardDocument
构造函数接受一组大量的参数。坐标系、页面大小、父窗口、样式、外观、文档是否接受拖放文件,以及行大小参数与之前覆盖的Document
案例相同。
剩余的是文件描述文本,打印菜单是否存在,以及复制和粘贴的格式列表。描述文本包含一个分号分隔的文件描述和允许文件的文件后缀列表,例如,Calc Files,clc;Text Files,txt。复制和粘贴的格式列表包含复制和粘贴信息的允许格式。
StandardDocument.cpp
#include "SmallWindows.h"
大多数构造函数参数都发送到Document
构造函数。然而,复制和粘贴的格式列表存储在copyFormatList
和pasteFormatList
中。文件过滤器由InitializeFileFilter
初始化:
namespace SmallWindows {
StandardDocument::StandardDocument(CoordinateSystem system,
Size pageSize,
String fileDescriptionsText,
Window* parentPtr /* = nullptr */,
WindowStyle style/* = OverlappedWindow */,
WindowShow windowShow /* = Normal */,
initializer_list<unsigned int>
copyFormatList /* = {} */,
initializer_list<unsigned int>
pasteFormatList /* = {}*/,
bool acceptDropFiles /* = true */,
Size lineSize /* = LineSize */)
:Document(TEXT("standarddocument"), system, pageSize,
parentPtr, style, windowShow,
acceptDropFiles, lineSize),
copyFormatList(copyFormatList),
pasteFormatList(pasteFormatList) {
InitializeFileFilter(fileDescriptionsText);
在Window
中,我们使用页面大小在逻辑单位和物理单位之间进行转换。在Document
中,我们使用它来设置滚动页面大小。然而,在StandardDocument
中,实际上有两种页面大小:外页大小和内页大小。外页大小是不考虑文档边距的页面大小。内页大小是通过从外页大小中减去边距得到的。在StandardDocument
中,我们使用内页大小来设置滚动条的大小:
SetHorizontalScrollTotalWidth(PageInnerWidth());
SetVerticalScrollTotalHeight(PageInnerHeight());
}
标准菜单
以下代码展示了这一点:
void StandardDocument::InitializeFileFilter(String fileListText)
{ OStringStream filterStream;
vector<String> fileList = Split(fileListText, TEXT('';''));
assert(fileList.size() > 0);
for (String fileText : fileList) {
vector<String> partList = Split(fileText, TEXT('',''));
assert(partList.size() == 2);
String description = Trim(partList[0]),
suffix = Trim(partList[1]);
fileSuffixList.push_back(suffix);
filterStream << description << TEXT(" (*.") << suffix
<< TEXT(")\n") << TEXT("*.") << suffix
<< TEXT("\n");
}
filterStream << TEXT("\n");
int index = 0;
for (TCHAR c : filterStream.str()) {
fileFilter[index++] = (c == TEXT(''\n'')) ? TEXT(''\0'') : c;
}
}
标准的文件菜单包含新建、打开、保存、另存为和退出菜单项,以及(如果print
为true
)页面设置、打印预览和打印菜单项:
Menu StandardDocument::StandardFileMenu(bool print) {
Menu fileMenu(this, TEXT("&File"));
fileMenu.AddItem(TEXT("&New\tCtrl+N"), OnNew);
fileMenu.AddItem(TEXT("&Open\tCtrl+O"), OnOpen);
fileMenu.AddItem(TEXT("&Save\tCtrl+S"), OnSave, SaveEnable);
fileMenu.AddItem(TEXT("Save &As\tCtrl+Shift+S"), OnSaveAs);
if (print) {
fileMenu.AddSeparator();
fileMenu.AddItem(TEXT("Page Set&up"), OnPageSetup);
fileMenu.AddItem(TEXT("Print Pre&view"), OnPrintPreview);
fileMenu.AddItem(TEXT("&Print\tCtrl+P"), OnPrintItem);
}
fileMenu.AddSeparator();
fileMenu.AddItem(TEXT("E&xit\tAlt+X"), OnExit);
return fileMenu;
}
标准的编辑菜单包含剪切、复制、粘贴和删除菜单项:
Menu StandardDocument::StandardEditMenu() {
Menu editMenu(this, TEXT("&Edit"));
editMenu.AddItem(TEXT("C&ut\tCtrl+X"), OnCut, CutEnable);
editMenu.AddItem(TEXT("&Copy\tCtrl+C"), OnCopy, CopyEnable);
editMenu.AddItem(TEXT("&Paste\tCtrl+V"), OnPaste,PasteEnable);
editMenu.AddSeparator();
editMenu.AddItem(TEXT("&Delete\tDelete"),
OnDelete, DeleteEnable);
return editMenu;
}
标准的帮助菜单包含使用应用程序名称的关于菜单项:
Menu StandardDocument::StandardHelpMenu() {
Menu helpMenu(this, TEXT("&Help"));
helpMenu.AddItem(TEXT("About ") +
Application::ApplicationName() +
TEXT(" ..."), OnAbout);
return helpMenu;
}
文件管理
当用户尝试关闭窗口时,TryClose
方法检查脏标志是否为true
。如果是true
,则询问用户在关闭前是否要保存文档。如果他们回答是,则像用户选择了保存菜单项一样保存文档。如果之后脏标志设置为false
,则表示保存操作成功,并返回true
。如果用户回答否,则返回true
并关闭窗口而不保存。如果答案是取消,则返回false
并中止关闭操作:
bool StandardDocument::TryClose() {
if (IsDirty()) {
switch (MessageBox(TEXT("Do you want to save?"),
TEXT("Unsaved Document"), YesNoCancel)) {
case Yes:
OnSave();
return !IsDirty();
case No:
return true;
case Cancel:
return false;
}
}
return true;
}
OnExit
方法调用TryClose
并删除应用程序的主窗口,如果TryClose
返回true
,则最终向消息循环发送退出消息以终止应用程序:
void StandardDocument::OnExit() {
if (TryClose()) {
delete Application::MainWindowPtr();
}
}
当用户选择新建菜单项时,会调用OnNew
方法。它尝试通过调用TryClose
来关闭窗口。如果TryClose
返回true
,则清除文档、脏标志和名称,并使窗口无效并更新。ClearDocument
方法被缩进以供子类覆盖,以清除文档的应用程序特定内容:
void StandardDocument::OnNew() {
if (TryClose()) {
ClearDocument();
ClearPageSetupInfo();
SetZoom(1.0);
SetDirty(false);
SetName(TEXT(""));
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
当用户选择打开菜单项时,会调用OnOpen
方法。它尝试通过调用TryClose
来关闭窗口,并在成功的情况下显示标准打开对话框以建立文件的路径。如果OpenDialog
返回true
且输入流有效,则读取页面设置信息,并调用ClearDocument
和ReadDocumentFromStream
方法,这些方法旨在由子类覆盖:
void StandardDocument::OnOpen() {
if (TryClose()) {
String name = GetName();
if (StandardDialog::OpenDialog(this, name, fileFilter,
fileSuffixList)) {
ClearDocument();
Invalidate();
UpdateWindow();
ifstream inStream(name.c_str());
if (inStream && ReadDocumentFromStream(name, inStream)) {
SetName(name);
}
else {
MessageBox(TEXT("Could not open ") +
name + TEXT("."));
}
}
}
SetDirty(false);
SetZoom(1.0);
Invalidate();
UpdateWindow();
UpdateCaret();
}
如果脏标志为true
,则保存菜单项被启用:
bool StandardDocument::SaveEnable() const {
return IsDirty();
}
保存文件时,如果文件已有名称,则调用SaveFileWithName
。如果文件尚未命名,则调用SaveFileWithoutName
代替:
void StandardDocument::OnSave() {
String name = GetName();
if (!name.empty()) {
SaveFileWithName(name);
}
else {
SaveFileWithoutName();
}
}
当用户选择另存为时,无论文档是否有名称,都会调用SaveFileWithoutName
并显示保存标准对话框:
void StandardDocument::OnSaveAs() {
SaveFileWithoutName();
}
SaveFileWithoutName
方法显示保存对话框。如果用户按下确定按钮,则SaveDialog
调用返回true
,设置新名称,并调用SaveFileWithName
以执行文档文件的实际写入:
void StandardDocument::SaveFileWithoutName() {
String name = GetName();
if (StandardDialog::SaveDialog(this, name, fileFilter,
fileSuffixList)) {
SaveFileWithName(name);
}
}
SaveFileWithName
方法尝试打开文档文件进行写入,并调用WriteDocumentToStream
,该方法旨在由子类覆盖以执行文档内容的实际写入。如果页面设置信息和文档内容写入成功,则清除脏标志:
void StandardDocument::SaveFileWithName(String name) {
ofstream outStream(name.c_str());
if (outStream && WriteDocumentToStream(name, outStream)) {
SetName(name);
SetDirty(false);
SetZoom(1.0);
}
}
void StandardDocument::ClearPageSetupInfo() {
pageSetupInfo.ClearPageSetupInfo();
}
bool StandardDocument::ReadPageSetupInfoFromStream
(istream &inStream) {
pageSetupInfo.ReadPageSetupInfoFromStream(inStream);
return ((bool) inStream);
}
bool StandardDocument::WritePageSetupInfoToStream
(ostream &outStream) const {
pageSetupInfo.WritePageSetupInfoToStream(outStream);
return ((bool) outStream);
}
当用户在帮助标准菜单中选择关于菜单项时,会显示一个包含应用程序名称的消息框:
void StandardDocument::OnAbout() {
String applicationName = Application::ApplicationName();
MessageBox(applicationName + TEXT(", version 1.0"),
applicationName, Ok, Information);
}
剪切、复制和粘贴
CutEnable
和DeleteEnable
的默认行为是简单地调用CopyEnable
,因为它们很可能在相同的条件下被启用:
bool StandardDocument::CutEnable() const {
return CopyEnable();
}
bool StandardDocument::DeleteEnable() const {
return CopyEnable();
}
OnCut
的默认行为是简单地调用OnCopy
和OnDelete
,这是剪切的常见操作:
void StandardDocument::OnCut() {
OnCopy();
OnDelete();
}
OnDelete
方法为空,并打算由子类覆盖:
void StandardDocument::OnDelete() {
// Empty.
}
CopyEnable
方法遍历粘贴格式列表,并根据格式调用IsCopyAsciiReady
、IsCopyUnicodeReady
或IsCopyGenericReady
。一旦其中一个方法返回true
,CopyEnable
就返回true
,这意味着只要允许其中一个格式的复制就足够了。当实际复制在OnCopy
中发生时,准备好的方法会被再次调用:
bool StandardDocument::CopyEnable() const {
for (unsigned int format : pasteFormatList) {
switch (format) {
case AsciiFormat:
if (IsCopyAsciiReady()) {
return true;
}
break;
case UnicodeFormat:
if (IsCopyUnicodeReady()) {
return true;
}
break;
default:
if (IsCopyGenericReady(format)) {
return true;
}
break;
}
}
return false;
}
OnCopy
方法遍历构造函数中给出的复制格式列表,并根据格式调用适当的方法:
void StandardDocument::OnCopy() {
if (Clipboard::Open(this)) {
Clipboard::Clear();
for (unsigned int format : copyFormatList) {
switch (format) {
如果应用了 ASCII 格式,并且IsCopyAsciiReady
返回true
,则调用CopyAscii
,该函数的目的是由子类覆盖以填充asciiList
中的 ASCII 文本。当列表被复制后,它会被传递给Clipboard
中的WriteAscii
,该函数将文本存储在全局剪贴板中:
case AsciiFormat:
if (IsCopyAsciiReady()) {
vector<String> asciiList;
CopyAscii(asciiList);
Clipboard::WriteText<AsciiFormat,char>(asciiList);
}
break;
如果应用了 Unicode 格式,并且IsCopyUnicodeReady
返回true
,则调用CopyUnicode
,该函数的目的是由子类覆盖以填充unicodeList
中的 Unicode 文本。当列表被复制后,它会被传递给Clipboard
中的WriteUnicode
,该函数将文本存储在全局剪贴板中:
case UnicodeFormat:
if (IsCopyUnicodeReady()) {
vector<String> unicodeList;
CopyUnicode(unicodeList);
Clipboard::WriteText<UnicodeFormat,wchar_t>
(unicodeList);
}
break;
如果既不应用 ASCII 也不应用 Unicode,并且IsCopyGenericReady
返回true
,则调用CopyGeneric
,该函数的目的是由子类覆盖以填充字符列表中的通用信息。在 C++中,char
类型始终占用一个字节;因此,在没有更通用的字节类型的情况下使用。当信息被复制到infoList
后,它会被传递给Clipboard
中的WriteGeneric
以在全局剪贴板上存储信息:
default:
if (IsCopyGenericReady(format)) {
InfoList infoList;
CopyGeneric(format, infoList);
Clipboard::WriteGeneric(format, infoList);
}
break;
}
}
Clipboard::Close();
}
}
PasteEnable
方法遍历构造函数中给出的粘贴格式列表,如果至少有一个格式在全局剪贴板上可用,则返回true
:
bool StandardDocument::PasteEnable() const {
if (Clipboard::Open(this)) {
for (unsigned int format : pasteFormatList) {
if (Clipboard::Available(format)) {
switch (format) {
case AsciiFormat: {
vector<String> asciiList;
if (Clipboard::ReadText<AsciiFormat,char>
(asciiList) &&
IsPasteAsciiReady(asciiList)) {
Clipboard::Close();
return true;
}
}
break;
case UnicodeFormat: {
vector<String> unicodeList;
if (Clipboard::ReadText<UnicodeFormat,wchar_t>
(unicodeList) &&
IsPasteUnicodeReady(unicodeList)) {
Clipboard::Close();
return true;
}
}
break;
default: {
InfoList infoList;
if (Clipboard::ReadGeneric(format, infoList) &&
IsPasteGenericReady(format, infoList)) {
Clipboard::Close();
return true;
}
}
}
}
}
Clipboard::Close();
}
return false;
}
OnPaste
方法遍历构造函数中给出的粘贴格式列表,并对每个格式检查它是否在全局剪贴板上可用。如果是,则调用适当的方法。请注意,虽然OnCopy
遍历整个复制格式列表,但OnPaste
在剪贴板上的第一个可用格式后就会退出,这使得粘贴格式列表的顺序变得重要:
void StandardDocument::OnPaste() {
if (Clipboard::Open(this)) {
for (unsigned int format : pasteFormatList) {
bool quit = false;
if (Clipboard::Available(format)) {
switch (format) {
在 ASCII 格式的情况下,Clipboard
中的ReadAscii
被调用,它从全局剪贴板读取文本列表,如果IsPasteAsciiReady
返回true
,则调用PasteAscii
,该函数的目的是由子类覆盖以执行实际的应用特定粘贴:
case AsciiFormat: {
vector<String> asciiList;
if (Clipboard::ReadText<AsciiFormat,char>
(asciiList) &&
IsPasteAsciiReady(asciiList)) {
PasteAscii(asciiList);
quit = true;
}
}
break;
在 Unicode 格式的情况下,Clipboard
中的ReadUnicode
被调用,它从全局剪贴板读取文本列表,如果IsPasteUnicodeReady
返回true
,则调用PasteUnicode
,该函数的目的是由子类覆盖以执行实际的应用特定粘贴:
case UnicodeFormat: {
vector<String> unicodeList;
if (Clipboard::ReadText<UnicodeFormat,wchar_t>
(unicodeList) &&
IsPasteUnicodeReady(unicodeList)) {
PasteUnicode(unicodeList);
quit = true;
}
}
break;
如果既不适用 ASCII 也不适用 Unicode,则在Clipboard
中调用ReadGeneric
以从全局剪贴板读取通用信息,如果IsPasteGenericReady
返回true
,则调用PasteGeneric
,该函数旨在由子类覆盖以执行实际的粘贴操作。
在通用情况下,复制和粘贴之间的一个区别是OnCopy
使用字符列表,因为它事先不知道大小(如果我们使用内存块,我们需要两个方法:一个计算块的大小,另一个执行实际的读取,这将很麻烦),而OnPaste
使用内存块,由于我们不知道大小,因此不能转换为字符列表。只有文档特定的覆盖版本PasteGeneric
可以决定内存块的大小:
default: {
InfoList infoList;
if (Clipboard::ReadGeneric(format, infoList) &&
IsPasteGenericReady(format, infoList)) {
PasteGeneric(format, infoList);
quit = true;
}
}
break;
}
if (quit) {
break;
}
}
}
Clipboard::Close();
}
}
丢弃文件
当用户在窗口的客户区域拖放一个或多个文件时,我们会检查每个文件名的文件后缀。如果我们找到恰好有一个文件具有文档的文件后缀之一(fileSuffixList
字段),我们将以与用户使用标准打开对话框打开它相同的方式打开它:
void StandardDocument::OnDropFile(vector<String> pathList) {
set<String> pathSet;
我们遍历路径列表,并将具有文件后缀的每个路径添加到pathSet
:
for (String path : pathList) {
for (String suffix : fileSuffixList) {
if (EndsWith(path, TEXT(".") + suffix)) {
pathSet.insert(path);
break;
}
}
}
如果pathSet
为空,则没有带有文件后缀的文件被丢弃。
if (pathSet.empty()) {
MessageBox(TEXT("No suitable dropped file."),
TEXT("Drop File"), Ok, Stop);
}
如果pathSet
包含多个文件,则丢弃了太多带有文件后缀的文件:
else if (pathSet.size() > 1) {
MessageBox(TEXT("To many suitable dropped files."),
TEXT("Drop File"), Ok, Stop);
}
如果pathSet
恰好包含一个文件,它将以与用户选择打开菜单项相同的方式读取:
else {
String path = *pathSet.begin();
if (TryClose()) {
ClearDocument();
ReadDocumentFromStream(path, ifstream(path));
SetName(path);
SetDirty(false);
SetZoom(1.0);
Invalidate();
UpdateWindow();
UpdateCaret();
}
}
}
页面大小
PageOuterSize
方法返回不考虑边距的页面大小。根据页面设置对话框中的方向,有两种页面大小。构造函数中给出的页面大小指的是Portrait
方向。在Landscape
方向的情况下,页面的宽度和高度会互换:
Size StandardDocument::PageOuterSize() const {
if (pageSetupInfo.GetOrientation() == Landscape) {
return Size(pageSize.Height(), pageSize.Width());
}
return pageSize;
}
PageInnerSize
方法返回考虑边距的页面大小。宽度减去左和右边距。高度减去上和下边距。记住,边距是以毫米给出的,逻辑单位是毫米的百分之一。因此,我们将边距乘以 100:
Size StandardDocument::PageInnerSize() const {
Size outerSize = PageOuterSize();
int innerWidth = outerSize.Width() -
(100 * (pageSetupInfo.LeftMargin() +
pageSetupInfo.RightMargin())),
innerHeight = outerSize.Height() -
(100 * (pageSetupInfo.TopMargin() +
pageSetupInfo.BottomMargin()));
return Size(innerWidth, innerHeight);
}
PageInnerWidth
和PageInnerHeight
方法返回减去边距后的文档宽度和高度。由于边距是以毫米给出的,而一毫米等于一百逻辑单位,因此我们将边距乘以 100 以获得逻辑单位:
int StandardDocument::PageInnerWidth() const {
return PageOuterSize().Width() -
(100 * (pageSetupInfo.LeftMargin() +
pageSetupInfo.RightMargin()));
}
int StandardDocument::PageInnerHeight() const {
return PageOuterSize().Height() -
(100 * (pageSetupInfo.TopMargin() +
pageSetupInfo.BottomMargin()));
}
页面设置
当用户选择页面设置菜单项时,会调用OnPageSetup
方法。它显示页面设置对话框(参考第十二章,辅助类)并调用OnPageSetup
,该函数旨在由子类覆盖,以通知应用程序页面设置信息已更改:
void StandardDocument::OnPageSetup() {
PageSetupDialog pageSetupDialog(this, &pageSetupInfo);
if (pageSetupDialog.DoModal()) {
OnPageSetup(pageSetupInfo);
}
}
打印
当用户选择 打印预览 菜单项时,会调用 OnPrintPreview
方法。它显示打印预览文档,这在 第十二章 中有更详细的描述,辅助类。GetTotalPages
方法返回文档中的当前页数:
void StandardDocument::OnPrintPreview() {
new PrintPreviewDocument(this, GetTotalPages());
}
当用户选择 打印 菜单项时,会调用 OnPrintItem
方法。它显示标准的 打印 对话框,并根据用户在对话框中指定的页面间隔、顺序和副本数量打印文档的页面:
该方法被命名为 OnPrintItem
,这样就不会与 Window
中的 OnPrint
混淆,后者在窗口接收到 WM_PAINT
消息时被调用。然而,这两个方法本可以都命名为 OnPrint
,因为它们有不同的参数列表:
void StandardDocument::OnPrintItem() {
int totalPages = GetTotalPages(), firstPage, lastPage, copies;
bool sorted;
PrintDialog
方法创建并返回一个指向 Graphics
对象的指针,如果用户按下 确定 按钮,或者如果用户按下 取消 按钮,则返回一个空指针。totalPages
参数指示用户可以选择的最后一个可能的页面(第一个可能的页面是 1)。在按下 确定 按钮的情况下,firstPage
、lastPage
、copies
和 sorted
被初始化:firstPage
和 lastPage
是要打印的页面间隔,copies
是要打印的副本数,而 sorted
表示(如果多于一个)副本是否将被排序:
Graphics* graphicsPtr =
StandardDialog::PrintDialog(this, totalPages, firstPage,
lastPage, copies, sorted);
Win32 API 函数 StartDoc
初始化打印过程。它通过 Graphics
对象获取连接到打印机的设备上下文,以及一个 DOCINFO
结构,该结构只需要初始化文档名称。如果 StartDoc
返回一个大于零的值,我们就可以打印页面。在打印过程中,我们准备设备上下文并禁用窗口:
if (graphicsPtr != nullptr) {
static DOCINFO docInfo;
docInfo.cbSize = sizeof docInfo;
docInfo.lpszDocName = GetName().c_str();
if (::StartDoc(graphicsPtr->GetDeviceContextHandle(),
&docInfo) > 0) {
PrepareDeviceContext
(graphicsPtr->GetDeviceContextHandle());
EnableWindow(false);
如果 sorted
为 true
,则页面按排序顺序打印。例如,假设 firstPage
设置为 1,lastPage
设置为 3,copies
设置为 2。如果 sorted
为 true
,则页面按顺序 1, 2, 3, 1, 2, 3 打印。如果 sorted
为 false
,则按顺序 1, 1, 2, 2, 3, 3 打印。PrintPage
对每个页面进行调用,并且只要它返回 true
,打印就会继续;printOk
跟踪循环是否继续:
if (sorted) {
bool printOk = true;
for (int copy = 1; (copy <= copies) && printOk; ++copy){
for (int page = firstPage;
(page <= lastPage) && printOk; ++page){
printOk = PrintPage(graphicsPtr, page,
copy, totalPages);
}
}
}
else {
bool printOk = true;
for (int page = firstPage;
(page <= lastPage) && printOk; ++page) {
for (int copy = 1; (copy <= copies) && printOk;
++copy) {
printOk = PrintPage(graphicsPtr, page,
copy, totalPages);
}
}
}
Win32 API 函数 EndDoc
用于完成打印:
::EndDoc(graphicsPtr->GetDeviceContextHandle());
}
}
}
在打印页面前后,PrintPage
方法调用 Win32 API 函数 StartPage
和 EndPage
。如果它们都返回大于零的值,则表示打印成功,返回 true
,并且可以打印更多页面。调用 OnPrint
(从 Window
中重写)来进行实际打印,page
和 copy
是当前页和副本,totalPages
是文档中的页数:
bool StandardDocument::PrintPage(Graphics* graphicsPtr,
int page, int copy, int totalPages){
if (::StartPage(graphicsPtr->GetDeviceContextHandle()) > 0) {
OnPrint(*graphicsPtr, page, copy, totalPages);
return (::EndPage(graphicsPtr->GetDeviceContextHandle())>0);
}
return false;
}
OnPrint
方法通过调用pageSetupInfo
字段打印提供的信息。然后,通过调用OnDraw
裁剪并绘制文档内容,如果存在,则绘制包围文档内容的框架:
void StandardDocument::OnPrint(Graphics& graphics, int page,
int copy, int totalPages) const {
通过绘制白色来清除文档。
graphics.FillRectangle(Rect(0, 0, PageOuterSize().Width(),
PageOuterSize().Height()), White, White);
int left = 100 * pageSetupInfo.LeftMargin(),
top = 100 * pageSetupInfo.TopMargin();
int right = left + PageInnerWidth(),
bottom = top + PageInnerHeight();
如果当前页面是第一页,除非为空,否则会写入页眉文本:
if (!pageSetupInfo.HeaderText().empty() &&
!((page == 1) && (!pageSetupInfo.HeaderFirst()))) {
Rect headerRect(left, 0, right, top);
String headerText =
Template(this, pageSetupInfo.HeaderText(),
copy, page, totalPages);
Color textColor = pageSetupInfo.HeaderFont().FontColor();
Color backColor = textColor.Inverse();
graphics.DrawText(headerRect, headerText,
pageSetupInfo.HeaderFont(), textColor, backColor);
}
与页眉文本类似,除非为空,否则会写入页脚文本;如果当前页面是第一页,则不会写入:
if (!pageSetupInfo.FooterText().empty() &&
!((page == 1) && (!pageSetupInfo.HeaderFirst()))) {
Rect footerRect(left, bottom, right,
PageOuterSize().Height());
String footerText =
Template(this, pageSetupInfo.FooterText(),
copy, page, totalPages);
Color textColor = pageSetupInfo.FooterFont().FontColor();
Color backColor = textColor.Inverse();
graphics.DrawText(footerRect, footerText,
pageSetupInfo.FooterFont(), textColor, backColor);
}
保存设备上下文当前状态,将原点设置为当前页面的左上角,裁剪当前页面的区域,调用OnDraw
以绘制当前页面,并最终恢复绘图区域:
int save = graphics.Save();
Point centerPoint(-left,
((page - 1) * PageInnerHeight()) - top);
graphics.SetOrigin(centerPoint);
Rect clipRect(0, (page - 1) * PageInnerHeight(),
PageInnerWidth(), page * PageInnerHeight());
graphics.IntersectClip(clipRect);
OnDraw(graphics, Print);
graphics.Restore(save);
最后,如果页面设置信息中的框架字段为true
,则页面被矩形包围:
if (pageSetupInfo.Frame()) {
graphics.DrawRectangle(Rect(left, top, right, bottom),
Black);
}
}
};
摘要
在本章中,我们学习了小窗口的文档类:Document
、Menu
、Accelerator
和StandardDocument
。在第十二章《辅助类》中,我们继续探讨小窗口的辅助类。
第十二章。辅助类
小型 Windows 包括一组辅助类,如下所示:
-
Size
、Point
、Rect
、Color
和Font
:这些封装了 Win32 API 结构SIZE
、POINT
、RECT
、COLORREF
和LOGFONT
。它们配备了与文件、剪贴板和注册表通信的方法。注册表是 Windows 系统中的一个数据库,我们可以用它来在应用程序执行之间存储值。 -
Cursor
:表示 Windows 光标。 -
DynamicList
:包含具有一组回调函数的动态大小列表。 -
Tree
:包含递归树结构。 -
InfoList
:包含可以转换到和从内存缓冲区转换的通用信息列表。 -
此外,还有一些字符串操作函数。
Size
类
Size
类是一个包含宽度和高度的简单类:
Size.h
namespace SmallWindows {
ZeroSize
对象是一个其宽度和高度设置为零的对象:
class Size;
extern const Size ZeroSize;
class Size {
public:
默认构造函数将宽度和高度初始化为零。大小可以通过初始化和赋值给另一个大小来初始化。Size
类使用赋值运算符将大小赋给另一个大小:
Size();
Size(int width, int height);
Size(const Size& size);
Size& operator=(const Size& size);
Size
对象可以被初始化并赋值为 Win32 API SIZE
结构体的值,并且Size
对象可以被转换为SIZE
:
Size(const SIZE& size);
Size& operator=(const SIZE& size);
operator SIZE() const;
比较两个大小时,首先比较宽度。如果它们相等,然后比较高度:
bool operator==(const Size& size) const;
bool operator!=(const Size& size) const;
bool operator<(const Size& size) const;
bool operator<=(const Size& size) const;
bool operator>(const Size& size) const;
bool operator>=(const Size& size) const;
friend Size Min(const Size& left, const Size& right);
friend Size Max(const Size& left, const Size& right);
乘法运算符将因子乘以宽度和高度。请注意,尽管因子是双精度浮点数,但得到的宽度和高度始终被四舍五入为整数:
Size operator*=(double factor);
friend Size operator*(const Size& size, double factor);
friend Size operator*(double factor, const Size& size);
也可以使用一对值来乘以大小,其中第一个值乘以宽度,第二个值乘以高度。此外,在这种情况下,得到的宽度和高度都是整数:
Size operator*=(pair<double,double> factorPair);
friend Size operator*(const Size& size,
pair<double,double> factorPair);
friend Size operator*(pair<double,double> factorPair,
const Size& size);
第一组加法运算符将距离加到宽度和高度上:
Size operator+=(int distance);
Size operator-=(int distance);
friend Size operator+(const Size& size, int distance);
friend Size operator-(const Size& size, int distance);
第二组加法运算符分别将宽度和高度相加和相减:
Size operator+=(const Size& size);
Size operator-=(const Size& size);
friend Size operator+(const Size& left, const Size& right);
friend Size operator-(const Size& left, const Size& right);
大小可以被写入到文件流、剪贴板和注册表中,也可以从这些地方读取:
bool WriteSizeToStream(ostream& outStream) const;
bool ReadSizeFromStream(istream& inStream);
void WriteSizeToClipboard(InfoList& infoList) const;
void ReadSizeFromClipboard(InfoList& infoList);
void WriteSizeToRegistry(String key) const;
void ReadSizeFromRegistry(String key,
Size defaultSize = ZeroSize);
宽度和高度通过常量方法进行检查,并通过非常量方法进行修改:
int Width() const {return width;}
int Height() const {return height;}
int& Width() {return width;}
int& Height() {return height;}
private:
int width, height;
};
};
Size
类的实现相当直接:
Size.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Size::Size()
:width(0),
height(0) {
// Empty.
}
Size::Size(int width, int height)
:width(width),
height(height) {
// Empty.
}
Size::Size(const Size& size)
:width(size.width),
height(size.height) {
// Empty.
}
Size& Size::operator=(const Size& size) {
if (this != &size) {
width = size.width;
height = size.height;
}
return *this;
}
Size::Size(const SIZE& size)
:width(size.cx),
height(size.cy) {
// Empty.
}
Size& Size::operator=(const SIZE& size) {
width = size.cx;
height = size.cy;
return *this;
}
Size::operator SIZE() const {
SIZE size = {width, height};
return size;
}
bool Size::operator==(const Size& size) const {
return (width == size.width) && (height == size.height);
}
bool Size::operator!=(const Size& size) const {
return !(*this == size);
}
如前所述,比较两个大小时,首先比较宽度。如果它们相等,然后比较高度:
bool Size::operator<(const Size& size) const {
return (width < size.width) ||
((width == size.width) && (height < size.height));
}
bool Size::operator<=(const Size& size) const {
return ((*this < size) || (*this == size));
}
bool Size::operator>(const Size& size) const {
return !(*this <= size);
}
bool Size::operator>=(const Size& size) const {
return !(*this < size);
}
注意,如果Min
和Max
返回的值相等,则返回右侧的值。我们可以让它返回左侧的值。然而,由于在这种情况下Size
对象持有的x
和y
值相同,并且方法返回的是对象而不是对象的引用,所以这并不重要。返回的是相同的值:
Size Min(const Size& left, const Size& right) {
return (left < right) ? left : right;
}
Size Max(const Size& left, const Size& right) {
return (left > right) ? left : right;
}
如前所述,得到的宽度和高度始终被四舍五入为整数,即使因子是双精度浮点数:
Size Size::operator*=(double factor) {
width = (int) (factor * width);
height = (int) (factor * height);
return *this;
}
Size operator*(const Size& size, double factor) {
return Size((int) (size.width * factor),
(int) (size.height * factor));
}
Size operator*(double factor, const Size& size) {
return Size((int) (factor * size.width),
(int) (factor * size.height));
}
Size Size::operator*=(pair<double,double> factorPair) {
width = (int) (factorPair.first * width);
height = (int) (factorPair.second * height);
return *this;
}
Size operator*(const Size& size,
pair<double,double> factorPair) {
return Size((int) (size.width * factorPair.first),
(int) (size.height * factorPair.second));
}
Size operator*(pair<double,double> factorPair,
const Size& size) {
return Size((int) (factorPair.first * size.width),
(int) (factorPair.second * size.height));
}
Size Size::operator+=(int distance) {
width += distance;
height += distance;
return *this;
}
Size Size::operator-=(int distance) {
width -= distance;
height -= distance;
return *this;
}
Size operator+(const Size& size, int distance) {
return Size(size.width + distance, size.height + distance);
}
Size operator-(const Size& size, int distance) {
return Size(size.width - distance, size.height - distance);
}
Size Size::operator+=(const Size& size) {
width += size.width;
height += size.height;
return *this;
}
Size Size::operator-=(const Size& size) {
width -= size.width;
height -= size.height;
return *this;
}
Size operator+(const Size& left, const Size& right) {
return Size(left.width + right.width,
right.height + right.height);
}
Size operator-(const Size& left, const Size& right) {
return Size(left.width - right.width,
right.height - right.height);
}
bool Size::WriteSizeToStream(ostream& outStream) const {
outStream.write((char*) &width, sizeof width);
outStream.write((char*) &height, sizeof height);
return ((bool) outStream);
}
bool Size::ReadSizeFromStream(istream& inStream) {
inStream.read((char*) &width, sizeof width);
inStream.read((char*) &height, sizeof height);
return ((bool) inStream);
}
void Size::WriteSizeToClipboard(InfoList& infoList) const {
infoList.AddValue<int>(width);
infoList.AddValue<int>(height);
}
void Size::ReadSizeFromClipboard(InfoList& infoList) {
infoList.GetValue<int>(width);
infoList.GetValue<int>(height);
}
当将大小写入注册表时,我们将大小转换为 SIZE
结构,并将其发送到 Registry
中的 WriteBuffer
:
void Size::WriteSizeToRegistry(String key) const {
SIZE sizeStruct = (SIZE) *this;
Registry::WriteBuffer(key, &sizeStruct, sizeof sizeStruct);
}
当从注册表中读取大小,我们将默认大小转换为 SIZE
结构,并将其发送到 Registry
中的 ReadBuffer
。然后,结果被转换回 Size
对象:
void Size::ReadSizeFromRegistry(String key,
Size defaultSize /*=ZeroSize*/){
SIZE sizeStruct, defaultSizeStruct = (SIZE) defaultSize;
Registry::ReadBuffer(key, &sizeStruct, sizeof sizeStruct,
&defaultSizeStruct);
*this = Size(sizeStruct);
}
const Size ZeroSize(0, 0);
};
点类
Point
类是一个小的类,包含二维点的 x 和 y 位置:
Point.h
namespace SmallWindows {
class Point {
public:
默认构造函数将 x 和 y 值初始化为零。点可以通过另一个点初始化和赋值:
Point();
Point(int x, int y);
Point(const Point& point);
与前面提到的 Size
类类似,Point
使用赋值运算符:
Point& operator=(const Point& point);
与前一部分中的 SIZE
类似,存在一个 POINT
Win32 API 结构。Point
对象可以通过 POINT
结构初始化和赋值,并且 Point
对象可以转换为 POINT
:
Point(const POINT& point);
Point& operator=(const POINT& point);
operator POINT() const;
比较两个点时,首先比较 x 值。如果它们相等,然后比较 y 值:
bool operator==(const Point& point) const;
bool operator!=(const Point& point) const;
bool operator<(const Point& point) const;
bool operator<=(const Point& point) const;
bool operator>(const Point& point) const;
bool operator>=(const Point& point) const;
friend Point Min(const Point& left, const Point& right);
friend Point Max(const Point& left, const Point& right);
与前面提到的 Size
类类似,点的 x 和 y 值可以乘以一个因子。请注意,尽管因子是一个双精度值,但生成的 x 和 y 值始终四舍五入为整数:
Point& operator*=(double factor);
friend Point operator*(const Point& point, double factor);
friend Point operator*(double factor, const Point& point);
还可以将点与一对值相乘,其中第一个值乘以 x 值,第二个值乘以 y 值。在这种情况下,生成的 x 和 y 值也是整数:
Point& operator*=(pair<double,double> factorPair);
friend Point operator*(const Point& point,
pair<double,double> factorPair);
friend Point operator*(pair<double,double> factorPair,
const Point& point);
第一组加法运算符将整数距离加到点的 x 和 y 值上:
Point& operator+=(const int distance);
Point& operator-=(const int distance);
friend Point operator+(const Point& left, int distance);
friend Point operator-(const Point& left, int distance);
第二组加法运算符将大小宽度和高度加到点的 x 和 y 值上:
Point& operator+=(const Size& size);
Point& operator-=(const Size& size);
friend Point operator+(const Point& point,const Size& size);
friend Point operator-(const Point& point,const Size& size);
第三组加法运算符将点的 x 和 y 值加和减去:
Point& operator+=(const Point& point);
Point& operator-=(const Point& point);
friend Point operator+(const Point&left, const Point&right);
friend Size operator-(const Point& left, const Point&right);
点可以写入、读取文件流、剪贴板和注册表:
bool WritePointToStream(ostream& outStream) const;
bool ReadPointFromStream(istream& inStream);
void WritePointToClipboard(InfoList& infoList) const;
void ReadPointFromClipboard(InfoList& infoList);
void WritePointToRegistry(String key) const;
void ReadPointFromRegistry(String key,
Point defaultPoint /* = ZeroPoint */);
点的 x 和 y 值由常量方法检查并由非常量方法修改:
int X() const {return x;}
int Y() const {return y;}
int& X() {return x;}
int& Y() {return y;}
private:
int x, y;
};
extern const Point ZeroPoint;
};
Point
类的实现也很直接:
Point.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Point::Point()
:x(0), y(0) {
// Empty.
}
Point::Point(int x, int y)
:x(x), y(y) {
// Empty.
}
Point::Point(const Point& point)
:x(point.x),
y(point.y) {
// Empty.
}
在赋值运算符中,一个好的习惯是验证我们不会分配相同的对象。然而,在这种情况下并不完全必要,因为我们只是分配了 x 和 y 的整数值:
Point& Point::operator=(const Point& point) {
if (this != &point) {
x = point.x;
y = point.y;
}
return *this;
}
Point::Point(const POINT& point)
:x(point.x),
y(point.y) {
// Empty.
}
Point& Point::operator=(const POINT& point) {
x = point.x;
y = point.y;
return *this;
}
Point::operator POINT() const {
POINT point = {x, y};
return point;
}
bool Point::operator==(const Point& point) const {
return ((x == point.x) && (y == point.y));
}
bool Point::operator!=(const Point& point) const {
return !(*this == point);
}
bool Point::operator<(const Point& point) const {
return (x < point.x) || ((x == point.x) && (y < point.y));
}
bool Point::operator<=(const Point& point) const {
return ((*this < point) || (*this == point));
}
bool Point::operator>(const Point& point) const {
return !(*this <= point);
}
bool Point::operator>=(const Point& point) const {
return !(*this < point);
}
Point Min(const Point& left, const Point& right) {
return (left < right) ? left : right;
}
Point Max(const Point& left, const Point& right) {
return (left > right) ? left : right;
}
Point& Point::operator*=(double factor) {
x = (int) (factor * x);
y = (int) (factor * y);
return *this;
}
Point operator*(const Point& point, double factor) {
return Point((int) (point.x * factor),
(int) (point.y * factor));
}
Point operator*(double factor, const Point& point) {
return Point((int) (factor * point.x),
(int) (factor * point.y));
}
Point& Point::operator*=(pair<double,double> factorPair) {
x = (int) (factorPair.first * x);
y = (int) (factorPair.second * y);
return *this;
}
Point operator*(const Point& point,
pair<double,double> factorPair) {
return Point((int) (point.x * factorPair.first),
(int) (point.y * factorPair.second));
}
Point operator*(pair<double,double> factorPair,
const Point& point) {
return Point((int) (factorPair.first * point.x),
(int) (factorPair.second * point.y));
}
Point& Point::operator+=(const int distance) {
x += distance;
y += distance;
return *this;
}
Point& Point::operator-=(const int distance) {
x -= distance;
y -= distance;
return *this;
}
Point& Point::operator+=(const Size& size) {
x += size.Width();
y += size.Height();
return *this;
}
Point& Point::operator-=(const Size& size) {
x -= size.Width();
y -= size.Height();
return *this;
}
Point& Point::operator+=(const Point& point) {
x += point.x;
y += point.y;
return *this;
}
Point& Point::operator-=(const Point& point) {
x -= point.x;
y -= point.y;
return *this;
}
Point operator+(const Point& left, int distance) {
return Point(left.x + distance, left.y + distance);
}
Point operator-(const Point& left, int distance) {
return Point(left.x - distance, left.y - distance);
}
Point operator+(const Point& point, const Size& size) {
return Point(point.x + size.Width(), point.y + size.Height());
}
Point operator-(const Point& point, const Size& size) {
return Point(point.x - size.Width(), point.y - size.Height());
}
Point operator+(const Point& left, const Point& right) {
return Point(left.x + right.x, left.y + right.y);
}
Size operator-(const Point& left, const Point& right) {
return Size(left.x - right.x, left.y - right.y);
}
bool Point::WritePointToStream(ostream& outStream) const {
outStream.write((char*) &x, sizeof x);
outStream.write((char*) &y, sizeof y);
return ((bool) outStream);
}
bool Point::ReadPointFromStream(istream& inStream) {
inStream.read((char*) &x, sizeof x);
inStream.read((char*) &y, sizeof y);
return ((bool) inStream);
}
void Point::WritePointToClipboard(InfoList& infoList) const {
infoList.AddValue<int>(x);
infoList.AddValue<int>(y);
}
void Point::ReadPointFromClipboard(InfoList& infoList) {
infoList.GetValue<int>(x);
infoList.GetValue<int>(y);
}
void Point::WritePointToRegistry(String key) const {
POINT pointStruct = (POINT) *this;
Registry::WriteBuffer(key, &pointStruct, sizeof pointStruct);
}
void Point::ReadPointFromRegistry(String key,
Point defaultPoint /* = ZeroPoint */) {
POINT pointStruct, defaultPointStruct = (POINT) defaultPoint;
Registry::ReadBuffer(key, &pointStruct, sizeof pointStruct,
&defaultPointStruct);
*this = Point(pointStruct);
}
const Point ZeroPoint(0, 0);
};
Rect 类
Rect
类包含矩形的四个边:左、上、右和下。
Rect.h
namespace SmallWindows {
class Rect;
extern const Rect ZeroRect;
class Rect {
public:
默认构造函数将所有四个边设置为零。矩形可以通过另一个矩形初始化或赋值。也可以使用左上角和右下角以及包含矩形宽度和高度的尺寸初始化矩形:
Rect();
Rect(int left, int top, int right, int bottom);
Rect(const Rect& rect);
Rect& operator=(const Rect& rect);
Rect(Point topLeft, Point bottomRight);
Rect(Point topLeft, Size size);
与前几节中的 SIZE
和 POINT
类似,矩形可以初始化和赋值给 Win32 API RECT
结构的值。Rect
对象也可以转换为 RECT
:
Rect(const RECT& rect);
Rect& operator=(const RECT& rect);
operator RECT() const;
比较运算符首先比较左上角。如果它们相等,然后比较右下角:
bool operator==(const Rect& rect) const;
bool operator!=(const Rect& rect) const;
bool operator<(const Rect& rect) const;
bool operator<=(const Rect& rect) const;
bool operator>(const Rect& rect) const;
bool operator>=(const Rect& rect) const;
乘法运算符将所有边乘以因子。尽管因子是双精度浮点数,但边框值始终是整数,类似于前几节中的Size
和Point
情况:
Rect& operator*=(double factor);
friend Rect operator*(const Rect& rect, double factor);
friend Rect operator*(double factor, const Rect& rect);
还可以将矩形与一对值相乘,其中第一个值与left
和right
相乘,第二个值与top
和bottom
相乘。此外,在这种情况下,结果值都是整数:
Rect& operator*=(pair<double,double> factorPair);
friend Rect operator*(const Rect& rect,
pair<double,double> factorPair);
friend Rect operator*(pair<double,double> factorPair,
const Rect& rect);
以下运算符有点特殊:加法运算符将大小添加到右下角,同时保持左上角不变,而减法运算符从左上角减去大小,同时保持右下角不变:
Rect& operator+=(const Size& size);
Rect& operator-=(const Size& size);
然而,以下运算符将大小添加到或从左上角和右下角:
friend Rect operator+(const Rect& rect, const Size& size);
friend Rect operator-(const Rect& rect, const Size& size);
以下运算符接受一个点作为参数,并将该点添加到,并从左上角和右下角减去:
Rect& operator+=(const Point& point);
Rect& operator-=(const Point& point);
friend Rect operator+(const Rect& rect, const Point& point);
friend Rect operator+(const Point& point, const Rect& rect);
friend Rect operator-(const Rect& rect, const Point& point);
矩形的宽度是左右边框之间的绝对差值,其高度是上下边框之间的绝对差值:
int Width() const {return abs(right - left);}
int Height() const {return abs(bottom - top);}
GetSize
方法返回矩形的宽度和高度。由于存在具有该名称的类,因此无法将其命名为Size
。然而,仍然可以定义返回Size
对象的运算符。Size
和Point
运算符返回矩形的尺寸和左上角:
Size GetSize() const {return Size(Width(), Height());}
operator Size() const {return GetSize();}
operator Point() const {return TopLeft();}
左上角和右下角都可以进行检查和修改。由于没有对应于角落的字段,因此不适当定义返回点引用的方法:
Point TopLeft() const {return Point(left, top);}
Point BottomRight() const {return Point(right, bottom);}
void SetTopLeft(Point topLeft) {left = topLeft.X();
right = topLeft.Y();}
void SetBottomRight(Point bottomRight)
{right = bottomRight.X();
bottom = bottomRight.Y();}
Clear
方法将所有四个角设置为 0,Normalize
方法如果左右边框和上下边框出现错误顺序,则交换左右边框和上下边框,PointInside
方法如果点位于矩形内部,则返回true
,假设它已经被归一化:
void Clear();
void Normalize();
bool PointInside(Point point) const;
矩形可以写入和读取文件流、剪贴板和注册表:
bool WriteRectToStream(ostream& outStream) const;
bool ReadRectFromStream(istream& inStream);
void WriteRectToClipboard(InfoList& infoList) const;
void ReadRectFromClipboard(InfoList& infoList);
void WriteRectToRegistry(String key) const;
void ReadRectFromRegistry(String key,
Rect defaultRect = ZeroRect);
四个角通过常量方法进行检查,并通过非常量方法进行修改:
int Left() const {return left;}
int Right() const {return right;}
int Top() const {return top;}
int Bottom() const {return bottom;}
int& Left() {return left;}
int& Right() {return right;}
int& Top() {return top;}
int& Bottom() {return bottom;}
private:
int left, top, right, bottom;
};
};
与Size
和Point
类似,Rect
的实现相当直接。
Rect.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Rect::Rect()
:left(0), top(0), right(0), bottom(0) {
// Empty.
}
Rect::Rect(int left, int top, int right, int bottom)
:left(left),
top(top),
right(right),
bottom(bottom) {
// Empty.
}
Rect::Rect(const Rect& rect)
:left(rect.left),
top(rect.top),
right(rect.right),
bottom(rect.bottom) {
// Empty.
}
Rect& Rect::operator=(const Rect& rect) {
if (this != &rect) {
left = rect.left;
top = rect.top;
right = rect.right;
bottom = rect.bottom;
}
return *this;
}
Rect::Rect(Point topLeft, Point bottomRight)
:left(topLeft.X()),
top(topLeft.Y()),
right(bottomRight.X()),
bottom(bottomRight.Y()) {
// Empty.
}
Rect::Rect(Point topLeft, Size size)
:left(topLeft.X()),
top(topLeft.Y()),
right(topLeft.X() + size.Width()),
bottom(topLeft.Y() + size.Height()) {
// Empty.
}
Rect::Rect(const RECT& rect)
:left(rect.left),
top(rect.top),
right(rect.right),
bottom(rect.bottom) {
// Empty.
}
Rect& Rect::operator=(const RECT& rect) {
left = rect.left;
top = rect.top;
right = rect.right;
bottom = rect.bottom;
return *this;
}
Rect::operator RECT() const {
RECT rect = {left, top, right, bottom};
return rect;
}
bool Rect::operator==(const Rect& rect) const {
return (left == rect.left) && (top == rect.top) &&
(right == rect.right) && (bottom == rect.bottom);
}
bool Rect::operator!=(const Rect& rect) const {
return !(*this == rect);
}
bool Rect::operator<(const Rect& rect) const {
return (TopLeft() < rect.TopLeft()) ||
((TopLeft() == rect.TopLeft()) &&
(BottomRight() < rect.BottomRight()));
}
bool Rect::operator<=(const Rect& rect) const {
return ((*this < rect) || (*this == rect));
}
bool Rect::operator>(const Rect& rect) const {
return !(*this <= rect);
}
bool Rect::operator>=(const Rect& rect) const {
return !(*this < rect);
}
Rect& Rect::operator*=(double factor) {
left = (int) (factor * left);
top = (int) (factor * top);
right = (int) (factor * right);
bottom = (int) (factor * bottom);
return *this;
}
Rect operator*(const Rect& rect, double factor) {
return Rect(rect.TopLeft() * factor,
rect.BottomRight() * factor);
}
Rect operator*(double factor, const Rect& rect) {
return Rect(factor * rect.TopLeft(),
factor * rect.BottomRight());
}
Rect& Rect::operator*=(pair<double,double> factorPair) {
left = (int) (factorPair.first * left);
top = (int) (factorPair.second * top);
right = (int) (factorPair.first * right);
bottom = (int) (factorPair.second * bottom);
return *this;
}
Rect operator*(const Rect& rect,
pair<double,double> factorPair) {
return Rect(rect.TopLeft() * factorPair,
rect.BottomRight() * factorPair);
}
Rect operator*(pair<double,double> factorPair,
const Rect& rect) {
return Rect(factorPair * rect.TopLeft(),
factorPair * rect.BottomRight());
}
Rect& Rect::operator+=(const Size& size) {
right += size.Width();
bottom += size.Height();
return *this;
}
Rect& Rect::operator-=(const Size& size) {
left -= size.Width();
top -= size.Height();
return *this;
}
Rect operator+(const Rect& rect, const Size& size) {
return Rect(rect.left + size.Width(),
rect.top + size.Height(),
rect.right + size.Width(),
rect.bottom + size.Height());
}
Rect operator-(const Rect& rect, const Size& size) {
return Rect(rect.left - size.Width(),
rect.top - size.Height(),
rect.right - size.Width(),
rect.bottom - size.Height());
}
Rect& Rect::operator+=(const Point& point) {
left += point.X();
top += point.Y();
right += point.X();
bottom += point.Y();
return *this;
}
Rect& Rect::operator-=(const Point& point) {
left -= point.X();
top -= point.Y();
right -= point.X();
bottom -= point.Y();
return *this;
}
Rect operator+(const Rect& rect, const Point& point) {
return Rect(rect.left + point.X(), rect.top + point.Y(),
rect.right + point.X(), rect.bottom + point.Y());
}
Rect operator+(const Point& point, const Rect& rect) {
return Rect(point.X() + rect.left, point.Y() + rect.top,
point.X() + rect.right, point.Y() + rect.bottom);
}
Rect operator-(const Rect& rect, const Point& point) {
return Rect(rect.left - point.X(), rect.top - point.Y(),
rect.right - point.X(), rect.bottom - point.Y());
}
void Rect::Clear() {
left = top = right = bottom = 0;
}
void Rect::Normalize() {
int minX = min(left, right), minY = min(top, bottom),
maxX = max(left, right), maxY = max(top, bottom);
left = minX;
top = minY;
right = maxX;
bottom = maxY;
}
bool Rect::PointInside(Point point) const {
return ((left <= point.X()) && (point.X() <= right) &&
(top <= point.Y()) && (point.Y() <= bottom));
}
bool Rect::WriteRectToStream(ostream& outStream) const {
outStream.write((char*) &left, sizeof left);
outStream.write((char*) &top, sizeof top);
outStream.write((char*) &right, sizeof right);
outStream.write((char*) &bottom, sizeof bottom);
return ((bool) outStream);
}
bool Rect::ReadRectFromStream(istream& inStream) {
inStream.read((char*) &left, sizeof left);
inStream.read((char*) &top, sizeof top);
inStream.read((char*) &right, sizeof right);
inStream.read((char*) &bottom, sizeof bottom);
return ((bool) inStream);
}
void Rect::WriteRectToClipboard(InfoList& infoList) const {
infoList.AddValue<int>(left);
infoList.AddValue<int>(top);
infoList.AddValue<int>(right);
infoList.AddValue<int>(bottom);
}
void Rect::ReadRectFromClipboard(InfoList& infoList) {
infoList.GetValue<int>(left);
infoList.GetValue<int>(top);
infoList.GetValue<int>(right);
infoList.GetValue<int>(bottom);
}
void Rect::WriteRectToRegistry(String key) const {
RECT pointStruct = (RECT) *this;
Registry::WriteBuffer(key, &pointStruct, sizeof pointStruct);
}
void Rect::ReadRectFromRegistry(String key,
Rect defaultRect /* = ZeroRect */) {
RECT rectStruct, defaultRectStruct = (RECT) defaultRect;
Registry::ReadBuffer(key, &rectStruct, sizeof rectStruct,
&defaultRectStruct);
*this = Rect(rectStruct);
}
const Rect ZeroRect(0, 0, 0, 0);
};
颜色类
Color
类是 Win32 API COLORREF
结构的包装类,它按照红-绿-蓝(RGB)标准存储颜色。颜色的每个分量都由一个介于 0 到 255 之间的值表示,这给出了理论上的总数 256³ = 16,777,216 种不同的颜色,其中Color
定义了 142 种标准颜色。
Color.h
namespace SmallWindows {
class Color;
extern const Color SystemColor;
默认构造函数将红色、绿色和蓝色值初始化为零,这对应于黑色。颜色对象也可以通过另一个颜色初始化和赋值:
class Color {
public:
Color();
Color(int red, int green, int blue);
Color(const Color& color);
Color& operator=(const Color& color);
等价运算符比较红色、绿色和蓝色值:
bool operator==(const Color& color) const;
bool operator!=(const Color& color) const;
Inverse
函数返回反转的颜色,而 GrayScale
返回相应的灰度颜色:
Color Inverse();
void GrayScale();
颜色可以被写入、从文件流、剪贴板和注册表中读取:
bool WriteColorToStream(ostream& outStream) const;
bool ReadColorFromStream(istream& inStream);
void WriteColorToClipboard(InfoList& infoList) const;
void ReadColorFromClipboard(InfoList& infoList);
void WriteColorToRegistry(String key) const;
void ReadColorFromRegistry(String key,
Color defaultColor =SystemColor);
通过常量方法检查包装的 COLORREF
结构值,并通过非常量方法进行修改:
COLORREF ColorRef() const {return colorRef;}
COLORREF& ColorRef() {return colorRef;}
private:
COLORREF colorRef;
};
预定义的颜色是常量对象:
extern const Color
AliceBlue, AntiqueWhite, Aqua, Aquamarine,
Azure, Beige, Bisque, Black, BlanchedAlmond,
Blue, BlueViolet, Brown, Burlywood, CadetBlue,
Chartreuse, Chocolate, Coral, CornflowerBlue,
Cornsilk, Crimson, Cyan, DarkBlue, DarkCyan,
DarkGoldenRod, DarkGray, DarkGreen, DarkKhaki,
DarkMagenta, DarkOliveGreen, DarkOrange, DarkOrchid,
DarkRed, DarkSalmon, DarkSeaGreen, DarkSlateBlue,
DarkSlateGray, DarkTurquoise, DarkViolet, DeepPink,
DeepSkyBlue, DimGray, DodgerBlue, FireBrick,
FloralWhite, ForestGreen, Fuchsia, Gainsboro,
GhostWhite, Gold, GoldenRod, Gray, Green, GreenYellow,
HoneyDew, HotPink, IndianRed, Indigo, Ivory, Khaki,
Lavender, LavenderBlush, Lawngreen, LemonChiffon,
LightBlue, LightCoral, LightCyan, LightGoldenRodYellow,
LightGreen, LightGray, LightPink, LightSalmon,
LightSeaGreen, LightSkyBlue, LightSlateGray,
LightSteelBlue, LightYellow, Lime, LimeGreen, Linen,
Magenta, Maroon, MediumAquamarine, MediumBlue,
MediumOrchid, MediumPurple, MediumSeaGreen,
MediumSlateBlue, MediumSpringGreen, MediumTurquoise,
MediumVioletRed, MidnightBlue, MintCream, MistyRose,
Moccasin, NavajoWhite, Navy, Navyblue, OldLace, Olive,
OliveDrab, Orange, OrangeRed, Orchid, PaleGoldenRod,
PaleGreen, PaleTurquoise, PaleVioletRed, PapayaWhip,
PeachPuff, Peru, Pink, Plum, PowderBlue, Purple,
Red, RosyBrown, RoyalBlue, SaddleBrown, Salmon,
SandyBrown, SeaGreen, SeaShell, Sienna, Silver, SkyBlue,
SlateBlue, SlateGray, Snow, SpringGreen, SteelBlue,
SystemColor, Tan, Teal, Thistle, Tomato, Turquoise,
Violet, Wheat, White, WhiteSmoke, Yellow, YellowGreen;
};
Color
的实现相当直接。Win32 的 RGB
宏根据三个颜色组件创建一个 COLORREF
值。
Color.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Color::Color()
:colorRef(RGB(0, 0, 0)) {
// Empty.
}
Color::Color(COLORREF colorRef)
:colorRef(colorRef) {
// Empty.
}
Color::Color(int red, int green, int blue)
:colorRef(RGB(red, green, blue)) {
// Empty.
}
Color::Color(const Color& color)
:colorRef(color.colorRef) {
// Empty.
}
Color& Color::operator=(const Color& color) {
if (this != &color) {
colorRef = color.colorRef;
}
return *this;
}
两个颜色相等,如果它们的包装 COLORREF
结构相等,并且它们通过 C 标准函数 memcpy
进行比较。
bool Color::operator==(const Color& color) const {
return (colorRef == color.colorRef);
}
bool Color::operator!=(const Color& color) const {
return !(*this == color);
}
Inverse
函数返回每个组件从 255 减去的反转颜色,而 GrayScale
返回每个组件持有红色、绿色和蓝色组件平均值的相关灰度颜色。GetRValue
、GetGValue
和 GetBValue
是 Win32 API 宏,用于提取红色、绿色和蓝色组件:
Color Color::Inverse() {
int inverseRed = 255 - GetRValue(colorRef);
int inverseGreen = 255 - GetGValue(colorRef);
int inverseBlue = 255 - GetBValue(colorRef);
return Color(inverseRed, inverseGreen, inverseBlue);
}
void Color::GrayScale() {
int red = GetRValue(colorRef);
int green = GetGValue(colorRef);
int blue = GetBValue(colorRef);
int average = (red + green + blue) / 3;
colorRef = RGB(average, average, average);
}
bool Color::WriteColorToStream(ostream& outStream) const {
outStream.write((char*) &colorRef, sizeof colorRef);
return ((bool) outStream);
}
bool Color::ReadColorFromStream(istream& inStream) {
inStream.read((char*) &colorRef, sizeof colorRef);
return ((bool) inStream);
}
void Color::WriteColorToClipboard(InfoList& infoList) const {
infoList.AddValue<COLORREF>(colorRef);
}
void Color::ReadColorFromClipboard(InfoList& infoList) {
infoList.GetValue<COLORREF>(colorRef);
}
void Color::WriteColorToRegistry(String key) const {
Registry::WriteBuffer(key, &colorRef, sizeof colorRef);
}
void Color::ReadColorFromRegistry(String key,
Color defaultColor /*=SystemColor */) {
Registry::ReadBuffer(key, &colorRef, sizeof colorRef,
&defaultColor.colorRef);
}
每个预定义的颜色都调用接受红色、绿色和蓝色组件的构造函数:
const Color
AliceBlue(240, 248, 255), AntiqueWhite(250, 235, 215),
Aqua(0, 255, 255), Aquamarine(127, 255, 212),
Azure(240, 255, 255), Beige(245, 245, 220),
Bisque(255, 228, 196), Black(0, 0, 0),
BlanchedAlmond(255, 255, 205), Blue(0, 0, 255),
BlueViolet(138, 43, 226), Brown(165, 42, 42),
Burlywood(222, 184, 135), CadetBlue(95, 158, 160),
Chartreuse(127, 255, 0), Chocolate(210, 105, 30),
Coral(255, 127, 80), CornflowerBlue(100, 149, 237),
Cornsilk(255, 248, 220), Crimson(220, 20, 60),
Cyan(0, 255, 255), DarkBlue(0, 0, 139),
DarkCyan(0, 139, 139), DarkGoldenRod(184, 134, 11),
DarkGray(169, 169, 169), DarkGreen(0, 100, 0),
DarkKhaki(189, 183, 107), DarkMagenta(139, 0, 139),
DarkOliveGreen(85, 107, 47), DarkOrange(255, 140, 0),
DarkOrchid(153, 50, 204), DarkRed(139, 0, 0),
DarkSalmon(233, 150, 122), DarkSeaGreen(143, 188, 143),
DarkSlateBlue(72, 61, 139), DarkSlateGray(47, 79, 79),
DarkTurquoise(0, 206, 209), DarkViolet(148, 0, 211),
DeepPink(255, 20, 147), DeepSkyBlue(0, 191, 255),
DimGray(105, 105, 105), DodgerBlue(30, 144, 255),
FireBrick(178, 34, 34), FloralWhite(255, 250, 240),
ForestGreen(34, 139, 34), Fuchsia(255, 0, 255),
Gainsboro(220, 220, 220), GhostWhite(248, 248, 255),
Gold(255, 215, 0), GoldenRod(218, 165, 32),
Gray(127, 127, 127), Green(0, 128, 0),
GreenYellow(173, 255, 47), HoneyDew(240, 255, 240),
HotPink(255, 105, 180), IndianRed(205, 92, 92),
Indigo(75, 0, 130), Ivory(255, 255, 240),
Khaki(240, 230, 140), Lavender(230, 230, 250),
LavenderBlush(255, 240, 245), Lawngreen(124, 252, 0),
LemonChiffon(255, 250, 205), LightBlue(173, 216, 230),
LightCoral(240, 128, 128), LightCyan(224, 255, 255),
LightGoldenRodYellow(250, 250, 210),
LightGreen(144, 238, 144), LightGray(211, 211, 211),
LightPink(255, 182, 193), LightSalmon(255, 160, 122),
LightSeaGreen(32, 178, 170), LightSkyBlue(135, 206, 250),
LightSlateGray(119, 136, 153), LightSteelBlue(176, 196, 222),
LightYellow(255, 255, 224), Lime(0, 255, 0),
LimeGreen(50, 205, 50), Linen(250, 240, 230),
Magenta(255, 0, 255), Maroon(128, 0, 0),
MediumAquamarine(102, 205, 170), MediumBlue(0, 0, 205),
MediumOrchid(186, 85, 211), MediumPurple(147, 112, 219),
MediumSeaGreen(60, 179, 113), MediumSlateBlue(123, 104, 238),
MediumSpringGreen(0, 250, 154), MediumTurquoise(72, 209, 204),
MediumVioletRed(199, 21, 133), MidnightBlue(25, 25, 112),
MintCream(245, 255, 250), MistyRose(255, 228, 225),
Moccasin(255, 228, 181), NavajoWhite(255, 222, 173),
Navy(0, 0, 128), Navyblue(159, 175, 223),
OldLace(253, 245, 230), Olive(128, 128, 0),
OliveDrab(107, 142, 35), Orange(255, 165, 0),
OrangeRed(255, 69, 0), Orchid(218, 112, 214),
PaleGoldenRod(238, 232, 170), PaleGreen(152, 251, 152),
PaleTurquoise(175, 238, 238), PaleVioletRed(219, 112, 147),
PapayaWhip(255, 239, 213), PeachPuff(255, 218, 185),
Peru(205, 133, 63), Pink(255, 192, 203),
Plum(221, 160, 221), PowderBlue(176, 224, 230),
Purple(128, 0, 128), Red(255, 0, 0),
RosyBrown(188, 143, 143), RoyalBlue(65, 105, 225),
SaddleBrown(139, 69, 19), Salmon(250, 128, 114),
SandyBrown(244, 164, 96), SeaGreen(46, 139, 87),
SeaShell(255, 245, 238), Sienna(160, 82, 45),
Silver(192, 192, 192), SkyBlue(135, 206, 235),
SlateBlue(106, 90, 205), SlateGray(112, 128, 144),
Snow(255, 250, 250), SpringGreen(0, 255, 127),
SteelBlue(70, 130, 180), SystemColor(0, 0, 0),
Tan(210, 180, 140), Teal(0, 128, 128),
Thistle(216, 191, 216), Tomato(255, 99, 71),
Turquoise(64, 224, 208), Violet(238, 130, 238),
Wheat(245, 222, 179), White(255, 255, 255),
WhiteSmoke(245, 245, 245), Yellow(255, 255, 0),
YellowGreen(139, 205, 50);
};
Font
类
Font
类是 Win32 API LOGFONT
结构的包装类。该结构包含大量属性;然而,我们只考虑字体名称和大小以及字体是否为斜体、粗体或下划线的字段;其他字段设置为零。系统字体是所有 LOGFONT
结构字段都设置为零的字体,这导致系统的标准字体。最后,Font
类还包括一个 Color
对象。
Font.h
namespace SmallWindows {
class Font;
extern const Font SystemFont;
class Font {
public:
默认构造函数将名称设置为空字符串,并将所有其他值设置为零,从而得到系统字体,通常是 10 点 Arial。字体的大小以排版点给出(1 点 = 1/72 英寸 = 1/72 * 25.4 毫米 ≈ 0.35 毫米)。字体也可以通过另一个字体初始化或赋值:
Font();
Font(String name, int size,
bool italic = false, bool bold = false);
Font(const Font& Font);
Font& operator=(const Font& font);
如果两个字体具有相同的名称和大小,以及相同的斜体、粗体和下划线状态(所有其他字段假定为零),则两个字体相等:
bool operator==(const Font& font) const;
bool operator!=(const Font& font) const;
字体可以被写入、从文件流、剪贴板和注册表中读取:
bool WriteFontToStream(ostream& outStream) const;
bool ReadFontFromStream(istream& inStream);
void WriteFontToClipboard(InfoList& infoList) const;
void ReadFontFromClipboard(InfoList& infoList);
void WriteFontToRegistry(String key);
void ReadFontFromRegistry(String key,
Font defaultFont = SystemFont);
PointToMeters
函数将排版点转换为逻辑单位(毫米的百分之一):
void PointsToLogical(double zoom = 1.0);
通过常量方法检查包装的 LOGFONT
结构,并通过非常量方法进行修改:
LOGFONT LogFont() const {return logFont;}
LOGFONT& LogFont() {return logFont;}
color
字段也可以通过常量方法进行检查,并通过非常量方法进行修改:
Color FontColor() const {return color;}
Color& FontColor() {return color;}
private:
LOGFONT logFont;
Color color;
};
};
Font.cpp
#include "SmallWindows.h"
namespace SmallWindows {
Font::Font() {
memset(&logFont, 0, sizeof logFont);
}
Font::Font(String name, int size, bool italic, bool bold) {
memset(&logFont, 0, sizeof logFont);
wcscpy_s(logFont.lfFaceName, LF_FACESIZE, name.c_str());
logFont.lfHeight = size;
logFont.lfItalic = (italic ? TRUE : FALSE);
logFont.lfWeight = (bold ? FW_BOLD : FW_NORMAL);
}
Font::Font(const Font& font) {
logFont = font.LogFont();
color = font.color;
}
Font& Font::operator=(const Font& font) {
if (this != &font) {
logFont = font.LogFont();
color = font.color;
}
return *this;
}
如果两个字体的包装 LOGFONT
结构和它们的 Color
字段相等,则两个字体相等:
bool Font::operator==(const Font& font) const {
return (::memcmp(&logFont, &font.logFont,
sizeof logFont) == 0) &&
(color == font.color);
}
bool Font::operator!=(const Font& font) const {
return !(*this == font);
}
write
和 read
方法写入和读取包装的 LOGFONT
结构,并调用 Color
的写入和读取方法:
bool Font::WriteFontToStream(ostream& outStream) const {
outStream.write((char*) &logFont, sizeof logFont);
color.WriteColorToStream(outStream);
return ((bool) outStream);
}
bool Font::ReadFontFromStream(istream& inStream) {
inStream.read((char*) &logFont, sizeof logFont);
color.ReadColorFromStream(inStream);
return ((bool) inStream);
}
void Font::WriteFontToClipboard(InfoList& infoList) const {
infoList.AddValue<LOGFONT>(logFont);
color.WriteColorToClipboard(infoList);
}
void Font::ReadFontFromClipboard(InfoList& infoList) {
infoList.GetValue<LOGFONT>(logFont);
color.ReadColorFromClipboard(infoList);
}
void Font::WriteFontToRegistry(String key) {
Registry::WriteBuffer(key, &logFont, sizeof logFont);
color.WriteColorToRegistry(key);
}
void Font::ReadFontFromRegistry(String key,
Font defaultFont /* = SystemFont */) {
Registry::ReadBuffer(key, &logFont, sizeof logFont,
&defaultFont.logFont);
color.ReadColorFromRegistry(key);
}
一个排版点等于 1/72 英寸,一个英寸等于 25.4 毫米。要将字体排版单位转换为逻辑单位(毫米的百分之一),我们需要将宽度和高度除以 72,乘以 2,540(2,540 逻辑单位等于 25.4 毫米)以及缩放因子:
void Font::PointsToLogical(double zoom /* = 1.0 */) {
logFont.lfWidth =
(int) (zoom * 2540.0 * logFont.lfWidth / 72.0);
logFont.lfHeight =
(int) (zoom * 2540.0 * logFont.lfHeight / 72.0);
}
const Font SystemFont;
};
Cursor 类
Win32 API 中有一组可用的光标,所有这些光标的名称都以IDC_
开头。在小窗口中,它们被赋予了其他名称,希望这些名称更容易理解。与其他情况不同,我们不能为光标使用枚举,因为它们实际上是零终止的 C++字符串(字符指针)。相反,每个光标都是一个指向零终止字符串的指针。LPCTSTR
代表长指针到常量 TChar 字符串。
光标有自己类的原因,而光标在Document
类中有方法,是因为光标确实需要一个窗口句柄来设置,而光标则不需要。
Cursor.h
namespace SmallWindows {
typedef LPCTSTR CursorType;
class Cursor {
public:
static const CursorType Normal;
static const CursorType Arrow;
static const CursorType ArrowHourGlass;
static const CursorType Crosshair;
static const CursorType Hand;
static const CursorType ArrowQuestionMark;
static const CursorType IBeam;
static const CursorType SlashedCircle;
static const CursorType SizeAll;
static const CursorType SizeNorthEastSouthWest;
static const CursorType SizeNorthSouth;
static const CursorType SizeNorthWestSouthEast;
static const CursorType SizeWestEast;
static const CursorType VerticalArrow;
static const CursorType HourGlass;
static void Set(CursorType cursor);
};
};
Cursor.cpp
#include "SmallWindows.h"
namespace SmallWindows {
const CursorType Cursor::Normal = IDC_ARROW;
const CursorType Cursor::Arrow = IDC_ARROW;
const CursorType Cursor::ArrowHourGlass = IDC_APPSTARTING;
const CursorType Cursor::Crosshair = IDC_CROSS;
const CursorType Cursor::Hand = IDC_HAND;
const CursorType Cursor::ArrowQuestionMark = IDC_HELP;
const CursorType Cursor::IBeam = IDC_IBEAM;
const CursorType Cursor::SlashedCircle = IDC_NO;
const CursorType Cursor::SizeAll = IDC_SIZEALL;
const CursorType Cursor::SizeNorthEastSouthWest = IDC_SIZENESW;
const CursorType Cursor::SizeNorthSouth = IDC_SIZENS;
const CursorType Cursor::SizeNorthWestSouthEast = IDC_SIZENWSE;
const CursorType Cursor::SizeWestEast = IDC_SIZEWE;
const CursorType Cursor::VerticalArrow = IDC_UPARROW;
const CursorType Cursor::HourGlass = IDC_WAIT;
Set
方法通过调用 Win32 API 函数LoadCursor
和SetCursor
来设置光标:
void Cursor::Set(CursorType cursor) {
::SetCursor(::LoadCursor(nullptr, cursor));
}
};
DynamicList 类
DynamicList
类可以被视为 C++标准类list
和vector
的更高级版本。它动态地改变其大小:
DynamicList.h
namespace SmallWindows {
template <class Type>
class DynamicList {
public:
IfFuncPtr
指针是一个函数原型,用于在测试(不更改)列表中的值时使用。它接受一个常量值和一个void
指针,并返回一个Boolean
值。DoFuncPtr
用于更改列表中的值,并接受一个(非常量)值和一个void
指针。这些void
指针由调用方法传递;它们包含额外的信息:
typedef bool (*IfFuncPtr)(const Type& value, void* voidPtr);
typedef void (*DoFuncPtr)(Type& value, void* voidPtr);
列表可以通过另一个列表初始化和赋值。默认构造函数创建一个空列表,析构函数则释放列表的内存:
DynamicList();
DynamicList(const DynamicList& list);
DynamicList& operator=(const DynamicList& list);
~DynamicList();
Empty
函数如果列表为空则返回true
,Size
返回列表中的值的数量,Clear
移除列表中的每个值,而IndexOf
返回给定值的零基于索引,如果没有这样的值在列表中,则返回负一:
bool Empty() const;
int Size() const;
void Clear();
int IndexOf(Type& value) const;
begin
和end
方法返回列表的开始和结束指针。它们被包含进来是为了使列表可以通过for
语句迭代:
Type* begin();
const Type* begin() const;
Type* end();
const Type* end() const;
索引方法检查或修改列表中给定零基于索引的值:
Type operator[](int index) const;
Type& operator[](int index);
Front
和Back
方法通过调用之前提到的索引方法来检查和修改列表的第一个和最后一个值:
Type Front() const {return (*this)[0];}
Type& Front() {return (*this)[0];}
Type Back() const {return (*this)[size - 1];}
Type& Back() {return (*this)[size - 1];}
PushFront
和PushBack
方法在列表的开始或结束处添加一个值或一个列表,而Insert
在指定的索引处插入一个值或一个列表:
void PushBack(const Type& value);
void PushBack(const DynamicList& list);
void PushFront(const Type& value);
void PushFront(const DynamicList& list);
void Insert(int index, const Type& value);
void Insert(int index, const DynamicList& list);
Erase
函数删除给定索引处的值,而 Remove
删除从 firstIndex
到 lastIndex
(包含)的列表,如果 lastIndex
为负一,则删除列表的末尾。如果 firstIndex
为零且 lastIndex
为负一,则删除整个列表。由于 Remove
中的 lastIndex
是默认参数,因此方法已被赋予不同的名称。给方法赋予相同的名称将违反重载规则:
void Erase(int deleteIndex);
void Remove(int firstIndex = 0, int lastIndex = -1);
Copy
函数将 firstIndex
到 lastIndex
(包含)的列表复制到 copyList
或 lastIndex
为负一时的列表的其余部分。如果 firstIndex
为零且 lastIndex
为负一,则整个列表被复制:
void Copy(DynamicList& copyList, int firstIndex = 0,
int lastIndex = -1) const;
AnyOf
函数如果至少有一个值满足 ifFuncPtr
,则返回 true
。也就是说,如果 ifFuncPtr
在以值作为参数调用时返回 true
,则 AllOf
函数返回 true
如果所有值都满足 ifFuncPtr
:
bool AnyOf(IfFuncPtr ifFuncPtr, void* ifVoidPtr = nullptr)
const;
bool AllOf(IfFuncPtr ifFuncPtr, void* ifVoidPtr = nullptr)
const;
FirstOf
和 LastOf
方法将 value
参数设置为满足 ifFuncPtr
的第一个和最后一个值;如果没有这样的值,则返回 false
:
bool FirstOf(IfFuncPtr ifFuncPtr, Type& value,
void* ifVoidPtr = nullptr) const;
bool LastOf(IfFuncPtr ifFuncPtr, Type& value,
void* ifVoidPtr = nullptr) const;
Apply
方法对列表中的所有值调用 doFuncPtr
,而 ApplyIf
方法对列表中满足 ifFuncPtr
的每个值调用 doFuncPtr
:
void Apply(DoFuncPtr doFuncPtr, void* ifVoidPtr = nullptr);
void ApplyIf(IfFuncPtr ifFuncPtr, DoFuncPtr doFuncPtr,
void* ifVoidPtr = nullptr,
void* doVoidPtr = nullptr);
CopyIf
方法将列表中满足 ifFuncPtr
的每个值复制到 copyList
。RemoveIf
移除满足 ifFuncPtr
的值:
void CopyIf(IfFuncPtr ifFuncPtr, DynamicList& copyList,
void* ifVoidPtr = nullptr) const;
void RemoveIf(IfFuncPtr ifFuncPtr,
void* ifVoidPtr = nullptr);
ApplyRemoveIf
方法对满足 ifFuncPtr
的每个值调用 doFuncPtr
,然后移除它们。将函数应用于要删除的值可能看起来很奇怪。然而,当删除动态分配的值时,这在其中 doFuncPtr
在从列表中删除每个值之前释放每个值的内存时非常有用。简单地调用 ApplyIf
和 RemoveIf
是不起作用的。当值被 ApplyIf
删除后,它们不能成为 RemoveIf
中 ifFuncPtr
调用的参数:
void ApplyRemoveIf(IfFuncPtr ifFuncPtr, DoFuncPtr doFuncPtr,
void* ifVoidPtr=nullptr,
void* doVoidPtr=nullptr);
大小是列表中的值的数量,缓冲区持有这些值本身。缓冲区的大小是动态的,当向列表中添加或从列表中删除值时,它会改变。当列表为空时,缓冲区指针为空:
private:
int size;
Type* buffer;
};
template <class Type>
DynamicList<Type>::DynamicList()
:size(0),
buffer(nullptr) {
// Empty.
}
默认构造函数和赋值运算符遍历给定的列表并复制每个值。为此,类型必须支持赋值运算符,除了数组之外的所有类型都支持:
template <class Type>
DynamicList<Type>::DynamicList(const DynamicList& list)
:size(list.size),
buffer(new Type[list.size]) {
assert(buffer != nullptr);
for (int index = 0; index < size; ++index) {
buffer[index] = list.buffer[index];
}
}
在赋值运算符中,我们首先删除缓冲区,因为它可能包含值。如果列表为空,缓冲区指针为空,删除运算符不执行任何操作:
template <class Type>
DynamicList<Type>& DynamicList<Type>::operator=
(const DynamicList& list) {
if (this != &list) {
delete[] buffer;
size = list.size;
assert((buffer = new Type[size]) != nullptr);
for (int index = 0; index < size; ++index) {
buffer[index] = list.buffer[index];
}
}
return *this;
}
析构函数简单地删除缓冲区。再次强调,如果列表为空,缓冲区指针为空,删除运算符不执行任何操作:
template <class Type>
DynamicList<Type>::~DynamicList() {
delete[] buffer;
}
template <class Type>
bool DynamicList<Type>::Empty() const {
return (size == 0);
}
template <class Type>
int DynamicList<Type>::Size() const {
return size;
}
Clear
方法将大小设置为零并将缓冲区设置为空:
template <class Type>
void DynamicList<Type>::Clear() {
size = 0;
delete[] buffer;
buffer = nullptr;
}
IndexOf
方法遍历列表并返回找到的值的索引,如果没有这样的值,则返回负一:
template <class Type>
int DynamicList<Type>::IndexOf(Type& value) const {
for (int index = 0; index < size; ++index) {
if (buffer[index] == value) {
return index;
}
}
return -1;
}
begin
方法返回列表中第一个值的地址:
template <class Type>
Type* DynamicList<Type>::begin() {
return &buffer[0];
}
template <class Type>
const Type* DynamicList<Type>::begin() const {
return &buffer[0];
}
end
方法返回列表中最后一个值之后的一步地址,这是 C++ 中列表迭代器的惯例:
template <class Type>
Type* DynamicList<Type>::end() {
return &buffer[size];
}
template <class Type>
const Type* DynamicList<Type>::end() const {
return &buffer[size];
}
如果索引超出列表范围,则发生断言:
template <class Type>
Type DynamicList<Type>::operator[](int index) const {
assert((index >= 0) && (index < size));
return buffer[index];
}
template <class Type>
Type& DynamicList<Type>::operator[](int index) {
assert((index >= 0) && (index < size));
return buffer[index];
}
当在原始列表的末尾添加值时,我们需要分配一个包含一个额外值的新的列表,并将新值添加到末尾:
template <class Type>
void DynamicList<Type>::PushBack(const Type& value) {
Type* newBuffer = new Type[size + 1];
assert(newBuffer != nullptr);
for (int index = 0; index < size; ++index) {
newBuffer[index] = buffer[index];
}
newBuffer[size++] = value;
delete[] buffer;
buffer = newBuffer;
}
当在原始列表的末尾添加新列表时,我们需要分配一个大小为原始列表和新列表之和的新列表,并将原始列表中的值复制到新列表中:
template <class Type>
void DynamicList<Type>::PushBack(const DynamicList& list) {
Type* newBuffer = new Type[size + list.size];
assert(newBuffer != nullptr);
for (int index = 0; index < size; ++index) {
newBuffer[index] = buffer[index];
}
for (int index = 0; index < list.size; ++index) {
newBuffer[size + index] = list.buffer[index];
}
delete[] buffer;
buffer = newBuffer;
size += list.size;
}
当在列表的开头插入新值时,我们需要将原始列表中的所有值向前移动一步,为新值腾出空间:
template <class Type>
void DynamicList<Type>::PushFront(const Type& value) {
Type* newBuffer = new Type[size + 1];
assert(newBuffer != nullptr);
newBuffer[0] = value;
for (int index = 0; index < size; ++index) {
newBuffer[index + 1] = buffer[index];
}
delete[] buffer;
buffer = newBuffer;
++size;
}
当在列表的开头插入新列表时,我们需要复制其所有值以及与新列表大小相对应的步数,为新值腾出空间:
template <class Type>
void DynamicList<Type>::PushFront(const DynamicList& list) {
Type* newBuffer = new Type[size + list.size];
assert(newBuffer != nullptr);
我们移动原始列表的值以腾出空间为新列表:
for (int index = 0; index < list.size; ++index) {
newBuffer[index] = list.buffer[index];
}
当我们为新列表腾出空间后,我们将它复制到原始列表的开头:
for (int index = 0; index < size; ++index) {
newBuffer[index + list.size] = buffer[index];
}
delete[] buffer;
buffer = newBuffer;
size += list.size;
}
Insert
方法的工作方式与 PushFront
类似。我们需要分配一个新列表,并将原始列表中的值复制到新列表中腾出空间,然后将新值复制到原始列表中:
template <class Type>
void DynamicList<Type>::Insert(int insertIndex,
const Type& value) {
assert((insertIndex >= 0) && (insertIndex <= size));
Type* newBuffer = new Type[size + 1];
assert(newBuffer != nullptr);
for (int index = 0; index < insertIndex; ++index) {
newBuffer[index] = buffer[index];
}
newBuffer[insertIndex] = value;
for (int index = 0; index < (size - insertIndex); ++index) {
newBuffer[insertIndex + index + 1] =
buffer[insertIndex + index];
}
delete[] buffer;
buffer = newBuffer;
++size;
}
template <class Type>
void DynamicList<Type>::Insert(int insertIndex,
const DynamicList& list){
assert((insertIndex >= 0) && (insertIndex <= size));
Type* newBuffer = new Type[size + list.size];
assert(newBuffer != nullptr);
for (int index = 0; index < insertIndex; ++index) {
newBuffer[index] = buffer[index];
}
for (int index = 0; index < list.size; ++index) {
newBuffer[insertIndex + index] = list.buffer[index];
}
for (int index = 0; index < (size - insertIndex); ++index) {
newBuffer[insertIndex + index + list.size] =
buffer[insertIndex + index];
}
delete[] buffer;
buffer = newBuffer;
size += list.size;
}
当在列表中删除值时,我们分配一个较小的新的列表,并将剩余的值复制到该列表中:
template <class Type>
void DynamicList<Type>::Erase(int eraseIndex) {
assert((eraseIndex >= 0) && (eraseIndex < size));
Type* newBuffer = new Type[size - 1];
assert(newBuffer != nullptr);
首先,我们复制删除索引之前的值:
for (int index = 0; index < eraseIndex; ++index) {
newBuffer[index] = buffer[index];
}
然后,我们复制删除索引之后的值:
for (int index = 0; index < (size - (eraseIndex + 1));
++index) {
newBuffer[eraseIndex + index] =
buffer[eraseIndex + index + 1];
}
delete[] buffer;
buffer = newBuffer;
--size;
}
Remove
方法的工作方式与 Delete
相同;区别在于可以从列表中删除多个值;removeSize
保存要删除的值的数量:
template <class Type>
void DynamicList<Type>::Remove(int firstIndex /* = 0 */,
int lastIndex /* = -1 */) {
if (lastIndex == -1) {
lastIndex = size - 1;
}
assert((firstIndex >= 0) && (firstIndex < size));
assert((lastIndex >= 0) && (lastIndex < size));
assert(firstIndex <= lastIndex);
int removeSize = lastIndex - firstIndex + 1;
Type* newBuffer = new Type[size - removeSize];
assert(newBuffer != nullptr);
for (int index = 0; index < firstIndex; ++index) {
newBuffer[index] = buffer[index];
}
for (int index = 0;
index < (size - (firstIndex + removeSize)); ++index){
newBuffer[firstIndex + index] =
buffer[firstIndex + index + removeSize];
}
delete[] buffer;
buffer = newBuffer;
size -= removeSize;
}
Copy
方法简单地为要复制的每个值调用 PushBack
:
template <class Type>
void DynamicList<Type>::Copy(DynamicList& copyList,
int firstIndex/* =0 */,
int lastIndex /* = -1 */) const {
if (lastIndex == -1) {
lastIndex = size - 1;
}
assert((firstIndex >= 0) && (firstIndex < size));
assert((lastIndex >= 0) && (lastIndex < size));
assert(firstIndex <= lastIndex);
for (int index = firstIndex; index <= lastIndex; ++index) {
copyList.PushBack(buffer[index]);
}
}
AnyOf
方法遍历列表,如果至少有一个值满足函数,则返回 true
:
template <class Type>
bool DynamicList<Type>::AnyOf(IfFuncPtr ifFuncPtr,
void* ifVoidPtr /* = nullptr */) const {
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
return true;
}
}
return false;
}
AllOf
方法遍历列表,如果至少有一个值不满足函数,则返回 false
:
template <class Type>
bool DynamicList<Type>::AllOf(IfFuncPtr ifFuncPtr,
void* ifVoidPtr /* = nullptr */) const {
for (int index = 0; index < size; ++index) {
if (!ifFuncPtr(buffer[index], ifVoidPtr)) {
return false;
}
}
return true;
}
FirstOf
方法以与 FirstOf
相同的方式查找列表中满足函数的第一个值,将其复制到值参数中,并返回 true
。如果没有找到满足函数的任何值,则返回 false
:
template <class Type>
bool DynamicList<Type>::FirstOf(IfFuncPtr ifFuncPtr,
Type& value, void* ifVoidPtr /* = nullptr */) const{
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
value = buffer[index];
return true;
}
}
return false;
}
LastOf
方法以与 FirstOf
相同的方式查找列表中满足函数的最后一个值;区别在于搜索是向后的:
template <class Type>
bool DynamicList<Type>::LastOf(IfFuncPtr ifFuncPtr, Type& value,
void* ifVoidPtr /* = nullptr */) const {
for (int index = (size - 1); index >= 0; --index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
value = buffer[index];
return true;
}
}
return false;
}
Apply
方法遍历列表,并对每个值调用 doFuncPtr
,值可能会被修改(实际上,Apply
的目的就是修改值),因为 doFuncPtr
的参数不是常量:
template <class Type>
void DynamicList<Type>::Apply(DoFuncPtr doFuncPtr,
void* doVoidPtr /* = nullptr */) {
for (int index = 0; index < size; ++index) {
doFuncPtr(buffer[index], doVoidPtr);
}
}
ApplyIf
方法遍历列表,并对满足 ifFuncPtr
的每个值调用 doFuncPtr
:
template <class Type>
void DynamicList<Type>::ApplyIf(IfFuncPtr ifFuncPtr,
DoFuncPtr doFuncPtr, void* ifVoidPtr /* = nullptr */,
void* doVoidPtr /* = nullptr */){
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
doFuncPtr(buffer[index], doVoidPtr);
}
}
}
CopyIf
方法通过为每个满足 ifFuncPtr
的值调用 PushBack
将每个值复制到 copyList
中:
template <class Type>
void DynamicList<Type>::CopyIf(IfFuncPtr ifFuncPtr,
DynamicList& copyList,
void* ifVoidPtr /* = nullptr */) const {
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
copyList.PushBack(buffer[index]);
}
}
}
RemoveIf
方法通过为每个值调用 Delete
来删除满足 ifFuncPtr
的每个值:
template <class Type>
void DynamicList<Type>::RemoveIf(IfFuncPtr ifFuncPtr,
void* ifVoidPtr /* = nullptr */) {
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
Erase(index--);
}
}
}
ApplyRemoveIf
方法将 doFuncPtr
应用到满足 ifFuncPtr
的每个值。我们不能简单地调用 Apply
和 RemoveIf
,因为 doFuncPtr
可能会释放 Apply
中的值,而 RemoveIf
中的 ifFuncPtr
在调用已删除的值时不会工作。相反,我们调用 doFuncPtr
并在调用后立即调用 Erase
。这样,在调用 doFuncPtr
之后不会访问值:
template <class Type>
void DynamicList<Type>::ApplyRemoveIf(IfFuncPtr ifFuncPtr,
DoFuncPtr doFuncPtr, void* ifVoidPtr /* = nullptr */,
void* doVoidPtr /* = nullptr */) {
for (int index = 0; index < size; ++index) {
if (ifFuncPtr(buffer[index], ifVoidPtr)) {
doFuncPtr(buffer[index], doVoidPtr);
Erase(index--);
}
}
}
};
树类
C++ 标准库包含一组用于数组、列表、向量、集合和映射的容器类。然而,没有用于树结构的类。因此,Tree
类被添加到 Small Windows 中。树由一组节点组成,其中之一是根节点。每个节点持有一个(可能为空)的子节点列表:
Tree.h
namespace SmallWindows {
template <class NodeType>
class Tree {
public:
Tree();
Tree(NodeType nodeValue,
initializer_list<Tree<NodeType>*> childList = {});
Tree(const Tree& tree);
Tree& operator=(const Tree& tree);
void Init(const Tree& tree);
~Tree();
树可以被写入和从文件流或剪贴板读取:
bool WriteTreeToStream(ostream& outStream) const;
bool ReadTreeFromStream(istream& inStream);
void WriteTreeToClipboard(InfoList& infoList) const;
void ReadTreeFromClipboard(InfoList& infoList);
每个树节点持有一个值,该值由常量方法检查并由非常量方法修改:
NodeType NodeValue() const {return nodeValue;}
NodeType& NodeValue() {return nodeValue;}
树节点还持有一个子节点列表,该列表由常量方法检查,并由非常量方法修改:
const DynamicList<Tree*>& ChildList() const
{return childList;}
DynamicList<Tree*>& ChildList() {return childList;}
private:
NodeType nodeValue;
DynamicList<Tree*> childList;
};
template <class NodeType>
Tree<NodeType>::Tree() {
// Empty.
}
子节点列表是一个树节点的初始化列表;默认情况下它是空的:
template <class NodeType>
Tree<NodeType>::Tree(NodeType nodeValue,
initializer_list<Tree<NodeType>*> childList /* = {} */)
:nodeValue(nodeValue) {
for (Tree<NodeType>* childNodePtr : childList) {
this->childList.PushBack(childNodePtr);
}
}
默认构造函数和赋值运算符调用 Init
来执行树的实际初始化:
template <class NodeType>
Tree<NodeType>::Tree(const Tree& tree) {
Init(tree);
}
template <class NodeType>
Tree<NodeType>& Tree<NodeType>::operator=(const Tree& tree) {
if (this != &tree) {
Init(tree);
}
return *this;
}
template <class NodeType>
void Tree<NodeType>::Init(const Tree& tree) {
nodeValue = tree.nodeValue;
for (Tree* childPtr : tree.childList) {
Tree* childClonePtr = new Tree(*childPtr);
assert(childClonePtr != nullptr);
childList.PushBack(childClonePtr);
}
}
析构函数递归地删除子节点:
template <class NodeType>
Tree<NodeType>::~Tree() {
for (Tree* childPtr : childList) {
delete childPtr;
}
}
WriteTreeToStream
方法将节点值和子节点数量写入流,然后对每个子节点递归调用自身:
template <class NodeType>
bool Tree<NodeType>::WriteTreeToStream(ostream& outStream)const{
nodeValue.WriteTreeNodeToStream(outStream);
int childListSize = childList.Size();
outStream.write((char*) &childListSize, sizeof childListSize);
for (Tree* childPtr : childList) {
childPtr->WriteTreeToStream(outStream);
}
return ((bool) outStream);
}
ReadTreeFromStream
方法从流中读取节点值和子节点数量,创建子节点,并对每个子节点递归调用自身:
template <class NodeType>
bool Tree<NodeType>::ReadTreeFromStream(istream& inStream) {
nodeValue.ReadTreeNodeFromStream(inStream);
int childListSize;
inStream.read((char*) &childListSize, sizeof childListSize);
for (int count = 0; count < childListSize; ++count) {
Tree* childPtr = new Tree();
assert(childPtr != nullptr);
childPtr->ReadTreeFromStream(inStream);
childList.PushBack(childPtr);
}
return ((bool) inStream);
}
WriteTreeToClipboard
和 ReadTreeFromClipboard
方法的工作方式与 WriteTreeToStream
和 ReadTreeFromStream
类似:
template <class NodeType>
void Tree<NodeType>::WriteTreeToClipboard(InfoList& infoList)
const {
nodeValue.WriteTreeNodeToClipboard(infoList);
infoList.AddValue<int>( childList.Size());
for (Tree* childPtr : childList) {
childPtr->WriteTreeToClipboard(infoList);
}
}
template <class NodeType>
void Tree<NodeType>::ReadTreeFromClipboard(InfoList& infoList) {
nodeValue.ReadTreeNodeFromClipboard(infoList);
int childListSize;
infoList.GetValue<int>(childListSize);
for (int count = 0; count < childListSize; ++count) {
Tree* childPtr = new Tree();
assert(childPtr != nullptr);
childPtr->ReadTreeFromClipboard(infoList);
childList.PushBack(childPtr);
}
}
};
InfoList 类
InfoList
类是一个具有模板方法的辅助类,它将信息存储在字符列表中;信息可以被添加和提取;或者写入,或从缓冲区读取:
InfoList .h
namespace SmallWindows {
class InfoList {
public:
template <class AlignType> void Align();
template <class ListType>
void AddValue(const ListType value);
template <class ListType>
void PeekValue(ListType& value, int index);
template <class ListType> void GetValue(ListType& value);
template <class CharType>
void AddString(basic_string<CharType> text);
template <class CharType>
basic_string<CharType> GetString();
void FromBuffer(const void* voidBuffer, int size);
void ToBuffer(void* voidBuffer);
int Size() const {return list.Size();}
private:
DynamicList<char> list;
};
Align
函数逐字节增加列表,直到对齐类型的大小是列表大小的除数:
template <class AlignType>
void InfoList::Align() {
int size = sizeof(AlignType);
while ((list.Size() % size) > 0) {
list.PushBack(0);
}
}
AddValue
函数通过逐字节将模板类型的值添加到列表中来添加值,而 GetValue
通过逐字节从列表中提取值来获取列表开头的值:
template <class ListType>
void InfoList::AddValue(const ListType value) {
int size = sizeof(ListType);
const char* buffer = (char*) &value;
for (int count = 0; count < size; ++count) {
list.PushBack(*(buffer++));
}
}
template <class ListType>
void InfoList::PeekValue(ListType& value, int index) {
int size = sizeof(ListType);
char* buffer = (char*) &value;
for (int count = 0; count < size; ++count) {
*(buffer++) = list[index + count];
}
}
template <class ListType>
void InfoList::GetValue(ListType& value) {
int size = sizeof(ListType);
char* buffer = (char*) &value;
for (int count = 0; count < size; ++count) {
*(buffer++) = list.Front();
list.Erase(0);
}
}
AddString
函数将文本的字符添加到列表中,并附带一个终止零字符,而 GetString
从列表中读取文本,直到遇到终止零字符:
template <class CharType>
void InfoList::AddString(basic_string<CharType> text) {
for (CharType c : text) {
AddValue<CharType>(c);
}
AddValue<CharType>(0);
}
template <class CharType>
basic_string<CharType> InfoList::GetString() {
bacic_string<CharType> text;
CharType c, zero = (CharType) 0;
while ((c = GetValue<CharType>()) != zero) {
text.append(c);
}
return text;
}
};
InfoList.cpp
#include "SmallWindows.h"
FromBuffer
函数将缓冲区的每个字节添加到列表中,而 ToBuffer
从列表中提取并复制每个字节到缓冲区:
void InfoList::FromBuffer(const void* voidBuffer, int size) {
const char* charBuffer = (const char*) voidBuffer;
for (int count = 0; count < size; ++count) {
list.PushBack(*(charBuffer++));
}
}
void InfoList::ToBuffer(void* voidBuffer) {
char* charBuffer = (char*) voidBuffer;
for (char c : list) {
*(charBuffer++) = c;
}
}
字符串
有少量字符串函数:
-
CharPtrToGenericString
:这接受文本作为一个char
字符指针,并以一个通用的String
对象返回相同的文本。请记住,String
类持有TCHAR
类型的值,其中许多是char
或wchar_t
,这取决于系统设置。 -
Split
:这接受一个字符串并返回一个包含文本空格分隔单词的字符串列表。 -
IsNumeric
:如果文本包含一个数值,则此函数返回true
。 -
Trim
:这会移除文本开头和结尾的空格。 -
ReplaceAll
:这会将一个字符串替换为另一个字符串。 -
WriteStringToStream
和ReadStringFromStream
:这些函数将字符串写入和从流中读取。 -
StartsWith
和EndsWith
:如果文本以子文本开头或结尾,则这些函数返回true
。
String.h
namespace SmallWindows {
extern String CharPtrToGenericString(char* text);
extern vector<String> Split(String text, TCHAR c = TEXT(' '));
extern bool IsNumeric(String text);
extern String Trim(String text);
void ReplaceAll(String& text, String from, String to);
extern bool WriteStringToStream(const String& text,
ostream& outStream);
extern bool ReadStringFromStream(String& text,
istream& inStream);
extern bool StartsWith(String text, String part);
extern bool EndsWith(String text, String part);
};
String.cpp
#include "SmallWindows.h"
namespace SmallWindows {
String CharPtrToGenericString(char* text) {
String result;
for (int index = 0; text[index] != '\0'; ++index) {
result += (TCHAR) text[index];
}
return result;
}
vector<String> Split(String text, TCHAR c /* = TEXT(' ') */) {
vector<String> list;
int spaceIndex = -1, size = text.size();
for (int index = 0; index < size; ++index) {
if (text[index] == c) {
String word =
text.substr(spaceIndex + 1, index - spaceIndex - 1);
list.push_back(word);
spaceIndex = index;
}
}
String lastWord = text.substr(spaceIndex + 1);
list.push_back(lastWord);
return list;
}
IsNumeric
方法使用 IStringStream
方法读取字符串的值,并将读取的字符数与文本长度进行比较。如果读取了文本的所有字符,则文本将包含一个数值,并返回 true
:
bool IsNumeric(String text) {
IStringStream stringStream(Trim(text));
double value;
stringStream >> value;
return stringStream.eof();
}
String Trim(String text) {
while (!text.empty() && isspace(text[0])) {
text.erase(0, 1);
}
while (!text.empty() && isspace(text[text.length() - 1])) {
text.erase(text.length() - 1, 1);
}
return text;
}
void ReplaceAll(String& text, String from, String to) {
int index, fromSize = from.size();
while ((index = text.find(from)) != -1) {
text.erase(index, fromSize);
text.insert(index, to);
}
}
bool WriteStringToStream(const String& text,ostream& outStream){
int size = text.size();
outStream.write((char*) &size, sizeof size);
for (TCHAR tChar : text) {
outStream.write((char*) &tChar, sizeof tChar);
}
return ((bool) outStream);
}
bool ReadStringFromStream(String& text, istream& inStream) {
int size;
inStream.read((char*) &size, sizeof size);
for (int count = 0; count < size; ++count) {
TCHAR tChar;
inStream.read((char*) &tChar, sizeof tChar);
text.push_back(tChar);
}
return ((bool) inStream);
}
bool StartsWith(String text, String part) {
return (text.find(part) == 0);
}
bool EndsWith(String text, String part) {
int index = text.rfind(part),
difference = text.length() - part.length();
return ((index != -1) && (index == difference));
}
};
摘要
在本章中,我们学习了 Small Windows 所使用的辅助类。在第十三章,剪贴板、标准对话框和打印预览中,我们将探讨注册表、剪贴板、标准对话框和打印预览。
第十三章。注册表、剪贴板、标准对话框和打印预览
本章描述了以下内容的实现:
-
注册表:一个 Windows 数据库,用于存储应用程序执行之间的信息。
-
剪贴板:一个 Windows 数据库,用于存储已剪切、复制和粘贴的信息。
-
标准对话框:用于保存和打开文档、颜色和字体以及打印。
-
打印预览:在
StandardDocument
类中,可以像打印一样在屏幕上查看文档。
注册表
Registry
类中的静态写入、读取和擦除方法在Integer
、Double
、Boolean
和String
类型的值以及 Windows 注册表中的内存块上操作。
Registry.h:
namespace SmallWindows {
class Registry {
public:
static void WriteInteger(String key, const int value);
static void WriteDouble(String key, const double value);
static void WriteBoolean(String key, const bool value);
static void WriteString(String key, const String text);
static void WriteBuffer(String key, const void* buffer,
int size);
static int ReadInteger(String key, const int defaultValue);
static double ReadDouble(String key,
const double defaultValue);
static bool ReadBoolean(String key,
const bool defaultValue);
static String ReadString(String key,
const String defaultText);
static void ReadBuffer(String key, void* buffer, int size,
const void* defaultBuffer);
static void Erase(String key);
};
};
Registry.cpp:
#include "SmallWindows.h"
namespace SmallWindows {
全局常量RegistryFileName
持有 Small Windows 注册表域的路径:
const String RegistryFileName = TEXT(".\\SmallWindows.ini");
WriteInteger
、WriteDouble
和WriteBoolean
函数简单地将值转换为字符串并调用WriteString
:
void Registry::WriteInteger(String key, const int intValue) {
WriteBuffer(key, &intValue, sizeof intValue);
}
void Registry::WriteDouble(String key,
const double doubleValue) {
WriteBuffer(key, &doubleValue, sizeof doubleValue);
}
void Registry::WriteBoolean(String key, const bool boolValue) {
WriteBuffer(key, &boolValue, sizeof boolValue);
}
WriteString
函数调用 Win32 API 函数WritePrivateProfileString
,将字符串写入注册表。所有 C++ String
对象都需要通过c_str
转换为以空字符终止的 C 字符串(char 指针):
void Registry::WriteString(String key, const String text) {
::WritePrivateProfileString
(Application::ApplicationName().c_str(),
key.c_str(), text.c_str(), RegistryFileName.c_str());
}
WriteBuffer
函数调用 Win32 API 函数WritePrivateProfileStruct
,将内存块写入注册表:
void Registry::WriteBuffer(String key, const void* buffer,
int size) {
::WritePrivateProfileStruct
(Application::ApplicationName().c_str(),
key.c_str(), (void*) buffer, size,
RegistryFileName.c_str());
}
ReadInteger
、ReadDouble
和ReadBoolean
函数将默认值转换为字符串并调用ReadString
。然后,将ReadString
的返回值转换并返回;_tstoi
和_tstof
是标准 C 函数atoi
和atof
的通用版本:
int Registry::ReadInteger(String key, const int defaultValue) {
int intValue;
ReadBuffer(key, &intValue, sizeof intValue, &defaultValue);
return intValue;
}
double Registry::ReadDouble(String key,
const double defaultValue) {
double doubleValue;
ReadBuffer(key, &doubleValue, sizeof doubleValue,
&defaultValue);
return doubleValue;
}
bool Registry::ReadBoolean(String key, const bool defaultValue){
bool boolValue;
ReadBuffer(key, &boolValue, sizeof boolValue, &defaultValue);
return boolValue;
}
ReadString
函数调用 Win32 API 函数GetPrivateProfileString
,将字符串值读取到text
中并返回读取的字符数。如果读取的字符数大于零,则将文本转换为string
对象并返回;否则,返回默认文本:
String Registry::ReadString(String key,
const String defaultText) {
TCHAR text[MAX_PATH];
int count =
::GetPrivateProfileString
(Application::ApplicationName().c_str(), key.c_str(),
nullptr, text, MAX_PATH, RegistryFileName.c_str());
return (count > 0) ? String(text) : defaultText;
}
ReadBuffer
函数调用 Win32 API 函数ReadPrivateProfileStruct
,从注册表中读取内存块。如果它返回零,则表示读取失败,并将默认缓冲区复制到缓冲区:
void Registry::ReadBuffer(String key, void* buffer, int size,
const void* defaultBuffer) {
int result =
::GetPrivateProfileStruct
(Application::ApplicationName().c_str(), key.c_str(),
buffer, size, RegistryFileName.c_str());
if (result == 0) {
::memcpy(buffer, defaultBuffer, size);
}
}
当从注册表中删除值时,我们使用空指针而不是字符串调用WritePrivateProfileString
,从而删除该值:
void Registry::Erase(String key) {
::WritePrivateProfileString
(Application::ApplicationName().c_str(),
key.c_str(),nullptr,RegistryFileName.c_str());
}
};
剪贴板类
Clipboard
类是对全局 Windows 剪贴板的接口,这使得在不同类型的应用程序之间剪切、复制和粘贴信息成为可能。剪贴板操作有两种形式:ASCII 和 Unicode 文本以及通用(应用程序特定)信息。
Clipboard.h:
namespace SmallWindows {
ASCII 和 Unicode 行的格式是预定义的。
enum {AsciiFormat = CF_TEXT, UnicodeFormat = CF_UNICODETEXT};
Open
和Close
打开和关闭剪贴板。如果成功,它们返回true
。Clear
在剪贴板打开时清除剪贴板。更具体地说,它移除任何潜在的信息,并且如果Available
返回true
,则表示剪贴板上存储了具有该格式的信息。
不同格式的信息可能存储在剪贴板上。例如,当用户在应用程序中复制文本时,文本可能以 ASCII 和 Unicode 文本以及更高级的应用程序特定格式存储在剪贴板上。如果剪贴板上有指定格式的信息,则Available
返回true
:
class Clipboard {
public:
static bool Open(const Window* windowPtr);
static bool Close();
static bool Clear();
static bool Available(unsigned int format);
WriteText
和ReadText
函数写入和读取字符串列表,而WriteGeneric
和ReadGeneric
函数写入和读取泛型信息:
template<int Format, class CharType>
static bool WriteText(vector<String>& lineList);
template<int Format, class CharType>
static bool ReadText(vector<String>& lineList);
static bool WriteGeneric(unsigned int format,
InfoList& infoList);
static bool ReadGeneric(unsigned int format,
InfoList& infoList);
};
Clipboard.cpp:
#include "SmallWindows.h"
namespace SmallWindows {
Open
、Close
和Clear
函数调用 Win32 API 函数OpenClipboard
、CloseClipboard
和EmptyClipboard
。它们都返回整数值;非零值表示成功:
bool Clipboard::Open(const Window* windowPtr) {
return (::OpenClipboard(windowPtr->WindowHandle()) != 0);
}
bool Clipboard::Close() {
return (::CloseClipboard() != 0);
}
bool Clipboard::Clear() {
return (::EmptyClipboard() != 0);
}
Available
函数通过调用 Win32 API 函数FormatAvailable
检查剪贴板上是否有指定格式的数据:
bool Clipboard::Available(unsigned int format) {
return (::IsClipboardFormatAvailable(format) != 0);
}
ASCII 和 Unicode 行
由于WriteText
和ReadText
是模板方法,它们包含在头文件中而不是实现文件中。WriteText
接受一个泛型字符串列表并将它们以任何格式写入剪贴板;AsciiFormat
(一个字节/字符)和UnicodeFormat
(两个字节/字符)是预定义的。
Clipboard.h:
template<int Format,class CharType>
bool Clipboard::WriteText(vector<String>& lineList) {
首先,我们需要找到缓冲区大小,我们通过计算行中的字符总数来计算它。我们还要为每一行加一,因为每一行也包含一个终止字符。终止字符是每一行的回车字符(\r
),除了最后一行,它由一个零字符(\0
)终止:
int bufferSize = 0;
for (String line : lineList) {
bufferSize += line.size();
}
int listSize = lineList.size();
bufferSize += listSize;
当我们计算出缓冲区大小时,我们可以调用 Win32 API 的GlobalAlloc
函数在全局剪贴板上分配缓冲区。我们稍后将将其连接到格式。我们使用模板字符类型的大小作为缓冲区:
HGLOBAL globalHandle =
::GlobalAlloc(GMEM_MOVEABLE, bufferSize * sizeof(CharType));
如果分配成功,我们将收到缓冲区的句柄。由于剪贴板及其缓冲区可以同时被多个进程使用,我们需要通过调用 Win32 API 函数GlobalLock
来锁定缓冲区。只要缓冲区被锁定,其他进程就无法访问它。当我们锁定缓冲区时,我们收到一个指向它的指针,我们可以用它来向缓冲区写入信息:
if (globalHandle != nullptr) {
CharType* buffer = (CharType*) ::GlobalLock(globalHandle);
if (buffer != nullptr) {
int bufferIndex = 0;
我们将行的字符写入缓冲区,除非它是列表中的最后一行,否则我们添加一个return
字符:
for (int listIndex = 0; listIndex < listSize;++listIndex) {
for (TCHAR tChar : lineList[listIndex]) {
buffer[bufferIndex++] = (CharType) tChar;
}
if (listIndex < (listSize - 1)) {
buffer[bufferIndex++] = (CharType) '\r';
}
}
我们在缓冲区的末尾添加一个零字符来标记其结束:
buffer[bufferIndex] = (CharType) '\0';
当缓冲区已加载信息后,我们只需解锁缓冲区,以便其他进程可以访问它并将缓冲区与格式关联:
::GlobalUnlock(globalHandle);
::SetClipboardData(Format, globalHandle);
最后,我们返回true
以指示操作成功:
return true;
}
}
如果我们没有能够为写入行列表分配缓冲区,我们通过返回false
来指示操作未成功:
return false;
}
当使用ReadText
读取行列表时,我们使用Format
(通常是AsciiFormat
或UnicodeFormat
)从剪贴板接收一个句柄,然后我们使用它来锁定缓冲区并接收其指针,这反过来又允许我们从缓冲区中读取:
template<int Format,class CharType>
bool Clipboard::ReadText(vector<String>& lineList) {
HGLOBAL globalHandle = ::GetClipboardData(Format);
if (globalHandle != nullptr) {
CharType* buffer = (CharType*) ::GlobalLock(globalHandle);
if (buffer != nullptr) {
String currentLine;
注意,我们必须将缓冲区大小除以模板字符类型大小(可能大于 1),以找到字符数:
int charCount =
::GlobalSize(globalHandle) / (sizeof(CharType));
for (int count = 0; count < charCount; ++count) {
CharType cChar = (*buffer++);
当我们遇到回车字符(\r
)时,当前行结束;我们将它添加到行列表中,然后清除它以便为下一行做好准备:
switch (cChar) {
case ((CharType) '\r') :
lineList.push_back(currentLine);
currentLine.clear();
break;
当我们遇到回车字符('\0'
)时,我们也把当前行添加到行列表中。然而,没有必要清除当前行,因为零字符是缓冲区的最后一个字符:
case ((CharType) '\0') :
lineList.push_back(currentLine);
break;
如果字符既不是回车也不是零字符,我们就将它添加到当前行。注意,我们读取一个CharType
类型的字符并将其转换为TCHAR
类型的通用字符:
default:
currentLine += (TCHAR) cChar;
break;
}
}
最后,我们解锁缓冲区并返回true
以指示操作成功:
::GlobalUnlock(globalHandle);
return true;
}
}
如果我们没有收到格式的缓冲区,我们返回false
以指示操作未成功:
return false;
}
};
通用信息
WriteGeneric
函数实际上比前面的WriteText
函数简单,因为它不需要考虑行列表。我们只需锁定剪贴板缓冲区,将infoList
中的每个字节写入缓冲区,解锁缓冲区,并将其与格式关联:
Clipboard.cpp:
bool Clipboard::WriteGeneric(unsigned int format,
InfoList& infoList) {
int bufferSize = infoList.Size();
HGLOBAL globalHandle = GlobalAlloc(GMEM_MOVEABLE, bufferSize);
if (globalHandle != nullptr) {
void* buffer = ::GlobalLock(globalHandle);
InfoList
函数中的ToBuffer
对象将其字节写入缓冲区:
if (buffer != nullptr) {
infoList.ToBuffer(buffer);
::GlobalUnlock(globalHandle);
::SetClipboardData(format, globalHandle);
return true;
}
}
如果我们没有成功分配全局缓冲区,我们返回false
以指示操作未成功:
return false;
}
ReadGeneric
函数锁定剪贴板缓冲区,将缓冲区中的每个字节写入infoList
,解锁缓冲区,并返回true
以指示操作成功:
bool Clipboard::ReadGeneric(unsigned int format,
InfoList& infoList) {
HGLOBAL globalHandle = ::GetClipboardData(format);
if (globalHandle != nullptr) {
void *buffer = ::GlobalLock(globalHandle);
int bufferSize = ::GlobalSize(globalHandle);
infoList.FromBuffer(buffer, bufferSize);
::GlobalUnlock(globalHandle);
return true;
}
如果我们没有收到全局句柄,我们返回false
以指示操作未成功:
return false;
}
};
标准对话框
在 Windows 中,可以定义对话框。与窗口不同,对话框的目的是填充控件,如按钮、框和文本字段。一个对话框可能是模态的,这意味着在对话框关闭之前,应用程序的其他窗口将变为禁用状态。在下一章中,我们将探讨如何构建我们自己的对话框。
然而,在本节中,我们将探讨 Windows标准对话框,用于保存和打开文件、选择字体和颜色以及打印。Small Windows 通过包装 Win32 API 函数支持标准对话框,这些函数为我们提供了对话框。
保存对话框
SaveDialog
函数显示标准保存对话框。
filter
参数过滤要显示的文件类型。每个文件格式由两部分定义:对话框中显示的文本和默认文件后缀。这两部分由一个零字符分隔,并且过滤器以两个零字符结束。例如,考虑以下:
Word Files (*.wrd)\0*.drw\0Text Files(*.txt)\0*.txt\0\0
fileSuffixList
参数指定允许的文件后缀,而 saveFlags
包含操作的标志。以下有两个标志可用:
-
PromptBeforeOverwrite
: 这个标志是一个警告信息,如果文件已经存在,则会显示 -
PathMustExist
: 如果路径不存在,则会显示一个错误信息
StandardDialog.h:
namespace SmallWindows {
class Window;
class Graphics;
class StandardDialog {
public:
enum SaveFlags {NoSaveFlag = 0,
PromptBeforeOverwrite = OFN_OVERWRITEPROMPT,
PathMustExist = OFN_PATHMUSTEXIST,
NormalSaveFlags = OFN_OVERWRITEPROMPT |
OFN_PATHMUSTEXIST};
static bool SaveDialog(Window* windowPtr, String& path,
const TCHAR* filter,
const vector<String> fileSuffixList,
StandardDialog::SaveFlags saveFlags =
NormalSaveFlags);
StandardDialog.cpp:
#include "SmallWindows.h"
namespace SmallWindows {
bool StandardDialog::SaveDialog(Window* windowPtr, String& path,
const TCHAR* filter,
const vector<String> fileSuffixList,
SaveFlags saveFlags
/* = NormalSaveFlags */) {
Win32 API OPENFILENAME
结构的 saveFileName
被加载了适当的值:hwndOwner
设置为窗口句柄,hInstance
设置为应用程序实例句柄,lpstrFilter
设置为 filter
参数,lpstrFile
设置为 pathBuffer
,它反过来又包含 path
参数,并且 Flags
设置为 saveFlags
参数:
OPENFILENAME saveFileName;
memset(&saveFileName, 0, sizeof saveFileName);
TCHAR pathBuffer[MAX_PATH];
wcscpy_s(pathBuffer, MAX_PATH, path.c_str());
saveFileName.lStructSize = sizeof saveFileName;
saveFileName.hwndOwner = windowPtr->WindowHandle();
saveFileName.hInstance = Application::InstanceHandle();
saveFileName.lpstrFilter = filter;
saveFileName.lpstrFile = pathBuffer;
saveFileName.nMaxFile = MAX_PATH;
saveFileName.Flags = saveFlags;
if (!fileSuffixList.empty()) {
saveFileName.lpstrDefExt = fileSuffixList.front().c_str();
}
else {
saveFileName.lpstrDefExt = nullptr;
}
当 saveFileName
被加载时,我们调用 Win32 API 函数 GetSaveFileName
,它显示标准的 保存 对话框,如果用户通过点击 保存 按钮或按 回车 键终止对话框,则返回非零值。在这种情况下,我们将 path
参数设置为所选路径,检查路径是否以 fileSuffixList
中的后缀之一结尾,如果是以,则返回 true
。如果路径后缀不在列表中,我们显示一个错误信息,并重新开始保存过程。如果用户取消过程,则返回 false
。实际上,用户完成过程的唯一方法是选择列表中的文件后缀或取消对话框:
while (true) {
if (::GetSaveFileName(&saveFileName) != 0) {
path = pathBuffer;
for (String fileWithSuffix : fileSuffixList) {
if (EndsWith(path, TEXT(".") + fileWithSuffix)) {
return true;
}
}
windowPtr->MessageBox(TEXT("Undefined file suffix."));
}
else {
return false;
}
}
}
打开对话框
OpenDialog
函数显示标准的 打开 对话框。
filter
和 fileSuffixList
参数与前面的 SaveDialog
函数中的方式相同。有三个标志可用:
-
PromptBeforeCreate
: 如果文件已经存在,则此标志会显示一个警告信息 -
FileMustExist
: 打开的文件必须存在 -
HideReadOnly
: 此标志表示在对话框中隐藏只读文件
OpenDialog.h:
enum OpenFlags {NoOpenFlag = 0,
PromptBeforeCreate = OFN_CREATEPROMPT,
FileMustExist = OFN_FILEMUSTEXIST,
HideReadOnly = OFN_HIDEREADONLY,
NormalOpenFlags = OFN_CREATEPROMPT |
OFN_FILEMUSTEXIST |
OFN_HIDEREADONLY};
static bool OpenDialog(Window* windowPtr, String& path,
const TCHAR* filter,
const vector<String> fileSuffixList,
StandardDialog::OpenFlags openFlags =
NormalOpenFlags);
OpenDialog
的实现与前面的 SaveDialog
函数类似。我们使用相同的 OPENFILENAME
结构;唯一的区别是我们调用 GetOpenFileName
而不是 GetSaveFileName
。
OpenDialog.cpp:
bool StandardDialog::OpenDialog(Window* windowPtr, String& path,
const TCHAR* filter,
const vector<String> fileSuffixList,
StandardDialog::OpenFlags openFlags
/*=NormalOpenFlags */){
OPENFILENAME openFileName;
memset(&openFileName, 0, sizeof openFileName);
TCHAR pathBuffer[MAX_PATH];
wcscpy_s(pathBuffer, MAX_PATH, path.c_str());
openFileName.lStructSize = sizeof openFileName;
openFileName.hwndOwner = windowPtr->WindowHandle();
openFileName.hInstance = Application::InstanceHandle();
openFileName.lpstrFilter = filter;
openFileName.lpstrFile = pathBuffer;
openFileName.nMaxFile = MAX_PATH;
openFileName.Flags = openFlags;
if (!fileSuffixList.empty()) {
openFileName.lpstrDefExt = fileSuffixList.front().c_str();
}
else {
openFileName.lpstrDefExt = nullptr;
}
while (true) {
if (::GetOpenFileName(&openFileName) != 0) {
path = pathBuffer;
for (String fileWithSuffix : fileSuffixList) {
if (EndsWith(path, TEXT(".") + fileWithSuffix)) {
return true;
}
}
windowPtr->MessageBox(TEXT("Undefined file suffix."));
}
else {
return false;
}
}
}
颜色对话框
ColorDialog
函数显示标准的 颜色 对话框。
StandardDialog.h:
static COLORREF customColorArray[];
static bool ColorDialog(Window* windowPtr, Color& color);
静态 COLORREF
数组 customColorArray
被用户在颜色对话框中使用,以存储所选颜色。由于它是静态的,customColorArray
数组在对话框显示会话之间被重用。
ColorDialog
函数使用 Win32 API CHOOSECOLOR
结构初始化对话框。hwndOwner
函数设置为窗口句柄,rgbResult
设置为颜色的 COLORREF
字段,lpCustColors
设置为自定义颜色数组。CC_RGBINIT
和 CC_FULLOPEN
标志使用给定的颜色初始化对话框,使其完全展开。
StandardDialog.cpp:
COLORREF StandardDialog::customColorArray[16];
bool StandardDialog::ColorDialog(Window* windowPtr,
Color& color) {
CHOOSECOLOR chooseColor;
chooseColor.lStructSize = sizeof chooseColor;
chooseColor.hwndOwner = windowPtr->WindowHandle();
chooseColor.hInstance = nullptr;
chooseColor.rgbResult = color.ColorRef();
chooseColor.lpCustColors = customColorArray;
chooseColor.Flags = CC_RGBINIT | CC_FULLOPEN;
chooseColor.lCustData = 0;
chooseColor.lpfnHook = nullptr;
chooseColor.lpTemplateName = nullptr;
Win32 的 ChooseColor
函数显示 颜色 对话框,如果用户通过点击 确定 按钮结束对话框,则返回非零值。在这种情况下,我们设置所选颜色并返回 true
:
if (::ChooseColor(&chooseColor) != 0) {
color.ColorRef() = chooseColor.rgbResult;
return true;
}
如果用户取消对话框,我们返回 false
:
return false;
}
字体对话框
FontDialog
函数显示一个标准的 字体 对话框。
StandardDialog.h:
static bool FontDialog(Window* windowPtr, Font& font);
FontDialog.cpp:
bool StandardDialog::FontDialog(Window* windowPtr, Font& font) {
LOGFONT logFont = font.LogFont();
Win32 API CHOOSEFONT
结构 chooseFont
被加载了适当的值。lpLogFont
对象设置为字体的 LOGFONT
字段,rgbColors
设置为颜色的 COLORREF
字段:
CHOOSEFONT chooseFont;
memset(&chooseFont, 0, sizeof chooseFont);
chooseFont.lStructSize = sizeof(CHOOSEFONT);
chooseFont.hInstance = Application::InstanceHandle();
chooseFont.hwndOwner = windowPtr->WindowHandle();
chooseFont.Flags = CF_INITTOLOGFONTSTRUCT |
CF_SCREENFONTS | CF_EFFECTS;
chooseFont.lpLogFont = &logFont;
chooseFont.rgbColors = font.FontColor().ColorRef();
Win32 的 ChooseFont
函数显示 字体 对话框,如果用户点击 确定 按钮则返回非零值。在这种情况下,我们设置所选字体和颜色并返回 true
:
if (::ChooseFont(&chooseFont) != 0) {
font.LogFont() = logFont;
font.FontColor() = Color(chooseFont.rgbColors);
return true;
}
如果用户取消对话框,我们返回 false
:
return false;
}
打印对话框
PrintDialog
函数显示一个标准的 打印 对话框。
如果用户点击 打印 按钮,所选的打印设置将保存在 PrintDialog
参数中:
PrintDialog.h:
static Graphics* PrintDialog(Window* parentPtr,
int totalPages,
int& firstPage, int& lastPage,
int& copies, bool& sorted);
};
};
PrintDialog
函数使用适当的值加载 Win32 API PRINTDLG
结构 printDialog
,nFromPage
和 nToPage
设置为要打印的第一页和最后一页(默认值分别为 1 和页数),nMaxPage
设置为页数,nCopies
设置为 1(默认值)。
PrintDialog.cpp:
Graphics* StandardDialog::PrintDialog(Window* parentPtr,
int totalPages,
int& firstPage, int& lastPage,
int& copies, bool& sorted) {
PRINTDLG printDialog;
memset(&printDialog, 0, sizeof printDialog);
printDialog.lStructSize = sizeof printDialog;
printDialog.hwndOwner = parentPtr->WindowHandle();
printDialog.hDevMode = nullptr;
printDialog.hDevNames = nullptr;
printDialog.hDC = nullptr;
printDialog.Flags = PD_ALLPAGES | PD_COLLATE |
PD_RETURNDC | PD_NOSELECTION;
printDialog.nFromPage = 1;
printDialog.nToPage = totalPages;
printDialog.nMinPage = 1;
printDialog.nMaxPage = totalPages;
printDialog.nCopies = 1;
printDialog.hInstance = nullptr;
printDialog.lCustData = 0L;
printDialog.lpfnPrintHook = nullptr;
printDialog.lpfnSetupHook = nullptr;
printDialog.lpPrintTemplateName = nullptr;
printDialog.lpSetupTemplateName = nullptr;
printDialog.hPrintTemplate = nullptr;
printDialog.hSetupTemplate = nullptr;
Win32 API 函数 PrintDlg
显示标准打印对话框,如果用户通过按下 打印 按钮结束对话框,则返回非零值。在这种情况下,打印的第一页和最后一页、副本数量以及是否排序存储在参数中,并创建返回用于打印的 Graphics
对象的指针。
如果用户选择了页面间隔,我们使用 nFromPage
和 nToPage
字段;否则,选择所有页面,并使用 nMinPage
和 nMaxPage
字段设置要打印的第一页和最后一页:
if (::PrintDlg(&printDialog) != 0) {
bool pageIntervalSelected =
((printDialog.Flags & PD_SELECTION) != 0);
if (pageIntervalSelected) {
firstPage = printDialog.nFromPage;
lastPage = printDialog.nToPage;
}
else {
firstPage = printDialog.nMinPage;
lastPage = printDialog.nMaxPage;
}
如果存在 PD_COLLATE
标志,则用户选择了排序页面:
copies = printDialog.nCopies;
sorted = (printDialog.Flags & PD_COLLATE) != 0;
最后,我们创建并返回一个指向用于打印时绘图的 Graphics
对象的指针。
return (new Graphics(parentPtr, printDialog.hDC));
}
如果用户通过按下 取消 按钮结束对话框,我们返回 null:
return nullptr;
}
};
打印预览
PrintPreviewDocument
类显示文档父窗口的页面。OnKeyDown
方法在用户按下Esc键时关闭文档。OnSize
方法调整页面的物理大小,以确保页面始终适合窗口。OnVerticalScroll
方法在用户向上或向下滚动时移动页面,而OnPaint
为每一页调用父文档的OnPrint
:
PrintPreviewDocument.h:
namespace SmallWindows {
class PrintPreviewDocument : Document {
public:
PrintPreviewDocument(StandardDocument* parentDocument,
int page = 1, Size pageSize = USLetterPortrait);
bool OnKeyDown(WORD key, bool shiftPressed,
bool controlPressed);
仅覆盖OnSize
函数以在Document
中中和其功能。在Document
中,OnSize
修改滚动条,但我们不希望在类中发生这种情况:
void OnSize(Size clientSize) {/* Empty. */}
void OnVerticalScroll(WORD flags, WORD thumbPos = 0);
void OnPaint(Graphics& graphics) const;
page
字段存储当前页码,totalPages
存储总页数:
private:
void SetHeader();
int page, totalPages;
};
};
PrintPreviewDocument.cpp
#include "SmallWindows.h"
构造函数将page
和totalPages
字段设置为适当的值。
namespace SmallWindows {
PrintPreviewDocument::PrintPreviewDocument
(StandardDocument* parentDocument, int totalPages /* = 1 */,
Size pageSize/* = USLetterPortrait */)
:Document(PreviewCoordinate, pageSize, parentDocument),
page(1),
totalPages(totalPages) {
水平滚动条始终设置为窗口的宽度,这意味着用户无法更改其设置:
SetHorizontalScrollPosition(0);
SetHorizontalScrollPageWidth(pageSize.Width());
SetHorizontalScrollTotalWidth(pageSize.Width());
垂直滚动条设置为与文档的页数相匹配,滚动滑块对应一页:
SetVerticalScrollPosition(0);
SetVerticalScrollPageHeight(pageSize.Height());
SetVerticalScrollTotalHeight(totalPages * pageSize.Height());
SetHeader();
ShowWindow(true);
}
标题显示当前页数和总页数:
void PrintPreviewDocument::SetHeader() {
SetName(TEXT("Print Preview: Page ") + to_String(page) +
TEXT(" out of ") + to_String(totalPages));
}
键盘输入
当用户按下键时,会调用OnKeyDown
函数。如果他们按下Esc键,预览窗口将被关闭并销毁,输入焦点将返回到应用程序的主窗口。如果他们按下Home、End、Page Up、Page Down键或上下箭头键,将调用OnVerticalScroll
以执行适当的操作:
bool PrintPreviewDocument::OnKeyDown
(WORD key, bool shiftPressed, bool controlPressed) {
switch (key) {
case KeyEscape: {
Window* parentWindow = ParentWindowPtr();
::CloseWindow(WindowHandle());
parentWindow->SetFocus();
}
break;
case KeyHome:
OnVerticalScroll(SB_TOP);
break;
case KeyEnd:
OnVerticalScroll(SB_BOTTOM);
break;
case KeyUp:
case KeyPageUp:
OnVerticalScroll(SB_LINEUP);
break;
case KeyDown:
case KeyPageDown:
OnVerticalScroll(SB_LINEDOWN);
break;
}
我们返回true
以指示已使用键盘输入:
return true;
}
滚动条
当用户滚动垂直条时,会调用OnVerticalScroll
函数。如果他们点击滚动条本身,在滚动滑块上方或下方,将显示上一页或下一页。如果他们将滑块拖动到新位置,将计算相应的页面。包括SB_TOP
和SB_BOTTOM
情况是为了适应前面OnKeyDown
函数中的Home和End键,而不是为了适应任何滚动操作;它们将页面设置为第一页或最后一页:
void PrintPreviewDocument::OnVerticalScroll(WORD flags,
WORD thumbPos /* = 0 */) {
int oldPage = page;
switch (flags) {
case SB_LINEUP:
case SB_PAGEUP:
page = max(1, page - 1);
break;
case SB_LINEDOWN:
case SB_PAGEDOWN:
page = min(page + 1, totalPages);
break;
case SB_THUMBTRACK:
case SB_THUMBPOSITION:
page = (thumbPos / pageSize.Height()) + 1;
break;
case SB_TOP:
page = 1;
break;
case SB_BOTTOM:
page = totalPages;
break;
}
如果滚动操作导致出现新页面,我们将设置标题和滚动条位置,并使窗口无效并更新:
if (oldPage != page) {
SetHeader();
SetVerticalScrollPosition((page - 1) * pageSize.Height());
Invalidate();
UpdateWindow();
}
}
PrintPreviewDocument
中的OnPaint
函数调用父标准文档窗口中的OnPaint
以绘制预览窗口的内容:
void PrintPreviewDocument::OnPaint(Graphics& graphics) const {
StandardDocument* parentDocument =
(StandardDocument*) ParentWindowPtr();
parentDocument->OnPrint(graphics, page, 1, totalPages);
}
};
摘要
在本章中,我们探讨了注册表、剪贴板、标准对话框和打印预览。在第十四章中,我们将探讨自定义对话框、控件、转换器和页面设置。
第十四章。对话框、控件和页面设置
在本章中,我们将探讨以下内容的实现:
-
自定义对话框:
Dialog
类旨在被子类继承并配备控件。 -
控件:
Control
类及其子类。有用于编辑字段、复选框、单选按钮、列表框和组合框的控件。 -
转换器:在字符串和其他值之间。例如,当用户输入代表数值的文本时,可以添加一个转换器将文本转换为值,或者如果文本不包含有效值,则显示错误消息。
-
页面设置:在这里我们扩展
Dialog
类。当设置StandardDocument
类的文档页面设置时使用对话框。它处理有关页眉、页脚和边距的信息。
自定义对话框
Dialog
类处理一组控件,这些控件通过AddControl
方法添加到对话框中。对于Dialog
类的子类,请参考本章最后部分的PageSetupDialog
。Dialog
类提供了一个模态对话框,这意味着在对话框关闭之前,应用程序中的所有其他窗口都将被禁用。
用户可以使用Tab键在控件之间导航,使用箭头键在同一个组中的单选按钮之间导航。他们还可以使用快捷键来访问控件。
Dialog.h
namespace SmallWindows {
dialogMap
字段由DialogProc
用于查找接收消息的对话框:
extern map<HWND,Dialog*> dialogMap;
extern Font DialogFont;
尽管Dialog
类调用了默认的Window
构造函数,该构造函数不调用 Win32 API 函数CreateWindowEx
,但Dialog
类仍然是Window
的子类。DoModal
收集有关对话框及其控件的信息,并调用 Win32 API 函数DialogBoxIndirectParam
:
class Dialog : public Window {
public:
Dialog(String name, Point topLeft,
Window* parentPtr = nullptr,
WindowStyle style = OverlappedWindow,
WindowStyle extendedStyle = NoStyle,
Font font = DialogFont);
如其名所示,DoModal
在其父窗口可见期间禁用其父窗口。也就是说,直到用户关闭对话框:
bool DoModal();
析构函数删除所有控件,这意味着Dialog
的子类应该在对话框中动态分配控件而不删除它们:
~Dialog();
AddControl
方法为控件分配一个身份号码并将其添加到idMap
。
int AddControl(Control* controlPtr);
每当用户更改对话框的大小时,都会调用OnSize
函数,它会遍历控件并调整它们的大小,以便它们保持相对于对话框客户端区域的大小。
void OnSize(Size windowSize);
当用户按下回车键时,会调用OnReturn
,而当他们按下Esc键时,会调用OnEscape
。它们的默认行为是关闭对话框并将控制权返回给DoModal
,返回码为 1 和 0;1 被解释为true
,0 被解释为false
。
void OnReturn();
void OnEscape();
OnControlInit
方法旨在被子类覆盖,并在对话框初始化时(当它接收WM_INITDIALOG
消息时)被调用。
virtual void OnDialogInit() {/* Empty. */}
TryClose
方法旨在被子类覆盖,其默认行为是返回true
。当用户尝试关闭对话框时,会调用OnClose
方法,其默认行为是调用TryClose
,如果返回true
则关闭对话框,在这种情况下也会调用OnDestroy
:
virtual bool TryClose() const {return true;}
virtual void OnClose();
virtual void OnDestroy() {/* Empty. */}
每个控件在添加到对话框时都会分配一个标识符,该标识符映射到idMap
中控制器的指针:
map<WORD,Control*> IdMap() const {return idMap;}
map<WORD,Control*>& IdMap() {return idMap;}
private:
map<WORD,Control*> idMap;
对话框有一个标题文本、左上角位置、字体、常规样式和扩展样式,这些由构造函数存储,并在DialogBoxIndirectParam
调用中由DoModal
使用。然而,对话框的大小不是构造函数参数;相反,大小基于控件尺寸:
String header;
Point topLeft;
Font font;
WindowStyle style;
WindowStyle extendedStyle;
在计算对话框大小时,使用leftMargin
、maxWidth
、topMargin
和maxHeight
字段。其思路是调整大小,使左右边距以及最接近的控件的上边距和下边距相等:
int leftMargin, maxWidth, topMargin, maxHeight;
第一个控件没有分配 0 的标识符,因为这会导致在处理消息时产生混淆,如果具有标识符 0 的控件是按钮。相反,我们用 1000 初始化currentId
,并且随着每个新控件的增加而减少其值。减少值是为了使对话框中的Tab键能够正确工作:
int currentId = 1000;
当对话框初始化(通过接收WM_INITDIALOG
消息)时,其大小被存储在originalClientSize
中,以便在OnSize
计算控件大小时使用:
Size originalClientSize;
每次对话框收到消息时都会调用DialogProc
方法。与WindowProc
不同,如果消息已被处理且不需要进一步处理,它将返回TRUE
。此外,它不会在末尾调用DefWindowProc
;相反,如果消息未被处理,它将返回FALSE
:
friend INT_PTR CALLBACK
DialogProc(HWND windowHandle, UINT message,
WPARAM wordParam, LPARAM longParam);
};
};
Dialog.cpp
#include "SmallWindows.h"
namespace SmallWindows {
map<HWND,Dialog*> dialogMap;
默认对话框字体设置为 12 点 Times New Roman。
Font DialogFont(TEXT("Times New Roman"), 12);
构造函数调用Window
构造函数,它设置父窗口指针并执行其他操作。也就是说,它不会调用 Win32 API 函数CreateWindowEx
。header
、topLeft
、style
、extendedStyle
和font
字段被存储起来,以便在DoModal
中使用:
Dialog::Dialog(String header, Point topLeft,
Window* parentPtr /*=nullptr*/,
WindowStyle style /* = OverlappedWindow */,
WindowStyle extendedStyle /* = NoStyle */,
Font font /* = DialogFont */)
:Window(parentPtr),
header(header),
topLeft(topLeft),
style(style),
extendedStyle(extendedStyle),
font(font) {
// Empty.
}
DoModal
函数使对话框进入模态状态。也就是说,其父窗口在对话框被销毁之前将变为禁用状态。但是,它首先将信息加载到infoList
中。AddValue
方法是InfoList
类的模板方法,并将不同类型的值添加到列表中:
bool Dialog::DoModal() {
InfoList infoList;
首先,我们需要添加值1
,以便设置我们想要工作的对话框模板版本:
infoList.AddValue<WORD>(1);
0xFFFF
值表示我们想要使用扩展对话框模板:
infoList.AddValue<WORD>(0xFFFF);
下一个单词是为了帮助标识符;然而,我们不使用它,所以我们将其设置为 0:
infoList.AddValue<DWORD>(0);
然后是扩展和常规样式。除了发送给构造函数的样式外,我们还设置对话框具有标题、系统菜单、模态框架和字体。由于DS_SETFONT
标志,我们将在稍后添加有关对话框字体信息:
infoList.AddValue<DWORD>(extendedStyle);
infoList.AddValue<DWORD>(style | WS_CAPTION | WS_SYSMENU |
DS_MODALFRAME | DS_SETFONT);
下一个值是对话框中控件的数量,由idMap
的大小给出:
infoList.AddValue<WORD>(idMap.size());
顶部左边的位置由topLeft
字段给出:
infoList.AddValue<WORD>(topLeft.X());
infoList.AddValue<WORD>(topLeft.Y());
对话框客户端区域的大小由maxWidth
、leftMargin
、maxHeight
和topMargin
设置,这些已在AddControl
中计算。客户端区域的宽度是控件集的最大宽度加上其左边距。这样,我们调整对话框以容纳具有相等左右边距以及上下边距的控件:
infoList.AddValue<WORD>(maxWidth + leftMargin);
infoList.AddValue<WORD>(maxHeight + topMargin);
接下来的两个零表示我们不希望使用菜单,并且我们使用默认的对话框Windows
类:
infoList.AddValue<WORD>(0);
infoList.AddValue<WORD>(0);
然后,我们设置对话框的标题。AddString
方法是一个InfoList
模板方法,它将带有终止符 0 的字符串添加到信息列表:
infoList.AddString<TCHAR>(header);
最后,我们设置对话框的字体。我们从Font
类的LOGFONT
结构中提取其大小(lfHeight
)、是否加粗(lfWeight
)或斜体,其字符集(由于我们不使用它,所以为 0)和字体名称:
LOGFONT logFont = font.LogFont();
infoList.AddValue<WORD>((WORD) logFont.lfHeight);
infoList.AddValue<WORD>((WORD) logFont.lfWeight);
infoList.AddValue<BYTE>(logFont.lfItalic);
infoList.AddValue<BYTE>(logFont.lfCharSet);
infoList.AddString<TCHAR>(logFont.lfFaceName);
当对话框信息已添加到信息列表中时,我们为每个控件调用AddControlInfo
,以便将控件信息添加到列表:
for (pair<WORD,Control*> entry : idMap) {
Control* controlPtr = entry.second;
controlPtr->AddControlInfo(infoList);
}
当列表已完全加载时,我们分配一个全局缓冲区并将其加载到列表中。ToBuffer
方法将列表复制到缓冲区:
HGLOBAL globalHandle = ::GlobalAlloc(0, infoList.Size());
if (globalHandle != nullptr) {
char* buffer = (char*) ::GlobalLock(globalHandle);
if (buffer != nullptr) {
infoList.ToBuffer(buffer);
如果存在,我们需要父窗口的句柄,然后我们通过调用 Win32 API 函数DialogBoxIndirectParam
创建对话框,该函数将在用户关闭对话框之前不返回。最后一个参数是Dialog
对象的指针,它将与WM_INITDIALOG
消息一起发送。存储在result
中的返回值是EndDialog
调用的第二个参数:
HWND parentHandle = (parentPtr != nullptr) ?
parentPtr->WindowHandle() : nullptr;
INT_PTR result =
::DialogBoxIndirectParam(Application::InstanceHandle(),
(DLGTEMPLATE*) buffer, parentHandle,
DialogProc, (LPARAM) this);
::GlobalUnlock(globalHandle);
如果结果值不等于 0,我们返回true
:
return (result != 0);
}
}
如果全局缓冲区分配失败,我们返回false
:
return false;
}
析构函数遍历idMap
并删除对话框中的每个控件:
Dialog::~Dialog() {
for (pair<WORD,Control*> entry : idMap) {
Control* controlPtr = entry.second;
delete controlPtr;
}
}
AddControl
方法将控件添加到对话框。如果是第一个要添加的控件(idMap
为空),则将leftMargin
和topMargin
设置为控件的左上角,并将maxWidth
和maxHeight
设置为左上角加上控件的宽度或高度。但是,如果不是,我们需要比较的第一个控件是其左上角和大小,与当前值,以找到控件集的边距和最大大小:
int Dialog::AddControl(Control* controlPtr) {
Point topLeft = controlPtr->TopLeft();
Size controlSize = controlPtr->GetSize();
if (idMap.empty()) {
leftMargin = topLeft.X();
topMargin = topLeft.X();
maxWidth = topLeft.X() + controlSize.Width();
maxHeight = topLeft.Y() + controlSize.Height();
}
else {
leftMargin = min(leftMargin, topLeft.X());
topMargin = min(topMargin, topLeft.Y());
maxWidth = max(maxWidth, topLeft.X() + controlSize.Width());
maxHeight = max(maxHeight,topLeft.Y()+controlSize.Height());
}
控件的标识号设置为currentId
,然后返回并递减:
idMap[currentId] = controlPtr;
return currentId--;
}
OnSize
方法比较客户端区域的新大小与其原始大小。它们之间的比率存储在factorPair
中:
void Dialog::OnSize(Size newClientSize) {
pair<double, double> factorPair
(((double) newClientSize.Width() /
originalClientSize.Width()),
((double) newClientSize.Height() /
originalClientSize.Height()));
idMap
的控件被迭代,每个控件的原始大小乘以factorPair
,这是新客户端区域大小与原始客户端区域大小的比率。这样,当用户改变对话框大小时,控件将保持它们相对于对话框客户端区域大小的相对大小。
for (pair<WORD,Control*> entry : idMap) {
Control* controlPtr = entry.second;
Rect originalRect = controlPtr->OriginalRect();
controlPtr->SetWindowDeviceRect(factorPair * originalRect);
}
}
当用户按下Return键时调用OnReturn
方法,当用户按下Esc键时调用OnEscape
,当用户关闭对话框时调用OnClose
。默认行为是调用TryClose
,如果它返回true
,则调用 Win32 API 函数EndDialog
,这将导致DoModal
中的DialogBoxIndirectParam
调用返回EndDialog
的第二个参数给出的整数值:
void Dialog::OnReturn() {
if (TryClose()) {
::EndDialog(windowHandle, 1);
}
}
void Dialog::OnEscape() {
if (TryClose()) {
::EndDialog(windowHandle, 0);
}
}
void Dialog::OnClose() {
if (TryClose()) {
::EndDialog(windowHandle, 0);
}
}
每次对话框收到消息时调用DialogProc
方法。第一个参数是对话框的句柄,通过dialogMap
映射到Dialog
指针:
INT_PTR CALLBACK DialogProc(HWND dialogHandle, UINT message,
WPARAM wordParam, LPARAM longParam){
switch (message) {
当对话框创建时调用WM_INITDIALOG
情况,但在它变得可见之前。当对话框通过DialogBoxIndirectParam
方法创建时,最后一个参数是指向封装的Dialog
对象的指针。该指针在longParam
参数中给出,它被转换为指向Dialog
的指针,并添加到dialogMap
中:
case WM_INITDIALOG: {
Dialog* dialogPtr = (Dialog*) longParam;
dialogMap[dialogHandle] = dialogPtr;
将对话框的 Win32 API 窗口句柄分配给dialogHandle
,计算并存储客户端区域的原始大小在originalClientSize
中,并调用OnDialogInit
:
dialogPtr->WindowHandle() = dialogHandle;
dialogPtr->originalClientSize =
dialogPtr->GetClientDeviceSize();
dialogPtr->OnDialogInit();
对于对话框中的每个控件,通过调用 Win32 API 函数GetDlgItem
设置其窗口句柄,该函数接受对话框窗口句柄和由AddControl
设置的控件身份号码。类似于对话框的原始客户端大小,控件的原始大小和位置也被存储。最后,对每个控件调用OnControlInit
:
for (pair<WORD,Control*> entry : dialogPtr->IdMap()) {
WORD controlId = entry.first;
Control* controlPtr = entry.second;
controlPtr->WindowHandle() =
::GetDlgItem(dialogHandle,controlId);
controlPtr->OriginalRect() =
controlPtr->GetWindowDeviceRect();
controlPtr->OnControlInit(dialogPtr);
}
}
消息处理完毕后,返回TRUE
:
return TRUE;
每次对话框的大小发生变化时,都会向对话框发送WM_SIZE
情况。宽度和高度存储在longParam
参数的低位和高位字中。调用OnSize
方法以处理该消息:
case WM_SIZE: {
Dialog* dialogPtr = dialogMap[dialogHandle];
assert(dialogPtr != nullptr);
Size clientSize =
{LOWORD(longParam), HIWORD(longParam)};
dialogPtr->OnSize(clientSize);
}
return TRUE;
当用户尝试关闭对话框时调用WM_CLOSE
情况。调用OnClose
方法来处理消息,该消息可能会也可能不会关闭对话框:
case WM_CLOSE: {
Dialog* dialogPtr = dialogMap[dialogHandle];
assert(dialogPtr != nullptr);
dialogPtr->OnClose();
}
return TRUE;
当对话框正在被销毁时调用WM_DESTROY
情况。与WM_CLOSE
不同,无法阻止对话框被销毁。由于WM_DESTROY
是发送到对话框的最后一个消息,对话框从dialogMap
中移除:
case WM_DESTROY: {
Dialog* dialogPtr = dialogMap[dialogHandle];
dialogPtr->OnDestroy();
dialogMap.erase(dialogHandle);
}
return TRUE;
当用户使用其中一个控件执行某些操作时,将发送WM_COMMAND
消息到对话框。在涉及控件的行动中,其身份号码存储在wordParam
的低位字中:
case WM_COMMAND: {
Dialog* dialogPtr = dialogMap[dialogHandle];
WORD controlId = LOWORD(wordParam);
如果身份号码是IDOK
或IDCANCEL
,则用户按下了Return或Esc键:
switch (controlId) {
case IDOK:
dialogPtr->OnReturn();
break;
case IDCANCEL:
dialogPtr->OnEscape();
break;
如果身份号码不是IDOK
或IDCANCEL
,我们使用idMap
和wordParam
的高位字中的通知代码查找控件。通知代码可能具有与IDOK
或IDCANCEL
相同的值,这就是为什么我们使用这种有些繁琐的结构来处理代码的原因:
default: {
Control* controlPtr =
dialogPtr->IdMap()[controlId];
WORD notificationCode = HIWORD(wordParam);
当控件获得或失去输入焦点时,调用OnGainFocus
或OnLoseFocus
;当它们更改文本字段的输入文本时,调用OnChange
;当它们更改组合框、列表框或多个列表框的选择时,调用OnSelect
;当它们点击按钮、复选框或单选按钮时,调用OnClick
:
switch (notificationCode) {
case EN_SETFOCUS:
controlPtr->OnGainFocus(dialogPtr);
break;
case EN_KILLFOCUS:
controlPtr->OnLoseFocus(dialogPtr);
break;
case EN_CHANGE:
controlPtr->OnChange(dialogPtr);
break;
case CBN_SELCHANGE:
controlPtr->OnSelect(dialogPtr);
break;
case BN_CLICKED:
controlPtr->OnClick(dialogPtr);
break;
}
}
}
}
当命令消息已被处理时,无需进一步处理。因此,我们返回true
:
return TRUE;
}
如果消息未被处理,我们返回false
以便消息可以被 Windows 系统进一步处理:
return FALSE;
}
};
控件
这里是小型窗口控件层次结构:
Control.h
namespace SmallWindows {
class Dialog;
构造函数将父窗口指针发送到Window
构造函数,并将其他值存储起来,直到通过AddControlInfo
将其添加到对话框信息列表中:
class Control : public Window {
public:
Control(Dialog* parentPtr, Point topLeft, Size controlSize,
String className, String text, int style);
void AddControlInfo(InfoList& infoList) const;
Point TopLeft() const {return topLeft;}
Size GetSize() const {return controlSize;}
以下方法旨在由子类重写,并且默认为空:
virtual void OnControlInit(Dialog* dialogPtr) {/* Empty. */}
virtual void OnGainFocus(Dialog* dialogPtr) {/* Empty. */}
virtual void OnLoseFocus(Dialog* dialogPtr) {/* Empty. */}
virtual void OnChange(Dialog* dialogPtr) {/* Empty. */}
virtual void OnSelect(Dialog* dialogPtr) {/* Empty. */}
virtual void OnClick(Dialog* dialogPtr) {/* Empty. */}
持有原始大小和位置的矩形由Dialog
在接收到MW_INITDIALOG
消息时设置:
Rect OriginalRect() const {return originalRect;}
Rect& OriginalRect() {return originalRect;}
private:
Rect originalRect;
每个控件都有一个身份号码,由Dialog
中的AddControl
提供。它有一个常规样式;扩展样式始终为 0。样式、左上角和控件大小、类名以及控件文本在Dialog
中的DoModal
调用AddControlInfo
时添加到信息列表中:
int controlId, style;
Point topLeft;
Size controlSize;
String className;
String text;
};
};
Control.cpp
#include "..\\SmallWindows.h"
构造函数为其父对话框调用AddControl
以将控件添加到对话框并接收控件的身份号码:
namespace SmallWindows {
Control::Control(Dialog* parentPtr, Point topLeft,
Size controlSize, String className,
String text, int style)
:Window(parentPtr),
topLeft(topLeft),
controlSize(controlSize),
className(className),
text(text),
style(style) {
controlId = parentPtr->AddControl(this);
}
AddControlInfo
方法,由Dialog
中的DoModal
调用,添加控件信息。首先,我们需要将信息列表与双字大小(4 字节)对齐:
void Control::AddControlInfo(InfoList& infoList) const {
infoList.Align<DWORD>();
帮助身份和扩展样式始终为 0:
infoList.AddValue<DWORD>(0);
infoList.AddValue<DWORD>(0);
样式通过子窗口和可见标志扩展,表示控件是对话框的子窗口,并且当对话框可见时它变得可见:
infoList.AddValue<DWORD>(WS_CHILD | WS_VISIBLE | style);
控件的上角和大小以对话框单位给出,这些单位基于对话框字体,并转换为设备单位:
infoList.AddValue<WORD>(topLeft.X());
infoList.AddValue<WORD>(topLeft.Y());
infoList.AddValue<WORD>(controlSize.Width());
infoList.AddValue<WORD>(controlSize.Height());
控制身份号码用于在用户执行某些操作时识别控制,例如点击按钮或选择列表项:
infoList.AddValue<DWORD>(controlId);
每个控件都有一个类名,它是按钮、列表、组合、静态(标签)或编辑(文本字段),以及文本,它是文本字段的文本或框或按钮的标签,但对于列表和组合框则忽略:
infoList.AddString<TCHAR>(className);
infoList.AddString<TCHAR>(text);
最后,可以与控件一起发送额外数据。然而,我们放弃了这个机会,只发送了 0:
infoList.AddValue<WORD>(0);
}
};
按钮控件
有四种按钮控件:组合框、按钮、复选框和单选按钮。复选框和单选按钮可以被选中;Check
和IsChecked
方法在ButtonControl
中定义。
ButtonControl.h
namespace SmallWindows {
class ButtonControl : public Control {
public:
ButtonControl(Dialog* parentPtr, Point topLeft,
Size controlSize, String text, int style);
protected:
void Check(bool check) const;
bool IsChecked() const;
};
};
ButtonControl.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
ButtonControl::ButtonControl(Dialog* parentPtr, Point topLeft,
Size controlSize, String text, int style)
:Control(parentPtr, topLeft, controlSize,
TEXT("button"), text, style) {
// Empty.
}
我们向复选框、复选框或单选按钮发送BM_SETCHECK
消息,以确定它是否被选中,并通过发送BM_GETCHECK
消息来查找它是否被选中:
void ButtonControl::Check(bool check) const {
::SendMessage(windowHandle, BM_SETCHECK, check ? 1 : 0, 0);
}
bool ButtonControl::IsChecked() const {
return (::SendMessage(windowHandle, BM_GETCHECK, 0, 0) != 0);
}
};
组合框非常简单;它封装了一组其他控件,除了其图形外观外没有其他功能。
GroupBox.h
namespace SmallWindows {
class GroupBox : public ButtonControl {
public:
GroupBox(Dialog* parentPtr, Point topLeft,
Size controlSize, String text);
};
};
GroupBox.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
GroupBox::GroupBox(Dialog* parentPtr, Point topLeft,
Size controlSize, String text)
:ButtonControl(parentPtr, topLeft, controlSize,
text, BS_GROUPBOX) {
// Empty.
}
};
clickListener
构造函数参数是一个当用户点击按钮时被调用的监听器。OnClick
方法覆盖了Control
类。
PushButton.h
namespace SmallWindows {
class PushButton : public ButtonControl {
public:
PushButton(Dialog* parentPtr, Point topLeft,
Size controlSize, String text,
VoidListener clickListener,
bool default = false);
void OnClick(Dialog* dialogPtr);
private:
VoidListener clickListener;
};
};
PushButton.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
PushButton::PushButton(Dialog* parentPtr, Point topLeft,
Size controlSize, String text,
VoidListener clickListener,
bool default /* = false */)
:ButtonControl(parentPtr, topLeft, controlSize, text,
WS_BORDER | WS_GROUP| WS_TABSTOP |
(default ? BS_DEFPUSHBUTTON : BS_PUSHBUTTON)),
clickListener(clickListener) {
// Empty.
}
void PushButton::OnClick(Dialog* dialogPtr) {
clickListener(dialogPtr);
}
};
复选框独立于其他复选框工作。checkPtr
参数是一个指向Boolean
值的指针,该值设置为true
或false
,具体取决于复选框是否被选中。
CheckBox.h
namespace SmallWindows {
class CheckBox : public ButtonControl {
public:
CheckBox(Dialog* parentPtr, Point topLeft,
Size controlSize, String text, bool* checkPtr);
private:
void OnControlInit(Dialog* dialogPtr);
void OnClick(Dialog* dialogPtr);
bool* checkPtr;
};
};
CheckBox.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
CheckBox::CheckBox(Dialog* parentPtr, Point topLeft,
Size controlSize, String text, bool* checkPtr)
:ButtonControl(parentPtr, topLeft, controlSize, text,
BS_AUTOCHECKBOX | WS_GROUP | WS_TABSTOP),
checkPtr(checkPtr) {
}
OnControlInit
方法覆盖了Control
类,并根据checkPtr
指向的值来检查复选框:OnClick
方法也覆盖了Control
类,如果复选框被选中,则将值设置为true
:
void CheckBox::OnControlInit(Dialog* dialogPtr) {
Check(*checkPtr);
}
void CheckBox::OnClick(Dialog* dialogPtr) {
*checkPtr = IsChecked();
}
};
单选按钮旨在与组中的其他单选按钮一起工作,每次恰好选中一个按钮。当用户在组中选中一个按钮时,它会被选中,而之前选中的按钮会被取消选中。组中的每个单选按钮都有一个基于零的索引;indexPtr
指向一个整数值,该值对所有组中的单选按钮都是共同的,并将其设置为当前选中的按钮的索引。
RadioButton.h
namespace SmallWindows {
class RadioButton : public ButtonControl {
public:
RadioButton(Dialog* parentPtr, Point topLeft, Size size,
String text, int* indexPtr, int index);
void OnControlInit(Dialog* dialogPtr);
void OnClick(Dialog* dialogPtr);
private:
int *indexPtr, index;
};
};
RadioButton.cpp
#include "..\\SmallWindows.h"
如果索引为 0,则构造函数将组和标签停止样式发送到Control
构造函数,因为第一个按钮是组中的第一个按钮。组中的所有按钮都不会通过***Tab***
键访问,而只有第一个按钮。group
样式表示按钮开始一个组,所有附加的单选按钮都被视为组的成员,直到添加了具有group
样式的另一个按钮:
namespace SmallWindows {
RadioButton::RadioButton(Dialog* parentPtr, Point topLeft,
Size size, String text, int* indexPtr,
int index)
:ButtonControl(parentPtr, topLeft, size, text,
BS_AUTORADIOBUTTON |
((index == 0) ? (WS_GROUP | WS_TABSTOP) : 0)),
indexPtr(indexPtr),
index(index) {
// Empty.
}
如果单选按钮的索引与indexPtr
指向的值相同,则该单选按钮被选中,并将值设置为已选按钮的索引:
void RadioButton::OnControlInit(Dialog* dialogPtr) {
Check((*indexPtr) == index);
}
void RadioButton::OnClick(Dialog* dialogPtr) {
*indexPtr = index;
}
};
列表控件
列表框有两种类型:单选列表框和复选列表框。单选列表框一次只能选择一个项目,而复选列表框可以同时选择一个或多个(或全部不选)项目。构造函数接受一个字符串列表,该列表通过LoadList
加载到列表框中。
ListControl.h
namespace SmallWindows {
class ListControl : public Control {
public:
ListControl(Dialog* parentPtr, Point topLeft,
Size controlSize, int style,
list<String> textList);
protected:
void LoadList() const;
private:
list<String> textList;
};
};
ListControl.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
ListControl::ListControl(Dialog* parentPtr, Point topLeft,
Size controlSize, int style,
list<String> textList)
:Control(parentPtr, topLeft, controlSize,
TEXT("listbox"), TEXT(""), style),
textList(textList) {
// Empty.
}
LoadList
方法通过调用LB_ADDSTRING
消息将textList
中的项目文本添加到(单选或复选)列表框中:
void ListControl::LoadList() const {
for (String text : textList) {
::SendMessage(windowHandle, LB_ADDSTRING,
0, (LPARAM) text.c_str());
}
}
};
一个(单个)列表框是一个包含可见项的框,与下拉列表的组合框相对。如果需要,列表可以滚动。一次只能选择一个项,与多列表不同。类似于单选框组,构造函数接受一个指向整数值的indexPtr
指针,该值表示当前选中项的零基于索引。此外,构造函数还接受一个字符串列表,该列表通过ListControl
中的LoadList
加载到列表框中。
ListBox.h
namespace SmallWindows {
class ListBox : public ListControl {
public:
ListBox(Dialog* parentPtr, Point topLeft, Size controlSize,
initializer_list<String> textList, int* indexPtr);
void OnControlInit(Dialog* dialogPtr);
void OnSelect(Dialog* dialogPtr);
private:
void SelectList(int index) const;
int GetListSelection() const;
int* indexPtr;
};
};
ListBox.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
ListBox::ListBox(Dialog* parentPtr, Point topLeft,
Size controlSize, initializer_list<String> textList,
int* indexPtr)
:ListControl(parentPtr, topLeft, controlSize, WS_VSCROLL |
WS_BORDER | LBS_NOTIFY | WS_GROUP | WS_TABSTOP,
textList),
indexPtr(indexPtr) {
// Empty.
}
void ListBox::OnControlInit(Dialog* dialogPtr) {
LoadList();
SelectList(*indexPtr);
}
void ListBox::OnSelect(Dialog* dialogPtr) {
*indexPtr = GetListSelection();
}
我们发送LB_SETCURSEL
消息来选择一个项,并使用LB_GETCURSEL
来获取当前选中项的索引:
void ListBox::SelectList(int index) const {
::SendMessage(windowHandle, LB_SETCURSEL, index, 0);
}
int ListBox::GetListSelection() const {
return ::SendMessage(windowHandle, LB_GETCURSEL, 0, 0);
}
};
多列表框是一个用户可以选择多个值或根本不选择值的列表框;因此,indexSetPtr
参数是指向索引集的指针,而不是指向单个索引的指针。
MultipleListBox.h
namespace SmallWindows {
class MultipleListBox : public ListControl {
public:
MultipleListBox(Dialog* parentPtr, Point topLeft,
Size controlSize, initializer_list<String> textList,
set<int>* indexSetPtr);
void OnControlInit(Dialog* dialogPtr);
void OnSelect(Dialog* dialogPtr);
private:
void SelectMultiple(set<int>& indexSet) const;
set<int> GetSelectionMultiple() const;
set<int>* indexSetPtr;
};
};
MultipleListBox.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
MultipleListBox::MultipleListBox(Dialog* parentPtr,
Point topLeft, Size controlSize,
initializer_list<String> textList,
set<int>* indexSetPtr)
:ListControl(parentPtr, topLeft, controlSize, LBS_MULTIPLESEL |
WS_VSCROLL | WS_BORDER | LBS_NOTIFY | WS_GROUP |
WS_TABSTOP, textList),
indexSetPtr(indexSetPtr) {
// Empty.
}
void MultipleListBox::OnControlInit(Dialog* dialogPtr) {
LoadList();
SelectMultiple(*indexSetPtr);
}
void MultipleListBox::OnSelect(Dialog* dialogPtr) {
*indexSetPtr = GetSelectionMultiple();
}
当用户在多列表中选择 0 个或多个值时,我们遍历索引并为每个索引发送带有Boolean
值的LB_SETSEL
消息,该值指示其项是否将被设置:
void MultipleListBox::SelectMultiple(set<int>& indexSet) const {
int size = ::SendMessage(windowHandle, LB_GETCOUNT, 0, 0);
for (int index = 0; index < size; ++index) {
BOOL selected = (indexSet.count(index) > 0) ? TRUE : FALSE;
::SendMessage(windowHandle, LB_SETSEL, selected, index);
}
}
当检查哪些值当前被选中时,我们为每个索引发送LB_GETSEL
消息,并将选中项的索引添加到集合中,然后返回该集合:
set<int> MultipleListBox::GetSelectionMultiple() const {
int size = ::SendMessage(windowHandle, LB_GETCOUNT, 0, 0);
set<int> indexSet;
for (int index = 0; index < size; ++index) {
if (::SendMessage(windowHandle, LB_GETSEL, index, 0) != 0) {
indexSet.insert(index);
}
}
return indexSet;
}
};
组合框
组合框是一个下拉列表项,用户可以从中选择一个。组合框的功能与列表框相同,只是它们的图形外观不同。此外,功能也等同于单选按钮组。类似于ListBox
和Radiobutton
,构造函数接受一个indexPtr
参数,它是一个指向整数值的指针,表示当前选中项的零基于索引。
ComboBox.h
namespace SmallWindows {
class ComboBox : public Control {
public:
ComboBox(Dialog* parentPtr, Point topLeft, Size controlSize,
initializer_list<String> textList, int* indexPtr);
void OnControlInit(Dialog* dialogPtr);
void OnSelect(Dialog* dialogPtr);
private:
void LoadCombo() const;
void SelectCombo(int index) const;
int GetComboSelection() const;
list<String> textList;
int* indexPtr;
};
};
ComboBox.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
ComboBox::ComboBox(Dialog* parentPtr, Point topLeft,
Size controlSize, initializer_list<String> textList,
int* indexPtr)
:Control(parentPtr, topLeft, controlSize, TEXT("combobox"),
TEXT(""), CBS_DROPDOWN | CBS_HASSTRINGS | LBS_NOTIFY |
LBS_COMBOBOX | WS_GROUP | WS_TABSTOP),
textList(textList),
indexPtr(indexPtr) {
// Empty.
}
void ComboBox::OnControlInit(Dialog* dialogPtr) {
LoadCombo();
SelectCombo(*indexPtr);
}
void ComboBox::OnSelect(Dialog* dialogPtr) {
*indexPtr = GetComboSelection();
}
CB_ADDSTRING
消息将项加载到组合框中,CB_SETCURSEL
设置选中项,CB_GETCURSEL
返回选中项的索引:
void ComboBox::LoadCombo() const {
for (String text : textList) {
::SendMessage(windowHandle, CB_ADDSTRING,
0, (LPARAM) text.c_str());
}
}
void ComboBox::SelectCombo(int index) const {
::SendMessage(windowHandle, CB_SETCURSEL, index, 0);
}
int ComboBox::GetComboSelection() const {
return ::SendMessage(windowHandle, CB_GETCURSEL, 0, 0);
}
};
标签
标签是一种显示的文本,通常用作文本框的提示;除了其图形外观外,它没有其他功能。
Label.h
namespace SmallWindows {
class Label : public Control {
public:
Label(Dialog* parentPtr, Point topLeft,
Size controlSize, String text);
};
};
Label.cpp
#include "..\\SmallWindows.h"
namespace SmallWindows {
Label::Label(Dialog* parentPtr, Point topLeft,
Size controlSize, String text)
:Control(parentPtr, topLeft, controlSize,
TEXT("static"), text, 0) {
}
};
TextField
类
TextField
类是一个文本框的模板;它接受存储在文本框中的值的类型;八进制、十进制或十六进制整数的整数基数(对于非整型类型忽略);以及下一节中Converter
类的转换器,它可以在值和文本之间进行转换。构造函数的valuePtr
参数是指向要编辑的值的指针。
TextField.h
namespace SmallWindows {
enum EditStyle {LeftEdit = ES_LEFT, CenterEdit = ES_CENTER,
RightEdit = ES_RIGHT, DigitsOnly = ES_NUMBER,
ReadOnly = ES_READONLY, Password = ES_PASSWORD,
Uppercase = ES_UPPERCASE,Lowercase=ES_LOWERCASE,
AutoScroll = ES_AUTOHSCROLL};
enum {oct = 8, dec = 10, hex = 16};
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
class TextField : public Control {
public:
TextField(Dialog* parentPtr, Point topLeft,
Size controlSize, Type* valuePtr,
int size = 100, EditStyle style = AutoScroll);
当文本框被创建时,会调用OnControlInit
方法。它将值转换为文本框中显示的文本。当用户离开文本框时,会调用OnLoseFocus
方法,如果文本有效,则将其文本转换为模板类型的值。如果文本无效,文本框将设置为从最新有效值转换的文本:
void OnControlInit(Dialog* dialogPtr);
void OnLoseFocus(Dialog* dialogPtr);
protected:
String GetText() const;
void SetText(String text);
private:
Type* valuePtr;
};
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
TextField<Type,Base,TheConverter>::TextField
(Dialog* parentPtr, Point topLeft, Size controlSize,
Type* valuePtr, int size /* = 100 */,
EditStyle style /* = AutoScroll */)
:Control(parentPtr, topLeft, controlSize, TEXT("edit"),
TEXT(""), style | WS_BORDER | WS_GROUP | WS_TABSTOP),
valuePtr(valuePtr) {
// Empty.
}
Win32 API 函数GetWindowText
获取文本字段的文本,SetWindowText
设置其文本。我们需要通过调用String
构造函数将零终止的字符指针字符串转换为String
对象,并通过调用String
类的c_str
方法将String
对象转换为零终止的字符指针:
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
String TextField<Type,Base,TheConverter>::GetText() const {
TCHAR buffer[MAX_PATH];
::GetWindowText(windowHandle, buffer, MAX_PATH);
return String(buffer);
}
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
void TextField<Type,Base,TheConverter>::SetText(String text) {
::SetWindowText(windowHandle, text.c_str());
}
当文本字段已初始化时,Converter
类的ValueToText
方法被调用,以将valuePtr
指向的值转换为文本字段中显示的文本:
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
void TextField<Type,Base,TheConverter>::OnControlInit
(Dialog* dialogPtr) {
SetText(TheConverter::ValueToText(*valuePtr, Base));
}
当文本字段失去输入焦点时,文本将通过Check
方法进行评估,以确定它是否适合转换为值。如果适合,则调用ValueToText
方法进行实际转换,然后文本被加载到文本字段中:
template <class Type = String, int Base = dec,
class TheConverter = Converter<Type>>
void TextField<Type,Base,TheConverter>::OnLoseFocus
(Dialog* dialogPtr) {
String text = GetText();
if (TheConverter::Check(text, Base)) {
*valuePtr = TheConverter::TextToValue(text, Base);
}
SetText(TheConverter::ValueToText(*valuePtr, Base));
}
};
转换器
Converter
类是一个模板类,旨在通过类型进行特殊化。其任务是转换模板类型和String
对象之间的值。Check
变量接受一个字符串,如果它包含一个有效的值则返回true
,TextToValue
将文本转换为值,而ValueToText
将值转换为文本。
Converter.h
namespace SmallWindows {
template <class Type>
class Converter {
public:
static bool Check(String& text, int base);
static Type TextToValue(String& text, int base);
static String ValueToText(Type& value, int base);
};
有符号整数
小窗口自带一组预定义的转换器,这些是Converter
的特殊化。其中之一处理类型为int
的有符号整数值。
Converter.h
template <>
class Converter<int> {
public:
static bool Check(String& text, int base);
static int TextToValue(String& text, int base);
static String ValueToText(int& value, int base);
};
Converter.cpp
#include "SmallWindows.h"
当检查给定的字符串是否包含有效的整数值时,我们创建一个初始化为修剪后的文本(移除了初始和终止的空白字符)的IStringStream
对象(istringstream
的泛型版本,使用TCHAR
代替char
)。然后,我们使用基数参数将文本读取到整数变量中,并测试流是否已到达文件末尾(eof
)。如果已到达,则意味着已读取文本的所有字符,这表明文本包含一个有效的整数值,并返回true
:
namespace SmallWindows {
bool Converter<int>::Check(String& text, int base) {
IStringStream stringStream(Trim(text));
int value;
stringStream >> setbase(base) >> value;
return stringStream.eof();
}
字符串转换为整数的转换与之前我们提到的Check
函数类似,区别在于我们返回整数值,假设Check
已经确认文本包含一个有效的整数值:
int Converter<int>::TextToValue(String& text, int base) {
IStringStream stringStream(Trim(text));
int value;
stringStream >> setbase(base) >> value;
return value;
}
当将整数转换为字符串时,我们使用OStringStream
方法(ostringstream
的泛型版本),将值写入流,并通过str
将流转换为字符串返回:
String Converter<int>::ValueToText(int& value, int base) {
OStringStream outputStream;
outputStream << setbase(base) << value;
return outputStream.str();
}
无符号整数
无符号整数与有符号整数的工作方式相同,唯一的区别是int
被替换为unsigned int
:
Converter.h
template <>
class Converter<unsigned int> {
public:
static bool Check(String& text, int base);
static unsigned int TextToValue(String& text, int base);
static String ValueToText(unsigned int& value, int base);
};
Converter.cpp
bool Converter<unsigned int>::Check(String& text, int base) {
IStringStream stringStream(Trim(text));
unsigned int value;
stringStream >> setbase(base) >> value;
return stringStream.eof() && (text.find(TEXT("-")) == -1);
}
unsigned int Converter<unsigned int>::TextToValue(String& text,
int base){
IStringStream stringStream(Trim(text));
unsigned int value;
stringStream >> setbase(base) >> value;
return value;
}
String Converter<unsigned int>::ValueToText(unsigned int&value,
int base){
OStringStream outputStream;
outputStream << setbase(base) << value;
return outputStream.str();
}
双精度值
双精度值忽略基数参数,不使用setbase
操作符;否则,测试和转换与整数情况相同。
Converter.h
template <>
class Converter<double> {
public:
static bool Check(String& text, int /* base */);
static double TextToValue(String& text, int /* base */);
static String ValueToText(double& value, int /* base */);
};
Converter.cpp
bool Converter<double>::Check(String& text, int /* base */) {
IStringStream stringStream(Trim(text));
double value;
stringStream >> value;
return stringStream.eof();
}
double Converter<double>::TextToValue(String& text,
int /* base */) {
IStringStream stringStream(Trim(text));
double value;
stringStream >> value;
return value;
}
String Converter<double>::ValueToText(double& value,
int /* base */) {
OStringStream outputStream;
outputStream << value;
return outputStream.str();
}
字符串
字符串情况很简单,因为字符串总是可以转换为另一个字符串。
Converter.h
template <>
class Converter<String> {
public:
static bool Check(String& text, int /* base */)
{return true;}
static String TextToValue(String& text, int /* base */)
{return String(text);}
static String ValueToText(String& value, int /* base */)
{return String(value);}
};
有理数
一个有理数是一个可以表示为两个整数分数的数,其中第二个整数不为零。在我们的应用中,我们实际上并不使用有理数或下一节中的复数。它们仅用于演示转换器,并在书的附录中实现。
Converter.h
template <>
class Converter<Rational> {
public:
static bool Check(String& text, int /* base */);
static Rational TextToValue(String& text, int /* base */);
static String ValueToText(Rational& value, int /* base */);
};
当检查文本是否包含一个有效的有理数时,我们简单地创建一个Rational
类的对象。如果构造函数接受文本而不抛出NotaRationalNumber
异常,我们返回true
。如果它抛出异常,则文本不可接受,我们返回false
。
Converter.cpp
bool Converter<Rational>::Check(String& text, int /* base */) {
try {
Rational value(text);
return true;
}
catch (NotaRationalNumber) {
return false;
}
}
当将字符串转换为有理数时,我们创建并返回一个Rational
对象,假设Check
已经确认该文本包含一个有效的有理数:
Rational Converter<Rational>::TextToValue(String& text,
int /* base */) {
return Rational(text);
}
当将有理数转换为字符串时,我们调用Rational
类的String
转换操作符。
String Converter<Rational>::ValueToText(Rational& value,
int /* base */) {
return ((String) value);
}
复数
一个复数是实数x和实数y乘以虚数单位i的和,i是方程x² + 1 = 0 的解。Converter
类关于Complex
类的特殊化与Rational
特殊化类似。
Converter.h
template <>
class Converter<Complex> {
public:
static bool Check(String& text, int /* base */);
static Complex TextToValue(String& text, int /* base */);
static String ValueToText(Complex& value, int /* base */);
};
};
Converter.cpp
bool Converter<Complex>::Check(String& text, int /* base */) {
try {
Complex value(text);
return true;
}
catch (NotaComplexNumber) {
return false;
}
}
Complex Converter<Complex>::TextToValue(String& text,
int /* base */) {
return Complex(text);
}
String Converter<Complex>::ValueToText(Complex& value,
int /* base */) {
return ((String) value);
}
};
页面设置
最后一个部分描述了页面设置功能,分为处理页面设置信息的PageSetupInfo
类、用于用户输入页面设置信息的PageSetupDialog
子类,以及将用户在页面设置对话框中输入的代码转换为实际值的Template
函数。
页面设置信息
PageSetupInfo
类包含有关页面的信息:纵向或横向方向、页边距、页眉和页脚的文本和字体、页眉和页脚是否出现在第一页上,以及页面是否被框架包围。
PageSetupInfo.h
namespace SmallWindows {
enum Orientation {Portrait, Landscape};
class PageSetupInfo {
public:
PageSetupInfo();
PageSetupInfo(const PageSetupInfo& pageSetupInfo);
bool operator==(const PageSetupInfo& pageSetupInfo);
bool operator!=(const PageSetupInfo& pageSetupInfo);
void ClearPageSetupInfo();
bool WritePageSetupInfoToStream(ostream& outStream) const;
bool ReadPageSetupInfoFromStream(istream& inStream);
Orientation& GetOrientation() {return orientation;}
int& LeftMargin() {return leftMargin;}
int& TopMargin() {return topMargin;}
int& RightMargin() {return rightMargin;}
int& BottomMargin() {return bottomMargin;}
String& HeaderText() {return headerText;}
String& FooterText() {return footerText;}
bool& HeaderFirst() {return headerFirst;}
bool& FooterFirst() {return footerFirst;}
bool& Frame() {return frame;}
Font& HeaderFont() {return headerFont;}
Font& FooterFont() {return footerFont;}
Orientation GetOrientation() const {return orientation;}
int LeftMargin() const {return leftMargin;}
int TopMargin() const {return topMargin;}
int RightMargin() const {return rightMargin;}
int BottomMargin() const {return bottomMargin;}
String HeaderText() const {return headerText;}
String FooterText() const {return footerText;}
bool HeaderFirst() const {return headerFirst;}
bool FooterFirst() const {return footerFirst;}
bool Frame() const {return frame;}
Font HeaderFont() const {return headerFont;}
Font FooterFont() const {return footerFont;}
private:
Orientation orientation;
int leftMargin, topMargin, rightMargin, bottomMargin;
String headerText, footerText;
bool headerFirst, footerFirst, frame;
Font headerFont, footerFont;
};
};
PageSetupInfo.cpp
#include "..\\SmallWindows\\SmallWindows.h"
默认构造函数通过调用PageSetupInfo
初始化默认成员值。
namespace SmallWindows {
PageSetupInfo::PageSetupInfo() {
ClearPageSetupInfo();
}
默认构造函数和赋值操作符复制成员值。
PageSetupInfo::PageSetupInfo(const PageSetupInfo& pageSetupInfo)
:orientation(pageSetupInfo.orientation),
leftMargin(pageSetupInfo.leftMargin),
topMargin(pageSetupInfo.topMargin),
rightMargin(pageSetupInfo.rightMargin),
bottomMargin(pageSetupInfo.bottomMargin),
headerText(pageSetupInfo.headerText),
footerText(pageSetupInfo.footerText),
headerFirst(pageSetupInfo.headerFirst),
footerFirst(pageSetupInfo.footerFirst),
frame(pageSetupInfo.frame),
headerFont(pageSetupInfo.headerFont),
footerFont(pageSetupInfo.footerFont) {
// Empty.
}
等价操作符比较所有字段:
bool PageSetupInfo::operator==
(const PageSetupInfo& pageSetupInfo) {
return (orientation == pageSetupInfo.orientation) &&
(leftMargin == pageSetupInfo.leftMargin) &&
(topMargin == pageSetupInfo.topMargin) &&
(rightMargin == pageSetupInfo.rightMargin) &&
(bottomMargin == pageSetupInfo.bottomMargin) &&
(headerText == pageSetupInfo.headerText) &&
(footerText == pageSetupInfo.footerText) &&
(headerFirst == pageSetupInfo.headerFirst) &&
(footerFirst == pageSetupInfo.footerFirst) &&
(frame == pageSetupInfo.frame) &&
(headerFont == pageSetupInfo.headerFont) &&
(footerFont == pageSetupInfo.footerFont);
}
bool PageSetupInfo::operator!=
(const PageSetupInfo& pageSetupInfo) {
return !(*this == pageSetupInfo);
}
void PageSetupInfo::ClearPageSetupInfo() {
orientation = Portrait;
leftMargin = 25;
topMargin = 25;
rightMargin = 25;
bottomMargin = 25;
headerText = TEXT("");
footerText = TEXT("");
headerFirst = true;
footerFirst = true;
frame = true;
headerFont = Font(TEXT("Times New Roman"), 12, false, true);
footerFont = Font(TEXT("Times New Roman"), 12, false);
}
页面设置信息可以写入或从流中读取:
bool PageSetupInfo::WritePageSetupInfoToStream
(ostream& outStream) const {
outStream.write((char*) &orientation, sizeof orientation);
outStream.write((char*) &leftMargin, sizeof leftMargin);
outStream.write((char*) &topMargin, sizeof topMargin);
outStream.write((char*) &rightMargin, sizeof rightMargin);
outStream.write((char*) &bottomMargin, sizeof bottomMargin);
WriteStringToStream(headerText, outStream);
WriteStringToStream(footerText, outStream);
outStream.write((char*) &headerFirst, sizeof headerFirst);
outStream.write((char*) &footerFirst, sizeof footerFirst);
outStream.write((char*) &frame, sizeof frame);
headerFont.WriteFontToStream(outStream);
footerFont.WriteFontToStream(outStream);
return ((bool) outStream);
}
bool PageSetupInfo::ReadPageSetupInfoFromStream
(istream& inStream) {
inStream.read((char*) &orientation, sizeof orientation);
inStream.read((char*) &leftMargin, sizeof leftMargin);
inStream.read((char*) &topMargin, sizeof topMargin);
inStream.read((char*) &rightMargin, sizeof rightMargin);
inStream.read((char*) &bottomMargin, sizeof bottomMargin);
ReadStringFromStream(headerText, inStream);
ReadStringFromStream(footerText, inStream);
inStream.read((char*) &headerFirst, sizeof headerFirst);
inStream.read((char*) &footerFirst, sizeof footerFirst);
inStream.read((char*) &frame, sizeof frame);
headerFont.ReadFontFromStream(inStream);
footerFont.ReadFontFromStream(inStream);
return ((bool) inStream);
}
};
页面设置对话框
PageSetupDialog
类是 Small Windows 的一部分,当用户选择页面设置菜单项时,由StandardDocument
框架显示。本书前面的文字处理程序给出了一个示例。PageSetupDialog
类是Dialog
的子类,并允许用户在PageSetupInfo
中输入信息。请注意,页眉和页脚的文本可以用下一节中解释的代码块进行注释。
PageSetupDialog.h
namespace SmallWindows {
class PageSetupDialog : public Dialog {
public:
PageSetupDialog(Window* parentPtr, PageSetupInfo* infoPtr);
每个按钮都有一个自己的监听器:
DEFINE_VOID_LISTENER(PageSetupDialog, OnHeaderFont);
DEFINE_VOID_LISTENER(PageSetupDialog, OnFooterFont);
DEFINE_VOID_LISTENER(PageSetupDialog, OnOk);
DEFINE_VOID_LISTENER(PageSetupDialog, OnCancel);
页面设置信息由 infoPtr
指向,当用户更改控件的状态时,它将被修改。还有一个 backupInfo
,以防用户取消对话框:
private:
PageSetupInfo *infoPtr, backupInfo;
};
};
PageSetupDialog.cpp
#include "SmallWindows.h"
构造函数将指针 infoPtr
设置为指向页面设置信息。该信息也存储在 backupInfo
中,如果用户取消对话框,将使用它;请参阅 OnCancel
:
namespace SmallWindows {
PageSetupDialog::PageSetupDialog(Window* parentPtr,
PageSetupInfo* infoPtr)
:Dialog(TEXT("Page Setup"), Point(0, 0), parentPtr),
infoPtr(infoPtr),
backupInfo(*infoPtr) {
每个控件都将 页面设置 对话框(this
)作为其父对话框,这意味着控件将由对话框的析构函数删除。这表明我们确实需要跟踪控件以便手动删除。实际上,我们不会手动删除它们,因为这会导致悬空指针:
new GroupBox(this, Point(10, 10),
Size(330, 50), TEXT("Margins"));
new Label(this, Point(20, 20), Size(50, 10),
TEXT("&Top Margin:"));
注意,我们提供一个引用作为顶部边距值的指针。当用户更改值时,此值将被修改:
new TextField<int>(this, Point(70, 20), Size(100, 12),
&infoPtr->TopMargin());
new Label(this, Point(180, 20), Size(50, 10),
TEXT("&Bottom Margin:"));
new TextField<int>(this, Point(230, 20), Size(100, 12),
&infoPtr->BottomMargin());
new Label(this, Point(20, 40), Size(50, 10),
TEXT("&Left Margin:"));
new TextField<int>(this, Point(70, 40), Size(100, 12),
&infoPtr->LeftMargin());
new Label(this, Point(180, 40), Size(50, 10),
TEXT("&Right Margin:"));
new TextField<int>(this, Point(230, 40), Size(100, 12),
&infoPtr->RightMargin());
new GroupBox(this, Point(10, 70),
Size(330, 50), TEXT("Header"));
new Label(this, Point(20, 80), Size(50, 10),
TEXT("&Header Text:"));
new TextField<>(this, Point(70, 80), Size(260, 12),
&infoPtr->HeaderText());
与 TextField
的情况类似,我们提供一个指向 HeaderFirst
值的引用的指针,这是一个 Boolean
值。当用户勾选复选框时,它将被修改:
new CheckBox(this, Point(70, 100), Size(100, 10),
TEXT("H&eader at First Page"),
&infoPtr->HeaderFirst());
当用户按下按钮时,会调用 OnHeaderFont
监听器:
new PushButton(this, Point(270, 98), Size(60, 15),
TEXT("He&ader Font"), OnHeaderFont);
new GroupBox(this, Point(10, 130),
Size(330, 50), TEXT("Footer"));
new Label(this, Point(20, 140), Size(50, 10),
TEXT("&Footer Text:"));
new TextField<>(this, Point(70, 140), Size(260, 12),
&infoPtr->FooterText());
new CheckBox(this, Point(70, 160), Size(100, 10),
TEXT("F&ooter at First Page"),
&infoPtr->FooterFirst());
new PushButton(this, Point(270, 158), Size(60, 15),
TEXT("Footer Fo&nt"), OnFooterFont);
new Label(this, Point(20, 190), Size(40, 10),
TEXT("&Orientation:"));
new ComboBox(this, Point(65, 190), Size(70, 30),
{TEXT("Portrait"), TEXT("Landscape")},
(int*) &infoPtr->GetOrientation());
new CheckBox(this, Point(20, 205), Size(100, 10),
TEXT("Page &Surrounded by Frame"),
&infoPtr->Frame());
new PushButton(this, Point(200, 200),
Size(60, 15), TEXT("Ok"), OnOk);
new PushButton(this, Point(270, 200), Size(60, 15),
TEXT("Cancel"), OnCancel);
}
OnHeaderFont
和 OnFooterFont
方法显示字体对话框:
void PageSetupDialog::OnHeaderFont() {
StandardDialog::FontDialog(this, infoPtr->HeaderFont());
}
void PageSetupDialog::OnFooterFont() {
StandardDialog::FontDialog(this, infoPtr->FooterFont());
}
OnOk
和 OnCancel
方法用于终止对话框。OnCancel
方法还会复制构造函数在开始时存储的备份信息,因为当用户取消对话框时,不会返回任何新信息:
void PageSetupDialog::OnOk() {
Dialog::OnReturn();
}
void PageSetupDialog::OnCancel() {
*infoPtr = backupInfo;
Dialog::OnEscape();
}
};
模板函数
当用户在 页面设置 对话框中的页眉和页脚字段中输入文本时,他们可以在文本中插入代码,这些代码需要翻译成有效的值。代码如下表所示:
代码 | 描述 | 示例 |
---|---|---|
%P | 带后缀的路径 | C:\Test\Test.wrd |
%p | 无后缀的路径 | C:\Test\Test |
%F | 带后缀的文件 | Test.wrd |
%f | 无后缀的文件 | Test |
%N | 总页数 | 7 |
%n | 当前页 | 5 |
%c | 当前副本 | 3 |
%D | 带完整月份的日期 | 2016 年 1 月 1 日 |
%d | 带缩写月份的日期 | 2016 年 1 月 1 日 |
%T | 带秒的时间 | 07:08:09 |
%t | 不带秒的时间 | 07:08 |
%% | 百分号字符 | % |
Template
函数的任务是用有效值替换代码。它接受带有模板代码的 templateText
字符串,并返回用有效值替换代码的文本。它还需要当前副本和页码以及总页数。
例如,页 %n / 总页数 %N
文本可以翻译为 页 3 / 5,而 文件: %F,日期: %d
可以翻译为 文件: Text.txt,日期: 2016 年 12 月 31 日。
Template.h
namespace SmallWindows {
String Template(const Document* documentPtr, String templateText,
int copy = 0, int page = 0, int totalPages = 0);
};
Template.cpp
#include "SmallWindows.h"
namespace SmallWindows {
String Template(const Document* documentPtr, String templateText,
int copy /* = 0 */, int page /* = 0 */,
int totalPages /* = 0 */) {
我们首先用副本数和当前页以及总页数替换 c
、n
和 N
代码。数值通过 to_String
转换为字符串:
ReplaceAll(templateText, TEXT("%c"), to_String(copy));
ReplaceAll(templateText, TEXT("%n"), to_String(page));
ReplaceAll(templateText, TEXT("%N"), to_String(totalPages));
路径的文件是其最后一个反斜杠(**)之后的文本,后缀是其最后一个点(.**)之后的文本。如果没有反斜杠,文件与路径相同;如果没有点,没有后缀的路径和文件与带有后缀的文件和路径相同:
String pathWithSuffix = documentPtr->GetName();
ReplaceAll(templateText, TEXT("%P"), pathWithSuffix);
int lastPathDot = pathWithSuffix.find_last_of(TEXT('.'));
String pathWithoutSuffix =
pathWithSuffix.substr(0, lastPathDot);
ReplaceAll(templateText, TEXT("%p"), pathWithoutSuffix);
int lastBackslash = pathWithSuffix.find_last_of(TEXT(''));
String fileWithSuffix =
pathWithSuffix.substr(lastBackslash + 1);
ReplaceAll(templateText, TEXT("%F"), fileWithSuffix);
int lastFileDot = fileWithSuffix.find_last_of(TEXT('.'));
String fileWithoutSuffix =
fileWithSuffix.substr(0, lastFileDot);
ReplaceAll(templateText, TEXT("%f"), fileWithoutSuffix);
当前日期和时间是通过调用标准 C 函数time
和localtime_s
获得的:
time_t t = ::time(nullptr);
struct tm time;
::localtime_s(&time, &t);
当前时间(带或不带秒)和当前日期(带完整月份名称和缩写月份名称)被写入字符串输出流。setw
操纵符确保总是写入两个字符,setfill
在必要时用零填充,而ios::right
以右对齐的方式写入值:
{ OStringStream timeWithoutSeconds;
timeWithoutSeconds << std::setw(2) << setw(2)
<< setiosflags(ios::right)
<< setfill(TEXT('0')) << time.tm_hour
<< TEXT(":") << setiosflags(ios::right)
<< setw(2) << setfill(TEXT('0'))
<< time.tm_min;
ReplaceAll(templateText, TEXT("%t"),
timeWithoutSeconds.str());
OStringStream timeWithSeconds;
timeWithSeconds << timeWithoutSeconds.str() << TEXT(":")
<< setiosflags(ios::right) << setw(2)
<< setfill(TEXT('0')) << time.tm_sec;
ReplaceAll(templateText, TEXT("%T"), timeWithSeconds.str());
}
{ static const String longMonths[] =
{TEXT("January"), TEXT("February"), TEXT("March"),
TEXT("April"), TEXT("May"), TEXT("June"), TEXT("July"),
TEXT("August"), TEXT("September"), TEXT("October"),
TEXT("November"), TEXT("December")};
OStringStream dateFullMonth;
dateFullMonth << longMonths[time.tm_mon] << TEXT(" ")
<< time.tm_mday << TEXT(", ")
<< (1900 + time.tm_year);
ReplaceAll(templateText, TEXT("%D"), dateFullMonth.str());
}
{ static const String shortMonths[] =
{TEXT("Jan"), TEXT("Feb"), TEXT("Mar"), TEXT("Apr"),
TEXT("May"), TEXT("Jun"), TEXT("Jul"), TEXT("Aug"),
TEXT("Sep"), TEXT("Oct"), TEXT("Nov"), TEXT("Dec")};
OStringStream dateShortMonth;
dateShortMonth << shortMonths[time.tm_mon] << TEXT(" ")
<< time.tm_mday << TEXT(", ")
<< (1900 + time.tm_year);
ReplaceAll(templateText, TEXT("%d"), dateShortMonth.str());
}
最后,我们需要将每个%%
实例替换为%
:
ReplaceAll(templateText, TEXT("%%"), TEXT("%"));
return templateText;
}
};
摘要
在本章中,我们探讨了自定义对话框、控件、转换器和页面设置对话框。本书剩下的部分是理性类和复数类的实现:
附录 A. 有理数和复数
本附录定义了来自前一章转换器部分的Rational
和Complex
类。
有理数
有理数可以表示为两个整数的分数,称为分子和分母。
Rational.h
namespace SmallWindows {
class NotaRationalNumber : public exception {
public:
NotaRationalNumber() {/* Empty. */}
};
默认构造函数将分子和分母分别初始化为 0 和 1。第二个构造函数接受一个字符串,如果字符串不包含有效的有理数则抛出NotaRationalNumber
异常。复制构造函数和赋值运算符接受另一个有理数。String
转换运算符将有理数作为字符串返回:
class Rational {
public:
Rational(int numerator = 0, int denominator = 1);
Rational(const String& text);
Rational(const Rational &rational);
Rational operator=(const Rational &complex);
operator String() const;
bool operator==(const Rational &rational) const;
bool operator!=(const Rational &rational) const;
bool operator< (const Rational &rational) const;
bool operator<=(const Rational &rational) const;
bool operator> (const Rational &rational) const;
bool operator>=(const Rational &rational) const;
Rational operator+(const Rational &rational) const;
Rational operator-(const Rational &rational) const;
Rational operator*(const Rational &rational) const;
Rational operator/(const Rational &rational) const;
当有理数是通过构造函数或任何算术运算符创建时,它总是被规范化:分子和分母被它们的最大公约数(GCD)除以:
private:
void Normalize();
int GCD(int iNum1, int iNum2);
int numerator, denominator;
};
};
Rational.cpp
#include "SmallWindows.h"
默认构造函数初始化分子和分母,如果分母为零则抛出异常。实际上,这个构造函数以及下一个接受字符串的构造函数是唯一可能分母为零的地方。以下构造函数和算术运算符总是产生分母非零的有理数:
namespace SmallWindows {
Rational::Rational(int numerator /* = 0 */,
int denominator /* = 1 */)
:numerator(numerator),
denominator(denominator) {
if (denominator == 0) {
throw NotaRationalNumber();
}
Normalize();
}
文本可以以两种格式存储有理数:作为整数后跟一个斜杠(/)和另一个整数,或者作为一个单独的整数。我们首先将分子和分母初始化为 0 和 1:
Rational::Rational(const String& text)
:numerator(0),
denominator(1) {
String trimText(Trim(text));
首先,我们尝试两个整数和一个斜杠;我们读取分子、斜杠和分母。在斜杠之前设置skipws
标志,这将导致流在斜杠之前跳过任何潜在的空白字符。如果我们到达行的末尾,分母不是 0,读取到slash
变量的字符确实是斜杠,文本包含一个有理数,并且我们已经读取了分子和分母,那么我们就完成了,并返回:
{ IStringStream totalStream(trimText);
TCHAR slash;
totalStream >> numerator >> setiosflags(ios::skipws)
>> slash >> denominator;
if (totalStream.eof() && (denominator != 0) &&
(slash == TEXT('/'))) {
Normalize();
return;
}
}
如果使用两个整数和一个斜杠不起作用,我们尝试单个整数的情形。我们创建一个新的流并读取分子。如果我们读取之后到达流的末尾,该字符串包含一个有效的整数。我们让分子保持其初始化的值,即 1,并返回。
{ IStringStream numeratorStream(trimText);
numeratorStream >> numerator;
if (numeratorStream.eof()) {
return;
}
}
如果两个整数以及一个斜杠和一个单独的整数都失败了,我们必须得出结论,该字符串不包含有效的有理数,并且我们抛出NotaRationalNumber
异常:
throw NotaRationalNumber();
}
复制构造函数只是复制有理数的分子和分母:
Rational::Rational(const Rational &rational)
:numerator(rational.numerator),
denominator(rational.denominator) {
// Empty.
}
赋值运算符也会复制有理数的分子和分母,并返回其自己的Rational
对象(*this
):
Rational Rational::operator=(const Rational &rational) {
numerator = rational.numerator;
denominator = rational.denominator;
return *this;
}
String
转换运算符创建一个OStringStream
对象并查看分母。如果它是 1,有理数可以表示为一个单独的整数;否则,它需要表示为分子和分母的分数。最后,流被转换为返回的字符串:
Rational::operator String() const {
OStringStream outStream;
if (denominator == 1) {
outStream << numerator;
}
else {
outStream << numerator << TEXT("/") << denominator;
}
return outStream.str();
}
由于有理数总是规范化,我们可以得出结论,如果两个有理数的分子和分母相同,则它们相等:
bool Rational::operator==(const Rational &rational) const {
return (numerator == rational.numerator) &&
(denominator == rational.denominator);
}
bool Rational::operator!=(const Rational &rational) const {
return !(*this == rational);
}
当决定一个有理数是否小于另一个有理数时,为了避免涉及浮点值,我们将两边都乘以分母并比较乘积:
bool Rational::operator<(const Rational &rational) const {
return ((numerator * rational.denominator) <
(rational.numerator * denominator));
}
bool Rational::operator<=(const Rational &rational) const {
return ((*this < rational) || (*this == rational));
}
bool Rational::operator>(const Rational &rational) const {
return !(*this <= rational);
}
bool Rational::operator>=(const Rational &rational) const {
return !(*this < rational);
}
当两个有理数相加时,我们也在每个项中乘以相反的分母:
Rational Rational::operator+(const Rational &rational) const {
Rational result((numerator * rational.denominator) +
(rational.numerator * denominator),
denominator * rational.denominator);
result.Normalize();
return result;
}
当减去两个有理数时,我们也在每个项中乘以相反的分母:
Rational Rational::operator-(const Rational &rational) const {
Rational result((numerator * rational.denominator) -
(rational.numerator * denominator),
denominator * rational.denominator);
result.Normalize();
return result;
}
当两个有理数相乘时,我们简单地乘以分子和分母:
Rational Rational::operator*(const Rational &rational) const {
Rational result(numerator * rational.numerator,
denominator * rational.denominator);
result.Normalize();
return result;
}
当除以两个有理数时,我们首先取第二个操作数的倒数,然后乘以分子和分母:
Rational Rational::operator/(const Rational &rational) const {
assert(rational.numerator != 0);
Rational result(numerator * rational.denominator,
denominator * rational.numerator);
result.Normalize();
return result;
}
当规范化有理数时,我们首先查看分子。如果它是 0,则无论其之前的值如何,我们都将其分母设置为 1 并返回:
void Rational::Normalize() {
if (numerator == 0) {
denominator = 1;
return;
}
然而,如果分子不是 0,我们查看分母。如果它小于 0,则我们交换分子和分母的符号,使分母始终大于 0:
if (denominator < 0) {
numerator = -numerator;
denominator = -denominator;
}
然后我们通过调用 GCD
来计算最大公约数,然后将分子和分母都除以最大公约数:
int gcd = GCD(abs(numerator), denominator);
numerator /= gcd;
denominator /= gcd;
}
GCD
方法通过比较数字并从较大的数字中减去较小的数字来递归调用自身。当它们相等时,我们返回该数字。GCD 算法被认为是世界上最早的非平凡算法。
int Rational::GCD(int number1, int number2) {
if (number1 > number2) {
return GCD(number1 - number2, number2);
}
else if (number1 < number2) {
return GCD(number1, number2 - number1);
}
else {
return number1;
}
}
};
复数
一个 复数 z = x + yi 是一个实数 x 和一个实数 y 乘以 虚数单位 i 的和,i ² = -1 ⇒ i = ±√(-1) ,这是方程 x ² + 1 = 0 的解。
Complex.h
namespace SmallWindows {
class NotaComplexNumber : public exception {
public:
NotaComplexNumber() {/* Empty. */}
};
extern double Square(double value);
构造函数、赋值运算符和 String
转换运算符与 Rational
中的对应项类似:
class Complex {
public:
Complex(double x = 0, double y = 0);
Complex(const Complex &complex);
Complex operator=(const Complex &complex);
bool ReadStream(const String& text);
Complex(const String& text);
operator String() const;
当比较两个复数时,比较它们的绝对值(参考 Abs
)。
bool operator==(const Complex &complex) const;
bool operator!=(const Complex &complex) const;
bool operator<(const Complex &complex) const;
bool operator<=(const Complex &complex) const;
bool operator>(const Complex &complex) const;
bool operator>=(const Complex &complex) const;
算术运算符适用于复数和双精度值:
Complex operator+=(double x);
Complex operator+=(Complex &complex);
friend Complex operator+(double x, const Complex &complex);
friend Complex operator+(const Complex &complex, double x);
friend Complex operator+(const Complex &complex1,
const Complex &complex2);
Complex operator-=(double x);
Complex operator-=(Complex &complex);
friend Complex operator-(double x, const Complex &complex);
friend Complex operator-(const Complex &complex, double x);
friend Complex operator-(const Complex &complex1,
const Complex &complex2);
Complex operator*=(double x);
Complex operator*=(Complex &complex);
friend Complex operator*(double x, const Complex &complex);
friend Complex operator*(const Complex &complex, double x);
friend Complex operator*(const Complex &complex1,
const Complex &complex2);
Complex operator/=(double x);
Complex operator/=(Complex &complex);
friend Complex operator/(double x, const Complex &complex);
friend Complex operator/(const Complex &complex, double x);
friend Complex operator/(const Complex &complex1,
const Complex &complex2);
复数的绝对值(及其转换为 double
的值)是实部和虚部的毕达哥拉斯定理,即各部分平方和的平方根:
double Abs() const {return sqrt(Square(x) + Square(y));}
operator double() const {return Abs();}
private:
double x, y;
};
};
Complex.cpp
#include "SmallWindows.h"
namespace SmallWindows {
double Square(double value) {
return value * value;
}
Complex::Complex(double x, double y)
:x(x), y(y) {
// Empty.
}
Complex::Complex(const Complex &complex)
:x(complex.x),
y(complex.y) {
// Empty.
}
Complex Complex::operator=(const Complex &complex) {
x = complex.x;
y = complex.y;
return *this;
}
当解释包含有理数的文本时,我们从流中读取文本,并且我们需要一些辅助函数来开始。ReadWhiteSpaces
方法读取(并处理)流开头的所有空白:
void ReadWhiteSpaces(IStringStream& inStream) {
while (true) {
TCHAR tChar = inStream.peek();
if ((tChar >= 0) && (tChar <= 255) && isspace(tChar)) {
inStream.get();
}
else {
break;
}
}
}
Peek
方法读取空白字符并在达到流末尾时返回零字符 (\0)。如果没有,我们通过调用 peek
来查看流中的下一个内容,并返回其结果值。请注意,peek
不会消耗流中的字符;它只是检查下一个字符:
TCHAR Peek(IStringStream& inStream) {
ReadWhiteSpaces(inStream);
if (inStream.eof()) {
return TEXT('\0');
}
else {
return (TCHAR) inStream.peek();
}
}
ReadI
方法验证流中的下一个字符是否为 i 或 I。如果是,它从流中读取字符并返回 true
:
bool ReadI(IStringStream& inStream) {
if (tolower(Peek(inStream)) == TEXT('i')) {
inStream.get();
return true;
}
return false;
}
ReadSign
方法验证流中的下一个字符是加号或减号。如果是,它从流中读取字符,将符号参数设置为 + 或 -,并返回 true
:
bool ReadSign(IStringStream& inStream, TCHAR& sign) {
TCHAR tChar = Peek(inStream);
switch (tChar) {
case TEXT('+'):
inStream.get();
sign = TEXT('+');
return true;
case TEXT('-'):
inStream.get();
sign = TEXT('-');
return true;
default:
return false;
}
}
ReadValue
方法验证流中的下一个两个字符是加号或减号后跟数字或点,或者第一个字符是数字或点。如果是后者,它从流的开始读取 value
参数并返回 true
:
bool ReadValue(IStringStream& inStream, double& value) {
TCHAR tChar = Peek(inStream);
if ((tChar == TEXT('+')) || (tChar == TEXT('-'))) {
inStream.get();
tChar = Peek(inStream);
inStream.unget();
if (isdigit(tChar) || (tChar == TEXT('.'))) {
inStream >> value;
return true;
}
}
else if (isdigit(tChar) || (tChar == TEXT('.'))) {
inStream >> value;
return true;
}
return false;
}
EndOfLine
方法简单地返回 true
,如果流中的下一个字符是零字符 (\0),在这种情况下,我们已经到达了字符串的末尾:
bool EndOfLine(IStringStream& inStream) {
return Peek(inStream) == TEXT('\0');
}
现在我们已经准备好将字符串解释为有理数。我们有以下十种情况,其中 x 和 y 是实数,i 是虚数单位,± 是加号或减号。所有十种情况都代表有效的复数:
-
x ± yi
-
x ± ±i
-
x ± i
-
yi ± x
-
±i ± x
-
i ± x
-
yi
-
±i
-
i
-
x
ReadStream
方法从文本创建一个输入流并尝试将其解释为前面提到的十种情况之一。想法是读取流并一次尝试潜在复数的一部分:
bool Complex::ReadStream(const String& text) {
IStringStream inStream(Trim(text));
double value1, value2;
TCHAR sign1, sign2;
如果流由一个值、一个符号、另一个值和 i 或 I 组成,我们根据情况 1 (x ± yi) 设置 x 和 y 并返回 true
。如果符号是负号,则 y 字段为负。然而,第二个值也可能是负的,在这种情况下 y 为正:
if (ReadValue(inStream, value1)) {
if (ReadSign(inStream, sign1)) {
if (ReadValue(inStream, value2) && ReadI(inStream) &&
EndOfLine(inStream)) {
x = value1;
y = (sign1 == TEXT('-')) ? -value2 : value2;
return true;
}
如果符号后面不是值,而是另一个符号和 i 或 I,则适用情况 2 (x ± ±i),我们返回 true
。在这种情况下,我们必须根据两个符号调整 y 的值两次:
else if (ReadSign(inStream, sign2)) {
if (ReadI(inStream) && EndOfLine(inStream)) {
x = value1;
y = (sign1 == TEXT('-')) ? -1 : 1;
y = (sign2 == TEXT('-')) ? -y : y;
return true;
}
}
如果符号后面不是值或另一个符号,而是 i 或 I,则适用情况 3 (x ± i),我们返回 true
:
else if (ReadI(inStream) && EndOfLine(inStream)) {
x = value1;
y = (sign1 == TEXT('-')) ? -1 : 1;
return true;
}
}
如果值后面不是符号,而是 i 或 I,然后是另一个符号和另一个值,则适用情况 4 (yi ± x),我们返回 true
:
else if (ReadI(inStream)) {
if (ReadSign(inStream, sign1)) {
if (ReadValue(inStream, value2) && EndOfLine(inStream)){
y = value1;
x = (sign1 == TEXT('-')) ? -value2 : value2;
return true;
}
}
如果值后面跟着 i 或 I 而没有其他内容,则适用情况 7 (yi),我们返回 true
:
else if(EndOfLine(inStream)) {
y = value1;
x = 0;
return true;
}
}
如果值后面没有其他内容,则适用情况 10 (x),我们返回 true
:
else if (EndOfLine(inStream)) {
x = value1;
y = 0;
return true;
}
}
如果流不以值开头,而是以符号后跟 i 或 I 开头,然后是另一个符号和另一个值,则适用情况 5 (±i ± x),我们返回 true
:
else if (ReadSign(inStream, sign1)) {
if (ReadI(inStream)) {
if (ReadSign(inStream, sign2)) {
if (ReadValue(inStream, value2) && EndOfLine(inStream)){
y = (sign1 == TEXT('-')) ? -1 : 1;
x = (sign2 == TEXT('-')) ? -value2 : value2;
return true;
}
}
如果流以符号开头后跟 i 或 I 而没有其他内容,则适用情况 8 (±i),我们返回 true
:
else if (EndOfLine(inStream)) {
y = (sign1 == TEXT('-')) ? -1 : 1;
x = 0;
return true;
}
}
}
如果流不以值或符号开头,而是以 i 或 I 开头,后面跟着符号和值,则适用情况 6 (i ± x),我们返回 true
:
else if (ReadI(inStream)) {
if (ReadSign(inStream, sign2)) {
if (ReadValue(inStream, value2) && EndOfLine(inStream)) {
y = 1;
x = (sign2 == TEXT('-')) ? -value2 : value2;
return true;
}
}
如果流由 i 或 I 和其他内容组成,则适用情况 9 (i),我们返回 true
:
else if (EndOfLine(inStream)) {
y = 1;
x = 0;
return true;
}
}
最后,如果上述任何情况都不适用,文本不包含复数,我们返回 false
:
return false;
}
接收文本的构造函数简单地调用 ReadStream
,如果 ReadStream
返回 false
,则抛出 NotaComplexNumber
异常。然而,如果 ReadStream
返回 true
,则 x 和 y 被设置为适当的值:
Complex::Complex(const String& text) {
if (!ReadStream(text)) {
throw NotaComplexNumber();
}
}
在 String
转换运算符中,我们考虑几个不同的案例:
-
x + i
-
x - i
-
x ± i
-
x
-
+i
-
-i
-
yi
-
0
如果实部 x 不为 0,我们在流上写下它的值,并考虑虚部 y 的前四种情况。如果 y 是正或负 1,我们简单地写下 +i
或 -i
。如果不是正或负 1,并且不是 0,我们使用 showpos
标志写下它的值,这强制在正值的情况下出现加号。最后,如果 y 是 0,我们根本不写它:
Complex::operator String() const {
OStringStream outStream;
if (x != 0) {
if (y == 1) {
outStream << x << TEXT("+i");
}
else if (y == -1) {
outStream << x << TEXT("-i");
}
else if (y != 0) {
outStream << x << setiosflags(ios::showpos)
<< y << TEXT("i");
}
else {
outStream << x;
}
}
如果 x 是零,我们省略它,并以我们之前的方式写下 y 的值。然而,如果 y 是零,我们写 0;如果 x 和 y 都为零,则什么也不写。此外,我们省略 showpos
标志,因为在正值的情况下不需要写加号:
else {
if (y == 1) {
outStream << TEXT("i");
}
else if (y == -1) {
outStream << TEXT("-i");
}
else if (y != 0) {
outStream << y << TEXT("i");
}
else {
outStream << TEXT("0");
}
}
return outStream.str();
}
如果两个复数的实部和虚部相等,则这两个复数相等:
bool Complex::operator==(const Complex &complex) const {
return ((x == complex.x) && (y == complex.y));
}
bool Complex::operator!=(const Complex &complex) const {
return !(*this == complex);
}
当决定一个复数是否小于另一个复数时,我们选择比较它们的绝对值,这由Abs
方法给出:
bool Complex::operator<(const Complex &complex) const {
return (Abs() < complex.Abs());
}
bool Complex::operator<=(const Complex &complex) const {
return ((*this < complex) || (*this == complex));
}
bool Complex::operator>(const Complex &complex) const {
return !(*this <= complex);
}
bool Complex::operator>=(const Complex &complex) const {
return !(*this < complex);
}
加法运算符都调用以下最终运算符,该运算符适用于所有四个算术运算符:
Complex Complex::operator+=(double x) {
*this = (*this + Complex(x));
return *this;
}
Complex Complex::operator+=(Complex &complex) {
*this = (*this + complex);
return *this;
}
Complex operator+(double x, const Complex &complex) {
return (Complex(x) + complex);
}
Complex operator+(const Complex &complex, double x) {
return (complex + Complex(x));
}
当加两个复数时,我们分别相加它们的实部和虚部:
Complex operator+(const Complex &complex1,
const Complex &complex2) {
return Complex(complex1.x + complex2.x,
complex1.y + complex2.y);
}
Complex Complex::operator-=(double x) {
return (*this - Complex(x));
}
Complex Complex::operator-=(Complex &complex) {
return (*this - complex);
}
Complex operator-(double x, const Complex &complex) {
return (Complex(x) - complex);
}
Complex operator-(const Complex &complex, double x) {
return (complex - Complex(x));
}
当减去两个复数时,我们分别减去它们的实部和虚部:
Complex operator-(const Complex &complex1,
const Complex &complex2) {
return Complex(complex1.x - complex2.x,
complex1.y - complex2.y);
}
Complex Complex::operator*=(double x) {
*this = (*this * Complex(x));
return *this;
}
Complex Complex::operator*=(Complex &complex) {
*this = (*this * complex);
return *this;
}
Complex operator*(double x, const Complex &complex) {
return (Complex(x) * complex);
}
Complex operator*(const Complex &complex, double x) {
return (complex * Complex(x));
}
两个复数的乘积可以通过一些代数方法建立:
(x [1] + y [1]i)(x [2] + y[2]i) = x [1]x [2] + x [1]y [2]i + y [1]ix [2] + y [1]y [2]i ² = x [1]x [2] + x [1]y [2]i + x [2]y [1]i - y [1]y [2] = (x [1]x [2] - y [1]y [2]) + (x [1]y [2] + x [2]y [1])i
Complex operator*(const Complex &complex1,
const Complex &complex2) {
return Complex((complex1.x * complex2.x) -
(complex1.y * complex2.y),
(complex1.x * complex2.y) +
(complex2.x * complex1.y));
}
Complex Complex::operator/=(double x) {
*this = (*this / Complex(x));
return *this;
}
Complex Complex::operator/=(Complex &complex) {
*this = (*this / complex);
return *this;
}
Complex operator/(double x, const Complex &complex) {
return (Complex(x) / complex);
}
Complex operator/(const Complex &complex, double x) {
return (complex / Complex(x));
}
两个复数的商也可以通过一些代数方法建立。复数 x [2] + y [2]i 的共轭是 x [2] - y [2]i,我们可以用它来应用共轭规则:
(x [2]+ y [2]i)(x [2] - y [2]i) = x [2]² - x [2]y [2]i + x [2]y [2]i - y [2]² (-1) = x [2]² - x [2]y [2]i + x [2]y [2]i + y [2]² = x [2]² + y [2]²
我们可以在除以两个复数时使用共轭规则,通过将共轭乘以分子和分母:
Complex operator/(const Complex &complex1,
const Complex &complex2) {
double sum = Square(complex2.x) + Square(complex2.y);
double x = ((complex1.x * complex2.x) +
(complex1.y * complex2.y)) / sum,
y = ((complex2.x * complex1.y) +
(complex1.x * complex2.y)) / sum;
return Complex(x, y);
}
};
概述
通过阅读这本书,你已经学会了如何在 Windows 中使用 Small Windows 开发应用程序,Small Windows 是一个用于 Windows 图形应用程序的 C++面向对象类库。我希望你喜欢这本书!