Skip to content

Instantly share code, notes, and snippets.

@evgkarasev
Last active May 16, 2020 09:20
Show Gist options
  • Save evgkarasev/3d4344a7ef07937c48c29b846907ac49 to your computer and use it in GitHub Desktop.
Save evgkarasev/3d4344a7ef07937c48c29b846907ac49 to your computer and use it in GitHub Desktop.
SpreadSheet in Scala / Swing
name := "ScalaCalcSheet"
version := "0.1"
scalaVersion := "2.13.2"
libraryDependencies += "org.scala-lang.modules" %% "scala-swing" % "2.1.1"
libraryDependencies += "org.scala-lang.modules" %% "scala-parser-combinators" % "1.1.2"
libraryDependencies += "com.sksamuel.avro4s" %% "avro4s-core" % "3.1.0"
package ScalaCalcSheet
import scala.math._
trait Arithmetic {
this: Evaluator =>
operations ++= List(
"sin" -> { case List(x) => sin(x) },
"cos" -> { case List(x) => cos(x) },
"tan" -> { case List(x) => tan(x) },
"asin" -> { case List(x) => asin(x) },
"acos" -> { case List(x) => acos(x) },
"atan" -> { case List(x) => atan(x) },
"deg" -> { case List(x) => toDegrees(x) }, // Converts an angle measured in radians to an approximately equivalent angle measured in degrees.
"rad" -> { case List(x) => toRadians(x) }, // Converts an angle measured in degrees to an approximately equivalent angle measured in radians.
"abs" -> { case List(x) => abs(x) }, // Determine the magnitude of a value by discarding the sign. Results are >= 0
"ln" -> { case List(x) => log(x) }, // Returns the natural logarithm of a Double value.
"lg" -> { case List(x) => log10(x) }, // Returns the base 10 logarithm of the given Double value.
"pow" -> { case List(x, y) => pow(x, y) }, // Returns the value of the first argument raised to the power of the second argument.
"sqrt" -> { case List(x) => sqrt(x) }, // Returns the square root of a Double value.
"cbrt" -> { case List(x) => cbrt(x) }, // Returns the cube root of the given Double value.
"sig" -> { case List(x) => signum(x) }, // For signum extract the sign of a value. Results are -1, 0 or 1.
"rand" -> { case List(x) => random() * x }, // Returns a Double value with a positive sign, greater than or equal to 0.0 and less than argument.
"add" -> { case List(x, y) => x + y },
"sub" -> { case List(x, y) => x - y },
"div" -> { case List(x, y) => x / y },
"mul" -> { case List(x, y) => x * y },
"mod" -> { case List(x, y) => x % y },
"sum" -> { xs => xs.foldLeft(0.0)(_ + _) }, // Returns sum of arguments within the range
"prod" -> { xs => xs.foldLeft(1.0)(_ * _) }, // Returns product of arguments within the range
"max" -> { xs => xs.foldLeft(xs.head)(max) }, // Returns maximum of arguments within the range
"min" -> { xs => xs.foldLeft(xs.head)(min) } // Returns minimum of arguments within the range
)
}
package ScalaCalcSheet
trait Evaluator {
this: Model =>
type Op = List[Double] => Double
val operations = new collection.mutable.HashMap[String, Op]
private def evalList(formula: Formula): List[Double] =
formula match {
case Range(_, _) =>
references(formula) map (_.value)
case _ =>
List(evaluate(formula))
}
def evaluate(formula: Formula): Double = try {
formula match {
case Coord(row, column) =>
cells(row)(column).value
case Number(v) =>
v
case Textual(_) =>
0
case Application(function, arguments) =>
val argVals = arguments flatMap evalList // List[Formula] => List[Double]
operations(function)(argVals)
}
} catch {
// Not-a-number error raised in case of any evaluation exception
case ex: Exception => Double.NaN
}
def references(formula: Formula): List[Cell] =
formula match {
case Coord(row, column) =>
List(cells(row)(column))
// this requires for range evaluation
case Range(Coord(r1, c1), Coord(r2, c2)) =>
for (row <- (r1 to r2).toList; column <- c1 to c2)
yield cells(row)(column)
// this requires for subscribing to references in formula
case Application(function, arguments) =>
arguments flatMap references
case _ =>
List()
}
}
package ScalaCalcSheet
import java.io._
import scala.swing._
import org.apache.avro.Schema
import com.sksamuel.avro4s.{AvroInputStream, AvroOutputStream, AvroSchema}
// Helper class for serialization purposes
case class SheetUserData(table: Array[Array[String]])
class FileOps {
val schema: Schema = AvroSchema[SheetUserData]
/** Deserialize the Table userdata and load from the file.
*
* @param file File to load the data.
* @return The userdata in helper object
*/
def readAvro(file: File): SheetUserData = {
val is = AvroInputStream.data[SheetUserData].from(file).build(schema)
val data = is.iterator.toList.head
is.close()
data
}
/** Serialize the Table userdata and save to the file.
*
* @param file File to save the data.
* @param data Userdata in helper object
*/
def writeAvro(file: File, data: SheetUserData): Unit = {
val os = AvroOutputStream.data[SheetUserData].to(file).build(schema)
os.write(data)
os.flush()
os.close()
}
/** Asks the user to choose a file to load date into cells.
*
* @param title What to put in the file chooser dialog title bar.
* @return The chosen file.
*/
def chooseOpenFile(title: String = ""): Option[File] = {
val chooser = new FileChooser(new File("."))
chooser.title = title
if (chooser.showOpenDialog(Main.top) == FileChooser.Result.Approve)
Some(chooser.selectedFile)
else None
}
/** Asks the user to choose a file to save date from cells.
*
* @param title What to put in the file chooser dialog title bar.
* @return The chosen file.
*/
def chooseSaveFile(title: String = ""): Option[File] = {
val chooser = new FileChooser(new File("."))
chooser.title = title
if (chooser.showSaveDialog(Main.top) == FileChooser.Result.Approve) {
Some(chooser.selectedFile)
} else None
}
/** Asks the user to choose a directory, then returns (as an option)
* an array of the files in that directory.
*
* @param title What to put in the file chooser dialog title bar.
* @return The files in the chosen directory.
*/
def getDirectoryListing(title: String = ""): Option[Array[File]] = {
val chooser = new FileChooser(null)
chooser.fileSelectionMode = FileChooser.SelectionMode.DirectoriesOnly
chooser.title = title
if (chooser.showOpenDialog(Main.top) == FileChooser.Result.Approve) {
Some(chooser.selectedFile.listFiles())
} else None
}
}
package ScalaCalcSheet
import scala.util.parsing.combinator._
object FormulaParsers extends RegexParsers with Formula {
def ident: Parser[String] = """[a-zA-Z_]\w*""".r
def decimal: Parser[String] = """-?\d+(\.\d*)?""".r
def pi: Parser[Number] = """Pi""".r ^^ ( _ => Number(3.141592653589793))
def e: Parser[Number] = """E""".r ^^ ( _ => Number(2.718281828459045))
def const: Parser[Number] = pi | e
def empty: Parser[Textual] =
"""""".r ^^ (_ => Empty)
def cell: Parser[Coord] =
"""[A-Za-z]\d+""".r ^^ { s =>
val column = s.charAt(0).toUpper - 'A'
val row = s.substring(1).toInt
Coord(row, column)
}
def range: Parser[Range] =
cell~":"~cell ^^ {
case c1~":"~c2 => Range(c1, c2)
}
def number: Parser[Number] =
decimal ^^ (d => Number(d.toDouble))
def application: Parser[Application] =
ident~"("~repsep(expr, ",")~")" ^^ {
case f~"("~ps~")" => Application(f, ps)
}
def expr: Parser[Formula] =
range | cell | number | application | const
def textual: Parser[Textual] =
"""[^=].*""".r ^^ Textual
def formula: Parser[Formula] =
number | textual | "="~>expr | empty
def parse(input: String): Formula =
parseAll(formula, input) match {
case Success(e, _) => e
case f: NoSuccess => Textual("[" + f.msg + "]")
}
}
package ScalaCalcSheet
trait Formula
case class Coord(row: Int, column: Int) extends Formula {
override def toString = ('A' + column).toChar.toString + row
}
case class Range(c1: Coord, c2: Coord) extends Formula {
override def toString = c1.toString + ":" + c2.toString
}
case class Number(value: Double) extends Formula {
override def toString = value.toString
}
case class Textual(value: String) extends Formula {
override def toString = value
}
case class Application(function: String,
arguments: List[Formula]) extends Formula {
override def toString =
function + arguments.mkString("(", ",", ")")
}
object Empty extends Textual("")
package ScalaCalcSheet
import swing._
import java.io._
import javax.swing.KeyStroke
object Main extends SimpleSwingApplication {
val height = 100
val width = 26
val spreadsheet = new Spreadsheet(height, width)
var file: File = new File("NewSheet.avro")
val fileOps = new FileOps
import fileOps._
val menubar: MenuBar = new MenuBar {
contents += new Menu("File") {
// TODO: Initialize new Spredsheet instance
contents += new MenuItem(new Action("New") {
accelerator = Some(KeyStroke.getKeyStroke("ctrl N"))
def apply {
spreadsheet.loadUserData(readAvro(file).table)
}
})
// Open existing file
contents += new MenuItem(new Action("Open") {
accelerator = Some(KeyStroke.getKeyStroke("ctrl O"))
def apply {
val choosedFile = chooseOpenFile("Open File")
spreadsheet.loadUserData(readAvro(choosedFile.getOrElse(file)).table)
}
})
// Save existing file
contents += new MenuItem(new Action("Save") {
accelerator = Some(KeyStroke.getKeyStroke("ctrl S"))
def apply {
writeAvro(file, SheetUserData(spreadsheet.getUserData))
}
})
// Save as new file
contents += new MenuItem(Action("Save As...") {
val choosedFile = chooseSaveFile("Save File")
writeAvro(choosedFile.getOrElse(file), SheetUserData(spreadsheet.getUserData))
})
contents += new MenuItem(Action("Exit") {
sys.exit(0)
})
}
contents += new Menu("Help") {
contents += new MenuItem(new Action("Formulas") {
accelerator = Some(KeyStroke.getKeyStroke("ctrl H"))
def apply {
val message =
"""Constants:
|=Pi Returns 3.141592653589793
|=E Returns 2.718281828459045
|References:
|=A1 Reference to actual value of cell A1
|=max(A1:A20) Returns maximum within the range of cells from A1 to A20
|Formulas:
|=sin(div(Pi, 2)) Returns sin of pi/2
|=add(sin(div(Pi, 4)), cos(div(Pi, 4))) Returns sin(Pi/4) + cos(pi/4))""".stripMargin
Dialog.showMessage(top, message=message, title="Formulas")
}
})
contents += new MenuItem(new Action("Functions") {
accelerator = Some(KeyStroke.getKeyStroke("ctrl F"))
def apply {
val message =
"""sin(x) | asin(x)
|cos(x) | acos(x)
|tan(x) | atan(x)
|deg(x) Converts an angle in radians to equivalent in degrees
|rad(x) Converts an angle in degrees to equivalent in radians
|abs(x) Determine the magnitude of a value by discarding the sign
|ln(x) Returns the natural logarithm of given value
|lg(x) Returns the base 10 logarithm of given value
|pow(x, y) Returns the value of the first argument raised to the power of the second argument.
|sqrt(x) Returns the square root of given value
|cbrt(x) Returns the cube root of given value
|sig(x) Extract the sign of a value. Results are -1, 0 or 1.
|rand(x) Returns positive value greater than zero and less than argument
|add(x, y) Returns x + y | sub(x, y) Returns x - y
|mul(x, y) Returns x * y | div(x, y) Returns x / y
|mod(x, y) Returns x % y
|sum(range) Returns sum of arguments within the range
|prod(range) Returns product of arguments within the range
|max(range) Returns maximum of arguments within the range
|min(range) Returns minimum of arguments within the range""".stripMargin
Dialog.showMessage(top, message=message, title="Functions")
}
})
contents += new MenuItem(Action("About") {
val message =
"""Scala CalcSheet Demo v0.1
|Scala v2.13.2 | Swing v1.1.2""".stripMargin
Dialog.showMessage(top, message=message, title="About")
})
}
}
def top: MainFrame = new MainFrame {
title = "ScalaSheet"
contents = spreadsheet
size = new Dimension(1000, 800)
menuBar = menubar
centerOnScreen()
}
}
package ScalaCalcSheet
import scala.swing._
class Model(val height: Int, val width: Int)
extends Evaluator with Arithmetic {
case class ValueChanged(cell: Cell) extends event.Event
case class Cell(row: Int, column: Int) extends Publisher {
override def toString: String = formula match {
case Textual(s) => s
case _ => value.toString
}
private var _value: Double = 0
def value: Double = _value
def value_=(w: Double) = {
if (!(_value == w || _value.isNaN && w.isNaN)) {
_value = w
publish(ValueChanged(this)) // New value publishes the change
}
}
private var _formula: Formula = Empty
def formula: Formula = _formula
def formula_=(f: Formula) = {
for (c <- references(formula)) deafTo(c) // Unsubscribes from old references
_formula = f
for (c <- references(_formula)) listenTo(c) // New formula subscribes to new references
value = evaluate(_formula)
}
reactions += {
case ValueChanged(_) => value = evaluate(formula)
}
}
val cells: Array[Array[Cell]] = Array.ofDim[Cell](height, width)
for (i <- 0 until height; j <- 0 until width)
cells(i)(j) = Cell(i, j)
}
package ScalaCalcSheet
import java.awt
import java.awt.event.MouseListener
import scala.swing._
import scala.swing.event._
class Spreadsheet(val height: Int, val width: Int)
extends ScrollPane {
val cellModel = new Model(height, width)
import cellModel._
val table: Table = new Table(height, width) {
rowHeight = 25
autoResizeMode = Table.AutoResizeMode.Off
showGrid = true
gridColor = new java.awt.Color(150, 150, 150)
focusable = true
override def rendererComponent(isSelected: Boolean, hasFocus: Boolean,
row: Int, column: Int): Component =
if (hasFocus)
new TextField(userData(row, column))
else
new Label(cells(row)(column).toString) {
xAlignment = Alignment.Right
}
def userData(row: Int, column: Int): String = {
val v = this(row, column)
if (v == null) "" else v.toString
}
reactions += {
case TableUpdated(table, rows, column) =>
for (row <- rows)
cells(row)(column).formula =
FormulaParsers.parse(userData(row, column))
case ValueChanged(cell) =>
updateCell(cell.row, cell.column)
}
for (row <- cells; cell <- row) listenTo(cell)
}
val rowHeader: ListView[String] =
new ListView((0 until height) map (_.toString)) {
fixedCellWidth = 30
fixedCellHeight = table.rowHeight
}
viewportView = table
rowHeaderView = rowHeader
def getUserData: Array[Array[String]] = {
val tableModel: Array[Array[String]] = Array.ofDim[String](height, width)
for (i <- 0 until height; j <- 0 until width) {
val v = table.model.getValueAt(i, j)
tableModel(i)(j) = if (v == null) "" else v.toString
}
tableModel
}
def loadUserData(tableModel: Array[Array[String]]): Unit = {
for (i <- tableModel.indices; j <- tableModel(0).indices) {
table.model.setValueAt(tableModel(i)(j), i, j)
cells(i)(j).formula = FormulaParsers.parse(tableModel(i)(j))
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment