I've been working on this project for the last few weeks.
Spending as much time on the terminal as I normally do, I'm not too fond of having to run a gui just to open a calculator for a quick check on something. And, while I know about bc and am aware that I could just issue, for example, 'echo 2*5 | bc' and it would print back '10', as expected, it's not really a solution I like.
At work, I can just open the ol' infamous MS Excel (which is really just a glorified calculator) to do whatever calculations i need and keep them visible for reference, but when not at work I don't even have Excel, so it's either startx > menu > calculator, or echo problem | bc.
Given I'm always on TTY + either GNU Screen or DVTM, I needed a better solution.
So, I took it upon myself to write something I could use.
Enter ncalc. It's a small (250k) ncurses calculator (hence the name: ncurses + calculator = ncalc) & CAS (Computer Algebra System) that works on either a TTY or a windowed terminal. It's built with C++17, ncursesw, and GiNaC, and offers symbolic computation, high-precision arithmetic, and terminal-native graphing capabilities with high-resolution Unicode Block Braille patterns.
Features:
- Symbolic Computation, Powered by GiNaC: ncalc can handle:
- Algebraic Solving: Solves linear and quadratic equations (e.g., `find y, y^2 = 4x`).
- Differentiation: Standard symbolic derivatives (e.g., `diff(sin(x), x)`).
- Partial Derivatives: Evaluate at specific points (e.g., `df/dx(1,0) where f(x,y) = x^2 + y^2`).
- Definite Integrals: Numeric results using Simpson's Rule (e.g., `i(2,0), x^2 dx`).
- Graphing: High-resolution rendering using Unicode Braille patterns (2x4 sub-pixel grid per character). Includes automatic X and Y axes.
- Interactive Interface: Keyboard-centric workflow with history navigation and UTF-8 wide-character support.
This is way more than I need on a daily basis from a regular calculator. But then... I'd already started, so I figured what the hell...
Project page on GitHub
Code:
main.cpp
//ncalc - ncurses calculator & CAS
//by Jonathan Torres
//
//This program is free software: you can redistribute it and/or modify it under the terms of the
//GNU General Public License as published by the Free Software Foundation, either version 3 of the
//License, or (at your option) any later version.
#include <ncurses.h>
#include <ginac/ginac.h>
#include <iostream>
#include <string>
#include <vector>
#include <iomanip>
#include <sstream>
#include <cmath>
#include <cln/exception.h>
#include <clocale>
#include <algorithm>
using namespace std;
using namespace GiNaC;
//Constants & Enums
enum AppMode {
MODE_CALCULATOR,
MODE_GRAPH
};
#define CTRL(c) ((c) & 0x1f)
const int KEY_CTRL_G = CTRL('g');
const int KEY_CTRL_P = CTRL('p');
const int KEY_CTRL_F = CTRL('f'); // Find
const int KEY_CTRL_W = CTRL('w'); // Where
const int KEY_CTRL_E = CTRL('e');
const int KEY_CTRL_N = CTRL('n'); // Imaginary unit
const int KEY_CTRL_D = CTRL('d'); // Derivative shortcut
//Global State
AppMode current_mode = MODE_CALCULATOR;
string current_input = "";
string last_result = "";
bool has_result = false;
ex memory_val = 0;
symtab table;
struct HistoryEntry {
string expr;
string res;
};
vector<HistoryEntry> history;
bool history_focus = false;
int scroll_offset = 0;
//Graphing State
double g_x_min = -10.0;
double g_x_max = 10.0;
double g_y_min = -10.0;
double g_y_max = 10.0;
//Forward Declarations
string format_result(const ex &e);
void calculate();
void draw_history(WINDOW* win);
void draw_graph(WINDOW* win);
void draw_input(WINDOW* win);
void apply_zoom(double factor);
string sanitize_expr(const string& in);
/**
* Sanitizes the input:
* 1. Brackets [] -> ()
* 2. Implicit multiplication
*/
string sanitize_expr(const string& in) {
string s = in;
for (char &c : s) {
if (c == '[') c = '(';
if (c == ']') c = ')';
}
string res_impl = "";
for (size_t i = 0; i < s.length(); ++i) {
res_impl += s[i];
if (i + 1 < s.length()) {
char curr = s[i];
char next = s[i+1];
if ((isdigit(curr) && (isalpha(next) || next == '(')) ||
(curr == ')' && (isdigit(next) || isalpha(next)))) {
res_impl += '*';
}
}
}
return res_impl;
}
string format_result(const ex &e) {
if (is_a<numeric>(e)) {
numeric n = ex_to<numeric>(e);
if (n.is_integer()) {
ostringstream ss;
ss << n;
return ss.str();
}
if (n.is_real()) {
double d = n.to_double();
if (abs(d) >= 1e10 || (abs(d) > 0 && abs(d) < 1e-4)) {
ostringstream ss;
ss << scientific << setprecision(6) << d;
return ss.str();
}
string s = to_string(d);
if (s.find('.') != string::npos) {
s.erase(s.find_last_not_of('0') + 1, string::npos);
if (s.back() == '.') s.pop_back();
}
return s;
}
}
ostringstream ss;
ss << e;
return ss.str();
}
/**
* Solves equations or simplifies expressions.
*/
void calculate() {
if (current_input.empty()) return;
string input = current_input;
try {
parser reader(table);
ex final_res;
bool found_custom = false;
// 1. Handle "where" syntax for derivatives or general substitution
size_t where_pos = input.find(" where ");
if (where_pos != string::npos) {
string lhs_part = input.substr(0, where_pos);
if (lhs_part.find("find ") == 0) lhs_part = lhs_part.substr(5);
string rhs_part = input.substr(where_pos + 7);
if (lhs_part.find("/d") != string::npos) {
size_t slash = lhs_part.find("/d");
size_t paren = lhs_part.find('(', slash);
if (slash != string::npos && paren != string::npos) {
string var_name = lhs_part.substr(slash + 2, paren - (slash + 2));
var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
size_t last_paren = lhs_part.find_last_of(')');
string pts_str = lhs_part.substr(paren + 1, last_paren - paren - 1);
size_t eq_sign = rhs_part.find('=');
if (eq_sign != string::npos) {
string expr_str = sanitize_expr(rhs_part.substr(eq_sign + 1));
ex expr = reader(expr_str);
if (table.find(var_name) != table.end()) {
ex diff_ex = expr.diff(ex_to<symbol>(table[var_name]));
lst subs_list;
stringstream ss(pts_str);
string pt;
vector<string> var_order = {"x", "y", "z", "a", "b"};
int v_idx = 0;
while (getline(ss, pt, ',') && v_idx < (int)var_order.size()) {
subs_list.append(ex_to<symbol>(table[var_order[v_idx]]) == reader(sanitize_expr(pt)).evalf());
v_idx++;
}
final_res = diff_ex.subs(subs_list).evalf();
last_result = format_result(final_res);
found_custom = true;
}
}
}
}
}
// 2. Handle "find var, eq"
if (!found_custom && input.find("find ") == 0) {
int level = 0;
size_t comma_pos = string::npos;
for (size_t i = 0; i < input.length(); ++i) {
if (input[i] == '(') level++;
else if (input[i] == ')') level--;
else if (input[i] == ',' && level == 0) {
comma_pos = i;
break;
}
}
if (comma_pos != string::npos) {
string var_name = input.substr(5, comma_pos - 5);
var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
string eq_str = input.substr(comma_pos + 1);
string sanitized_eq = sanitize_expr(eq_str);
size_t eq_sign = sanitized_eq.find('=');
ex eq_ex;
if (eq_sign != string::npos) {
eq_ex = reader(sanitized_eq.substr(0, eq_sign)) - reader(sanitized_eq.substr(eq_sign + 1));
} else {
eq_ex = reader(sanitized_eq);
}
if (table.find(var_name) == table.end()) table[var_name] = symbol(var_name);
symbol target_var = ex_to<symbol>(table[var_name]);
try {
final_res = lsolve(eq_ex == 0, target_var);
if (final_res.nops() > 0 || !is_a<lst>(final_res)) {
last_result = var_name + " = " + format_result(final_res.evalf());
found_custom = true;
}
} catch (...) {
try {
// Try Quadratic formula as fallback
ex eq = eq_ex.expand();
if (eq.degree(target_var) == 2) {
ex a = eq.coeff(target_var, 2);
ex b = eq.coeff(target_var, 1);
ex c = eq.coeff(target_var, 0);
ex disc = (b*b - 4*a*c).normal();
ex r1 = ((-b + sqrt(disc)) / (2*a)).normal();
ex r2 = ((-b - sqrt(disc)) / (2*a)).normal();
last_result = var_name + " = {" + format_result(r1) + ", " + format_result(r2) + "}";
found_custom = true;
}
} catch (...) {}
}
if (!found_custom) {
// Newton's method fallback for univariate
try {
symbol x = target_var;
ex f = eq_ex;
ex df = f.diff(x);
vector<double> guesses = {1.0, 0.1, 10.0, -1.0, 100.0, 0.0};
for (double guess : guesses) {
double curr = guess;
bool converged = false;
for (int i = 0; i < 100; ++i) {
try {
ex val_ex = f.subs(x == curr).evalf();
ex dval_ex = df.subs(x == curr).evalf();
if (is_a<numeric>(val_ex) && is_a<numeric>(dval_ex)) {
double val = ex_to<numeric>(val_ex).to_double();
double dval = ex_to<numeric>(dval_ex).to_double();
if (abs(val) < 1e-10) { converged = true; break; }
if (abs(dval) < 1e-18) break;
double next_val = curr - val / dval;
if (isnan(next_val) || isinf(next_val) || abs(next_val - curr) < 1e-14) {
if (abs(val) < 1e-8) converged = true;
break;
}
curr = next_val;
} else break;
} catch (...) { break; }
}
if (converged) {
if (abs(curr - round(curr)) < 1e-9) curr = round(curr);
last_result = var_name + " ~ " + format_result(numeric(curr).evalf());
found_custom = true;
break;
}
}
} catch (...) {}
}
if (!found_custom) {
last_result = format_result(eq_ex.normal()) + " = 0";
found_custom = true;
}
}
}
// 3. Handle "i(u,l), expr dx"
if (!found_custom && input.find("i(") == 0) {
size_t end_paren = input.find("),");
if (end_paren != string::npos) {
string range = input.substr(2, end_paren - 2);
size_t comma = range.find(',');
double upper = ex_to<numeric>(reader(range.substr(0, comma)).evalf()).to_double();
double lower = ex_to<numeric>(reader(range.substr(comma + 1)).evalf()).to_double();
string body = input.substr(end_paren + 2);
size_t d_pos = body.find(" d");
string expr_str = sanitize_expr(body.substr(0, d_pos));
string var_name = body.substr(d_pos + 2);
var_name.erase(remove(var_name.begin(), var_name.end(), ' '), var_name.end());
ex expr = reader(expr_str);
if (table.find(var_name) == table.end()) table[var_name] = symbol(var_name);
symbol x_sym = ex_to<symbol>(table[var_name]);
// Numeric integration using Simpson's Rule
double sum = 0;
int n = 1000;
double step = (upper - lower) / n;
try {
for (int j = 0; j <= n; ++j) {
double wx = lower + j * step;
double vy = ex_to<numeric>(expr.subs(x_sym == wx).evalf()).to_double();
if (j == 0 || j == n) sum += vy;
else if (j % 2 == 1) sum += 4 * vy;
else sum += 2 * vy;
}
double area = (step / 3.0) * sum;
last_result = "A = " + format_result(numeric(area));
found_custom = true;
} catch (...) {
last_result = "Integration failed";
}
}
}
if (!found_custom) {
string raw = sanitize_expr(input);
size_t eq_pos = raw.find('=');
if (eq_pos != string::npos) {
string lhs_s = raw.substr(0, eq_pos);
string rhs_s = raw.substr(eq_pos + 1);
if (lhs_s == "y" || lhs_s == "f(x)") {
final_res = reader(rhs_s).evalf();
last_result = format_result(final_res);
} else {
ex eq = reader(lhs_s) - reader(rhs_s);
last_result = format_result(eq.normal()) + " = 0";
}
} else {
final_res = reader(raw).normal().evalf();
last_result = format_result(final_res);
}
}
has_result = true;
history.push_back({current_input, last_result});
current_input = last_result;
int rows, cols; getmaxyx(stdscr, rows, cols);
int max_entries = (rows - 5 - 2) / 2;
scroll_offset = max(0, (int)history.size() - max_entries);
} catch (exception &e) {
last_result = "Error: " + string(e.what());
has_result = false;
} catch (...) {
last_result = "Unknown Error";
has_result = false;
}
}
void draw_graph(WINDOW* win) {
werase(win); box(win, 0, 0);
int h, w; getmaxyx(win, h, w);
int plot_w = w - 2, plot_h = h - 2;
if (plot_w <= 0 || plot_h <= 0) return;
mvwprintw(win, 0, 2, " Graph Mode: %s ", current_input.c_str());
mvwprintw(win, h - 1, 2, " X: [%.1f, %.1f] Y: [%.1f, %.1f] ", g_x_min, g_x_max, g_y_min, g_y_max);
int grid_w = plot_w * 2, grid_h = plot_h * 4;
vector<vector<unsigned char>> grid(plot_h, vector<unsigned char>(plot_w, 0));
auto set_dot = [&](int r, int c) {
if (r >= 0 && r < grid_h && c >= 0 && c < grid_w) {
grid[r/4][c/2] |= (unsigned char[]){0x01, 0x02, 0x04, 0x40, 0x08, 0x10, 0x20, 0x80}[(r%4) + (c%2)*4];
}
};
// Axes
if (g_y_min <= 0 && g_y_max >= 0) {
int r = (int)((g_y_max) / (g_y_max - g_y_min) * grid_h);
for (int c = 0; c < grid_w; ++c) set_dot(r, c);
}
if (g_x_min <= 0 && g_x_max >= 0) {
int c = (int)((-g_x_min) / (g_x_max - g_x_min) * grid_w);
for (int r = 0; r < grid_h; ++r) set_dot(r, c);
}
try {
parser reader(table);
string raw = sanitize_expr(current_input);
size_t eq_pos = raw.find('=');
ex func = reader(eq_pos != string::npos ? raw.substr(eq_pos + 1) : raw);
symbol x_sym = ex_to<symbol>(table["x"]);
double dx = (g_x_max - g_x_min) / grid_w;
double dy = (g_y_max - g_y_min) / grid_h;
for (int col = 0; col < grid_w; ++col) {
double wx = g_x_min + col * dx;
ex vy = func.subs(x_sym == wx).evalf();
if (is_a<numeric>(vy) && ex_to<numeric>(vy).is_real()) {
int row = (int)((g_y_max - ex_to<numeric>(vy).to_double()) / dy);
set_dot(row, col);
}
}
} catch (...) {}
for (int r = 0; r < plot_h; ++r) {
wmove(win, r + 1, 1);
for (int c = 0; c < plot_w; ++c) {
unsigned char v = grid[r][c];
if (v == 0) waddch(win, ' ');
else {
char u[4] = {(char)0xE2, (char)(0xA0 + (v >> 6)), (char)(0x80 + (v & 0x3F)), 0};
waddstr(win, u);
}
}
}
wrefresh(win);
}
void draw_history(WINDOW* win) {
if (current_mode == MODE_GRAPH) { draw_graph(win); return; }
werase(win); if (history_focus) wattron(win, A_REVERSE); box(win, 0, 0);
mvwprintw(win, 0, 2, " History "); if (history_focus) wattroff(win, A_REVERSE);
int h, w; getmaxyx(win, h, w);
int max_e = (h - 2) / 2;
for (int i = 0; i < max_e && (scroll_offset + i) < (int)history.size(); ++i) {
int idx = scroll_offset + i;
mvwprintw(win, 1 + i * 2, 2, "Exp: %s", history[idx].expr.c_str());
mvwprintw(win, 2 + i * 2, 2, "Res: %s", history[idx].res.c_str());
}
wrefresh(win);
}
void draw_input(WINDOW* win) {
werase(win); if (!history_focus) wattron(win, A_REVERSE); box(win, 0, 0);
mvwprintw(win, 0, 2, current_mode == MODE_GRAPH ? " Graph Mode " : " Input ");
if (!history_focus) wattroff(win, A_REVERSE);
mvwprintw(win, 1, 2, "Exp: %s", current_input.c_str());
if (current_mode == MODE_CALCULATOR) {
mvwprintw(win, 2, 2, "Res: %s", last_result.c_str());
mvwprintw(win, 3, 2, "[q] Quit | [Esc] Clear | [Ctrl+f] Find | [Ctrl+w] Where");
} else {
mvwprintw(win, 3, 2, "Zoom: [+/-] or [j/k] | [q] Quit | [Ctrl+g] Calc");
}
wrefresh(win);
}
void apply_zoom(double factor) {
double xm = (g_x_min + g_x_max)/2, ym = (g_y_min + g_y_max)/2;
double xh = (g_x_max - g_x_min)*factor/2, yh = (g_y_max - g_y_min)*factor/2;
g_x_min = xm - xh; g_x_max = xm + xh; g_y_min = ym - yh; g_y_max = ym + yh;
}
void handle_input(int ch) {
if (ch == KEY_CTRL_G) { current_mode = (current_mode == MODE_CALCULATOR ? MODE_GRAPH : MODE_CALCULATOR); return; }
if (ch == '\t') { history_focus = !history_focus; return; }
if (ch == KEY_CTRL_P) { current_input += "Pi"; return; }
if (ch == KEY_CTRL_E) { current_input += "exp(1)"; return; }
if (ch == KEY_CTRL_N) { current_input += "I"; return; }
if (ch == KEY_CTRL_F) { current_input += "find "; return; }
if (ch == KEY_CTRL_W) { current_input += " where "; return; }
if (ch == KEY_CTRL_D) { current_input += "diff("; return; }
if (current_mode == MODE_GRAPH) {
if (ch == '+' || ch == '=' || ch == 'k' || ch == KEY_UP) { apply_zoom(0.8); return; }
if (ch == '-' || ch == '_' || ch == 'j' || ch == KEY_DOWN) { apply_zoom(1.25); return; }
}
if (history_focus && current_mode == MODE_CALCULATOR) {
int rows, cols; getmaxyx(stdscr, rows, cols);
int max_e = (rows - 5 - 2) / 2;
if (ch == KEY_UP && scroll_offset > 0) scroll_offset--;
else if (ch == KEY_DOWN && scroll_offset < max(0, (int)history.size() - max_e)) scroll_offset++;
return;
}
if (ch == '\n' || ch == KEY_ENTER) { if (current_mode == MODE_CALCULATOR) calculate(); return; }
if (ch == KEY_BACKSPACE || ch == 127 || ch == 8) { if (!current_input.empty()) current_input.pop_back(); return; }
if (ch == 27) { //Alt sequences
nodelay(stdscr, TRUE); int n = getch(); nodelay(stdscr, FALSE);
if (n == -1) { current_input = ""; last_result = ""; has_result = false; }
else if (n == 's') current_input += "asin(";
else if (n == 'c') current_input += "acos(";
else if (n == 't') current_input += "atan(";
else if (n == 'f') current_input += "f(";
else if (n == 'u') current_input += "upper";
else if (n == 'l') current_input += "lower";
else if (n == 'e') current_input += "expr";
else if (n == 'v') current_input += "dvar";
return;
}
if (current_mode == MODE_CALCULATOR) {
if (ch == 's') { current_input += "sin("; return; }
if (ch == 'c') { current_input += "cos("; return; }
if (ch == 't') { current_input += "tan("; return; }
if (ch == 'i') { current_input += "i("; return; }
if (ch == 'N') { if (!current_input.empty()) current_input = "-(" + current_input + ")"; return; }
if (ch == 'S') { current_input += "sqrt("; return; }
if (ch == 'Q') { current_input += "cbrt("; return; }
if (ch == 'm') {
if (has_result) { try { parser r(table); memory_val = r(last_result).evalf(); } catch(...) {} }
return;
}
if (ch == 'n') { memory_val = 0; return; }
if (ch == 'r') { current_input += format_result(memory_val); return; }
if (ch == '%') { current_input = "(" + current_input + ")/100"; calculate(); return; }
}
if (ch >= ' ' && ch <= '~') current_input += (char)ch;
}
int main() {
setlocale(LC_ALL, "");
table["x"] = symbol("x"); table["y"] = symbol("y"); table["z"] = symbol("z");
table["a"] = symbol("a"); table["b"] = symbol("b");
table["upper"] = symbol("upper"); table["lower"] = symbol("lower");
table["expr"] = symbol("expr"); table["dvar"] = symbol("dvar");
table["Pi"] = Pi; table["I"] = I;
initscr(); cbreak(); noecho(); keypad(stdscr, TRUE);
#if defined(ESCDELAY)
ESCDELAY = 25;
#endif
int rows, cols; getmaxyx(stdscr, rows, cols);
int input_h = 5;
WINDOW *h_win = newwin(rows - input_h, cols, 0, 0), *i_win = newwin(input_h, cols, rows - input_h, 0);
keypad(i_win, TRUE);
while (true) {
draw_history(h_win); draw_input(i_win);
int ch = wgetch(i_win);
if (ch == 'q') break;
if (ch == KEY_RESIZE) {
getmaxyx(stdscr, rows, cols);
wresize(h_win, rows - input_h, cols); wresize(i_win, input_h, cols);
mvwin(i_win, rows - input_h, 0); clear();
} else handle_input(ch);
}
delwin(h_win); delwin(i_win); endwin();
return 0;
}
Makefile
#Licensed under GPL-3.0-or-later
CXX = g++
CXXFLAGS = -Wall -Wextra -std=c++17
LIBS = -lncursesw -lginac -lcln
TARGET = ncalc
SRC = main.cpp
MAN = ncalc.1
PREFIX ?= /usr/local
all: $(TARGET)
$(TARGET): $(SRC)
$(CXX) $(CXXFLAGS) $(SRC) -o $(TARGET) $(LIBS)
install: all
install -d $(DESTDIR)$(PREFIX)/bin
install -m 755 $(TARGET) $(DESTDIR)$(PREFIX)/bin
install -d $(DESTDIR)$(PREFIX)/share/man/man1
install -m 644 $(MAN) $(DESTDIR)$(PREFIX)/share/man/man1
uninstall:
rm -f $(DESTDIR)$(PREFIX)/bin$(TARGET)
rm -f $(DESTDIR)$(PREFIX)/share/man/man1/$(MAN)
clean:
rm -f $(TARGET)
.PHONY: all clean install uninstall