从零开始自制计算器
1 概述
这个是仿照Win10
自带的计算器制作的简化版本,是用Qt做的,直接把整个表达式输入然后得出计算结果。
主要分为三部分:
- 界面:就是看到的计算器,包括标题栏,中间的输出框,还有各个按键
- 事件处理:就是处理对应的鼠标与键盘事件
- 表达式处理:就是处理整个输入的字符串,返回计算的结果,当然这个还支持错误判断功能
2 新建工程
选择Widgets Application
:
一般只需MinGW
:
这里默认即可,名字可以随便改。
3 界面
3.1 按键
按键的话,基本上按着改就可以了,改布局,改颜色,改字体。
首先先打开.ui文件:
3.1.1 Grid Layout
添加一个Grid Layout
并调整好大小。
3.1.2 按键
拖入Push Button
作为按键,sizePolicy
属性那里水平和垂直属性都选择Expanding
:
3.1.3 颜色与字体
调整好颜色,设置styleSheet
与字体:
参考styleSheet
如下:
border:1px groove rgb(220,220,220);
background-color:rgb(243,243,243);
字体:
3.1.4 布局
复制制作好的Button
,布好局。
3.1.5 改内容
这里不仅把里面的字符改变,还要把相应的对象名也改变:
再细调每一个按键,包括大小、字体与颜色,使总体效果更好:
数字要注意有"加粗"效果,符号的话尽量"精细"一点。
3.1.6 大小与间隔
整体修改大小,同时加上间隔。
调整好间隔,注意细节。
下面是Win10
自带的计算器:
看到间隔了没?
想要的就是这种效果。
可以先运行看看:
两边的间隔的话一会配合widget
的大小调整即可。
3.2 输出框
输出框很简单,就是一个QLineEdit
。
3.2.1 QLineEdit
首先添加一个QLineEdit
。
3.2.2 大小与颜色
调整好大小,设置好背景颜色。
參考qss
如下:
border:0px groove rgb(243,243,243);
background-color:rgb(245,245,245);
3.2.3 其他
设置字体、只读和对齐。
3.3 标题栏
标题栏其实也很简单,一个QBoxLayout
。
3.3.1 新建Horizontal Layout
3.3.2 添加细节
QLabel
输入标题,两个QPushButton
表示最小化与关闭,同时加入两个Spacer
,让标题与左边空出一些距离。
其实就是模仿Win10
的标题栏的效果。
这里就不做最大化了。因为涉及到按钮的重新排布问题,这个可以自己选择实现。
3.4 整体处理
3.4.1 标题栏
把上一步做的标题栏移到合适的位置,同时删除自带的QMenuBar
、QToolBar
、QStatusBar
。
3.4.2 细节调整
调整整体大小,同时添加透明度。
调整好后大概就那样,透明度这里选择了0.9
。
4 事件处理
4.1 光标事件
4.1.1 标题栏
4.1.1.1 拖动效果
首先把本来那个标题栏去掉。
setWindowFlags(windowFlags() | Qt::FramelessWindowHint);
再在protected
中加入鼠标监听函数:
void mousePressEvent(QMouseEvent *);
void mouseMoveEvent(QMouseEvent *);
私有成员中加入两个QPoint
,分别表示当前窗口坐标与光标的坐标。
QPoint mousePoint;
QPoint windowPoint;
第一个函数是鼠标按下时触发的,根据event->button()
判断是否是左键,是的话获取mouse
坐标,再设置窗口坐标。
当触发第二个函数时,即先判断是否按住左键不放,使用MainWindow
的move
方法移动窗口。
event->globalPos()
获取坐标后减去原来光标的坐标得到窗口坐标的变化量,再用原坐标加上这个变化量。
void MainWindow::mousePressEvent(QMouseEvent *event)
{
if(event->button() == Qt::LeftButton)
{
mousePoint = event->globalPos();
windowPoint = frameGeometry().topLeft();
}
}
void MainWindow::mouseMoveEvent(QMouseEvent *event)
{
if(event->buttons() & Qt::LeftButton)
{
move(windowPoint + event->globalPos() - mousePoint);
}
}
4.1.1.2 最小化与关闭
这里以最小化为例,关闭也一样的,改一下函数调用即可。
在最小化按钮中右键选择Go to slot
:
选择clicked()
:
添加一个最小化函数:
下面是关闭的函数:
4.1.2 按键
按键的鼠标事件包括两个:
- 光标移入与移出事件,为按键添加阴影,加深颜色等
- 单击事件,在输出框中增减对应的字符
4.1.2.1 移入与移出事件
这里的实现方式是通过事件过滤器实现的,增加一个eventFilter()
函数:
bool eventFilter(QObject *,QEvent *);
首先通过event->type()
判断事件类型,如果是光标悬停,再判断对应的各个对象增加阴影效果。
addNumButtonEffet()
如下:
void MainWindow::addNumButtonEffect(QPushButton *button,QGraphicsDropShadowEffect *shadow)
{
shadow->setEnabled(true);
button->setStyleSheet(
"border:1px groove rgb(220,220,220);"
"background-color:rgb(193,193,193);"
);
}
这里QGraphicsDropShadowEffect *shadow
事先初始化好了:
然后在添加事件过滤器:
这里可以对比一下有没有阴影的效果:
没有阴影:
加上阴影:
呃....这里可能是截图工具的问题,看不出来有多大的效果,但是直接在机器上看是有比较大的区别的,建议还是加上阴影。
4.1.2.2 单击事件
单击事件就是单击了某个按键然后用户可以在输出框中看到对应的反应。
依次选择按键,右键Go to slot
:
选择clicked()
:
然后添加处理函数,这里实现了一个添加文本与清除焦点的函数,添加文本就是对应按键被光标单击后添加到输出框。单击后会把焦点保留在这个按钮上,键盘上敲空格默认会帮你"按一次"这个按钮,因此如果不清除焦点的话,在光标单击了某个按钮,比如7,按空格就会在输出框上输出7,光标单击了8后,按空格就会在输出框上输出8。
这里添加文本时还要注意默认的起提示作用的0。
void MainWindow::appendText(const QString &s)
{
if(ui->box->text() == "0")
ui->box->setText(s);
else
ui->box->setText(ui->box->text()+s);
}
void MainWindow::appendTextAndClearFocus(QPushButton *button, const QString &s)
{
appendText(s);
button->clearFocus();
}
4.2 键盘事件
键盘事件就是主要处理各个按键按下时的阴影与输出框添加输出。
键盘事件通过以下两个函数处理:
void keyPressEvent(QKeyEvent *);
void keyReleaseEvent(QKeyEvent *);
第一个是按键按下时触发的,第二个是松开按键触发的。
4.2.1 添加阴影
在按键按下时添加上阴影与颜色加深效果:
通过event->key()
依次判断各个键。
键位參考。
然后添加在keyRealeseEvent()
中把对应的阴影去掉:
void MainWindow::keyReleaseEvent(QKeyEvent *event)
{
switch (event->key())
{
case Qt::Key_0:
case Qt::Key_1:
case Qt::Key_2:
case Qt::Key_3:
case Qt::Key_4:
case Qt::Key_5:
case Qt::Key_6:
case Qt::Key_7:
case Qt::Key_8:
case Qt::Key_9:
case Qt::Key_Plus:
case Qt::Key_Minus:
case Qt::Key_Asterisk:
case Qt::Key_Slash:
case Qt::Key_AsciiCircum:
case Qt::Key_Percent:
case Qt::Key_ParenLeft:
case Qt::Key_ParenRight:
case Qt::Key_BraceLeft:
case Qt::Key_BraceRight:
case Qt::Key_BracketLeft:
case Qt::Key_BracketRight:
case Qt::Key_Backspace:
case Qt::Key_Space:
case Qt::Key_Period:
case Qt::Key_Escape:
case Qt::Key_Equal:
case Qt::Key_Return:
removeNumButtonEffect(ui->num0,num0_shadow);
removeNumButtonEffect(ui->num1,num1_shadow);
removeNumButtonEffect(ui->num2,num2_shadow);
removeNumButtonEffect(ui->num3,num3_shadow);
removeNumButtonEffect(ui->num4,num4_shadow);
removeNumButtonEffect(ui->num5,num5_shadow);
removeNumButtonEffect(ui->num6,num6_shadow);
removeNumButtonEffect(ui->num7,num7_shadow);
removeNumButtonEffect(ui->num8,num8_shadow);
removeNumButtonEffect(ui->num9,num9_shadow);
removeSignButtonEffect(ui->plus,plus_shadow);
removeSignButtonEffect(ui->minus,minus_shadow);
removeSignButtonEffect(ui->mutiply,mutiply_shadow);
removeSignButtonEffect(ui->divide,divide_shadow);
removeSignButtonEffect(ui->pow,pow_shadow);
removeSignButtonEffect(ui->percent,percent_shadow);
removeSignButtonEffect(ui->parentheses,parentheses_shadow);
removeSignButtonEffect(ui->parentheses,parentheses_shadow);
removeSignButtonEffect(ui->brace,brace_shadow);
removeSignButtonEffect(ui->brace,brace_shadow);
removeSignButtonEffect(ui->bracket,bracket_shadow);
removeSignButtonEffect(ui->bracket,bracket_shadow);
removeSignButtonEffect(ui->backspace,backspace_shadow);
removeSignButtonEffect(ui->blank,space_shadow);
removeSignButtonEffect(ui->dot,dot_shadow);
removeSignButtonEffect(ui->C,c_shadow);
removeSignButtonEffect(ui->equal,equal_shadow);
break;
}
}
这里之所以没有一个个按键去判断是因为有可能同时多个按键按下,然后同时松开后发现某个按键还存在阴影,因此统一当其中一个按键释放时去除所有按键的阴影。
4.2.2 添加输出
在输出框中添加输出,调用一个函数即可:
5 整体细节再处理
5.1 淡入效果
看看效果:
这里实际使用了Qt的动画,针对透明度改变的动画。
void MainWindow::fadeIn(void)
{
QPropertyAnimation * changeOpacity = new QPropertyAnimation(this,"windowOpacity");
changeOpacity->setStartValue(0);
changeOpacity->setEndValue(0.91);
changeOpacity->setDuration(2500);
changeOpacity->start();
}
第一行表示改变的是透明度,第二三行设置起始值与结束值,接下来设置动画时间(单位ms),然后开始动画。
5.2 设置固定尺寸
这里可以不设置最大尺寸,但一定要设置最小尺寸。
设置这个实际上禁止了拖拽去改变大小。
5.3 淡出效果
淡出效果与淡入效果类似。
不同的时需要添加计时处理,不能直接在exit(0)
前调用fadeOut()
函数,因为动画会在另一个线程启动,所以需要在主线程休眠指定秒数,等待淡出效果完成后,主线程再调用exit(0)
。
void MainWindow::fadeOut(void)
{
QPropertyAnimation * changeOpacity = new QPropertyAnimation(this,"windowOpacity");
changeOpacity->setStartValue(0.9);
changeOpacity->setEndValue(0);
changeOpacity->setDuration(2500);
changeOpacity->start();
QTime start = QTime::currentTime().addMSecs(2500);
while(QTime::currentTime() < start)
QCoreApplication::processEvents(QEventLoop::AllEvents, 100);
}
其中addMSecs()
表示要延迟的秒数,while
循环体中表示处理本线程的事件,其中100表示处理事件最多100ms就返回本语句。
这里就不放淡出效果的图片了。
6 表达式处理
由于这是整个字符串作为表达式进行输入,需要先进行判断再计算,所以分为判断与计算两部分。
这里使用了一个新开的控制台工程,后面会把这个整合起来。
6.1 判断
使用了一个check
类判断,由于只有10个数字按键,加减乘除、小数点、求余、求次幂、大中小括号、空格,所以可以分成这几类进行判断。
6.1.1 去除所有空格
void removeAllBlank(void)
{
size_t i = 0;
while((i = s.find(' ',i)) != string::npos)
s.erase(i,1);
}
首先把所有空格去除,避免之后的判断。
6.1.2 分类判断
把表达式中的所有字符分成5类:
- 数字
- 小数点
- 运算符号
+ - * / ^ %
- 左括号类
( [ {
- 右括号类
) ] }
然后就是针对每个类型判断它的下一个字符是否是允许的类型,不是的话返回false
。
比如碰上了一个( [ {
,则它的下一个不能是运算符号或者小数点,当然允许-
与+
,因为有(-7) (+234)
这种情况。
然后把这个符号保存下来判断后面是否是对应的右括号。
if(isLeftBrace(i))
{
if(isSignOrDot(i+1))
{
if(s[i+1] != '-' && s[i+1] != '+')
return false;
}
braces.push(s[i]);
}
整个判断函数如下:
bool valid(void)
{
if(isSignOrDot(0) || isRightBrace(0))
return false;
len = s.size();
stack<char> braces;
for(size_t i=0;i<len;++i)
{
if(isLeftBrace(i))
{
if(isSignOrDot(i+1))
{
if(s[i+1] != '-' && s[i+1] != '+')
return false;
}
if(isRightBrace(i+1))
return false;
braces.push(s[i]);
}
else if(isRightBrace(i))
{
if(isDot(i+1) || isDigit(i+1) || isLeftBrace(i+1))
return false;
if(isRightBrace(i+1))
{
stack<char> braces_copy(braces);
if(braces_copy.empty())
return false;
braces_copy.pop();
if(braces_copy.empty())
return false;
}
if(braces.empty())
return false;
char brace = braces.top();
if((brace == '(' && s[i] != ')') || (brace == '[' && s[i] != ']') || (brace == '{' && s[i] != '}'))
return false;
braces.pop();
}
else if(isSign(i))
{
if(isSign(i+1) || isDot(i+1) || isRightBrace(i+1))
return false;
}
else if(isDot(i))
{
if(isSignOrDot(i+1) || isBrace(i+1))
return false;
}
else if(isDigit(i))
{
if(isRightBrace(i+1))
{
if(braces.empty())
return false;
char brace = braces.top();
if((brace == '(' && s[i+1] != ')') || (brace == '[' && s[i+1] != ']') || (brace == '{' && s[i+1] != '}'))
return false;
}
}
}
return braces.empty();
}
特别要注意下的就是碰到右括号的情况,除了要判断是否是单独存在的右括号,还有判断是否与前一个左括号匹配.
6.1.3 加0
这是针对单目运算符-
的情况,比如(-7)
,然后把它转化为(0-7)
:
string getResult(void)
{
size_t len = s.size();
for(size_t i = 0;i<len;++i)
{
if(s[i] == '(' && (s[i+1] == '-' || s[i+1] == '+'))
s.insert(i+1,"0");
}
return s;
}
在左小括号后判断是否是-
或+
,是的话对应位置插入0。
6.2 计算
6.2.1 calc
辅助类
calc
辅助类中使用了两个栈,运算符栈与操作数栈。
private:
stack<char> operators;
stack<double> operands;
其中有两个重要的方法:
bool canCalculate(char sign);
void calculate(void);
第一个方法将下一个准备进入的符号作为参数,判断是否可以计算操作数栈的前两个数,如果可以的话,使用第二个函数进行计算。
calculate()
会将出栈两个操作数与一个运算符,得出结果后在将其压回操作数栈。
void calculate(void)
{
double post = popAndGetNum();
char sign = popAndGetSign();
double pre = popAndGetNum();
double result = 0.0;
switch (sign)
{
case '+':
result = pre+post;
break;
case '-':
result = pre-post;
break;
case '*':
result = pre*post;
break;
case '/':
if(fabs(post) < 1e-6)
{
cout<<"Error.Divisor is 0.";
exit(EXIT_FAILURE);
}
else
result = pre / post;
break;
case '^':
result = pow(pre,post);
break;
case '%':
result = static_cast<int>(pre) % static_cast<int>(post);
break;
}
push(result);
}
bool canCalculate(char sign)
{
if(sign == '(' || sign == '[' || sign == '{' || operators.empty())
return false;
char t = getSign();
if(t == '^')
return true;
switch (t)
{
case '+':
case '-':
return sign == '+' || sign == '-';
case '*':
case '/':
case '%':
return sign == '+' || sign == '-' || sign == '*' || sign == '/' || sign == '%';
}
return false;
}
下面是calc
类:
class calc
{
private:
stack<char> operators;
stack<double> operands;
char getSign(void)
{
return operators.top();
}
double getNum(void)
{
return operands.top();
}
void popSign(void)
{
operators.pop();
}
void popNum(void)
{
operands.pop();
}
double popAndGetNum(void)
{
double num = getNum();
popNum();
return num;
}
char popAndGetSign(void)
{
char sign = getSign();
popSign();
return sign;
}
public:
void push(double num)
{
operands.push(num);
}
void push(char sign)
{
operators.push(sign);
}
char get(void)
{
return getSign();
}
void pop(void)
{
popSign();
}
double result(void)
{
return getNum();
}
void calculate(void)
{
double post = popAndGetNum();
char sign = popAndGetSign();
double pre = popAndGetNum();
double result = 0.0;
switch (sign)
{
case '+':
result = pre+post;
break;
case '-':
result = pre-post;
break;
case '*':
result = pre*post;
break;
case '/':
if(fabs(post) < 1e-6)
{
cout<<"Error.Divisor is 0.";
exit(EXIT_FAILURE);
}
else
result = pre / post;
break;
case '^':
result = pow(pre,post);
break;
case '%':
result = static_cast<int>(pre) % static_cast<int>(post);
break;
}
push(result);
}
bool canCalculate(char sign)
{
if(sign == '(' || sign == '[' || sign == '{' || operators.empty())
return false;
char t = getSign();
if(t == '^')
return true;
switch (t)
{
case '+':
case '-':
return sign == '+' || sign == '-';
case '*':
case '/':
case '%':
return sign == '+' || sign == '-' || sign == '*' || sign == '/' || sign == '%';
}
return false;
}
bool empty(void)
{
return operators.empty();
}
};
private封装了一些简单的对两个栈进行操作的工具方法,公有的pop()与get()是对运算符栈进行的操作.因为外部不需要对操作数栈进行操作,由calculate()进行操作,公有的push重载了,可以push到操作数栈或运算符栈.
6.2.2 计算部分
计算部分在这里直接放在了main
中:
int main(void)
{
check chk;
while(!chk.inputAndCheck())
cout<<"Error!Please input again.\n";
string s = chk.getResult();
size_t len = s.size();
calc c;
for(size_t i=0;i<len;++i)
{
if(isdigit(s[i]))
{
double num;
size_t i1 = i+1;
while(i1 < len && (isdigit(s[i1]) || s[i1] == '.'))
++i1;
istringstream input(s.substr(i,i1));
input>>num;
i = i1-1;
c.push(num);
}
else if(s[i] == '}' || s[i] == ']' || s[i] == ')')
{
char sign;
char start = (s[i] == '}' ? '{' : ( s[i] == ']' ? '[' : '('));
while((sign = c.get()) != start)
c.calculate();
c.pop();
}
else //s[i] is [ ( { + - * / ^ %
{
while(c.canCalculate(s[i]))
c.calculate();
c.push(s[i]);
}
}
while(!c.empty())
c.calculate();
cout<<"result is "<<c.result()<<endl;
return 0;
}
对表达式的每个字符逐个处理,若是数字,提取出来并压栈。
若是右括号类,不断从运算符栈中提取直到把这段括号内的表达式计算完成。
否则若是左括号或者是运算符,当可以计算的时候一直计算,提取两个操作数运算并压栈,再把新的运算符压栈。
最后使用result()
获取结果。
6.2.3 测试
这里就显示几个很长的例子算了。
6.6/{2.3+34.3*2.22-5%2+22%4*[2+3.4/5-(4.3+3.2*33.3)]+34.3} + 7.8*{2.4-6/6+0-0*[23.4-3.4/6+4*(2.2+3)]}+0 - 0 + 0.0
= 10.8569
3.4 - (+3.34) + 34.3 * (-2) / 3.34 + {[(-3.4)^2/3.4+3.4/3]-3.32+[3*(-3)]}
= -28.2656
9^5-34.4^2.3+5%6-34+66%78-78%4 + (-3)*3.4 / {3*(-7)+[3*(-8)+3*(3.4+4.34)/9.3-3.2 + 0.0 - 0]+0.0 - 0}+3.4^4/6.888
= 55683.2
可以手工验证一下。
7 整合
这部分把界面部分与表达式处理部分整合起来。
7.1 调用进程与输出结果
设置界面的调用进程,并获取输出结果。
计算表达式的程序叫MyCalc.exe
,注意把它放在对应的工程文件夹下面,然后使用QProcess
调用。
使用execute
执行,表达式先去除所有的空格,然后作为命令行参数传递给计算程序,然后计算程序把计算结果写入到result.txt
文件,Qt读取这个文件,如果读到#
表示表达式输入错误,否则,则是正确的计算结果。
对于结果因为在计算程序中设置了fixed
格式,因此对于1+2
也会返回3.000000
,这步把多余的0去掉,还要注意小数点的情况。
7.2 一些细节
7.2.1 修改事件
鼠标键盘修改事件。
修改setText
的内容,把结果传递过去。
7.2.1 数字格式
设置fixed
格式,否则的话显示的是科学计数法,对小数位数有要求的话可以设置setprecision
。
7.2.3 错误提示
这里出现错误时,输出"#",然后主程序读取到就会提示"表达式错误,请重新输入。"。
还有除数为0的错误提示,这个要注意一下:
7.2.4 整合错误处理
可以考虑把错误处理整合过来,比如输入了一个点,不能继续输入点,输入了一个乘号或者除号不能再继续输入另一个符号:
8 打包发布
8.1 Enigma Virtual Box
首先去下载Enigma Virtual Box
。
8.2 添加环境变量
把Qt文件夹下的如图所示的bin
添加到Path
环境变量。
8.3 打包库文件
使用windeployqt
打包,首先把程序调成release
模式,运行一次,生成release
的exe,然后把exe复制到一个单独的文件夹,再用命令行进入到这个文件夹,运行
windelpoyqt xxx.exe
这个命令把需要的dll复制到当前所在文件夹。
8.4 生成单个exe
打开Enigma Virtual Box,选择:
第一个选择release
的exe,第二个选择打包之后的文件夹,然后选择添加文件,选择递归添加,添加上一步生成的所有文件(夹)。
这里选择压缩文件,然后选择压缩等待完成即可。
8.5 测试
点击运行:
大功告成!
9 源码
10 参考链接
11 最后
这个简单的计算器有很大的改进空间,比如可以添加各种"数":正弦函数、余弦函数、正切函数、反正弦函数、指数函数、对数函数、高阶导数、抽象函数、复合函数、心里没数等等。另外还可以改进矩形的按钮,可以改成圆角矩形或者椭圆形。对于阴影的处理可以添加淡入淡出效果。
最后就是磨砂,因为Win10
的是有磨砂效果的,这个目前还不会。
最后再上几个图,看看效果(由于动图大小的限制只是简单的表达式):