diff --git a/.gitignore b/.gitignore index 71f5281..e474b09 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,6 @@ cacerts /save *~ + +# Ocelot configuration file +/ocelot.conf diff --git a/src/main/resources/ocelot/desktop/colorscheme.txt b/src/main/resources/ocelot/desktop/colorscheme.txt index 6840631..5d96277 100644 --- a/src/main/resources/ocelot/desktop/colorscheme.txt +++ b/src/main/resources/ocelot/desktop/colorscheme.txt @@ -69,4 +69,13 @@ HistogramBarTop = #ccfdcc HistogramBarFill = #64cc65 HistogramGrid = #336633 HistogramFill = #73ff7360 -HistogramEdge = #ccfdcc \ No newline at end of file +HistogramEdge = #ccfdcc + +VerticalMenuBackground = #222222cc +VerticalMenuEntryActive = #333333 +VerticalMenuEntryForeground = #bbbbbb + +SliderBackground = #aaaaaa +SliderBorder = #888888 +SliderHandler = #bbbbbb +SliderForeground = #333333 diff --git a/src/main/resources/ocelot/desktop/ocelot.conf b/src/main/resources/ocelot/desktop/ocelot.conf new file mode 100644 index 0000000..f460af7 --- /dev/null +++ b/src/main/resources/ocelot/desktop/ocelot.conf @@ -0,0 +1,18 @@ +# Ocelot configuration. This file uses typesafe config's HOCON syntax. +# Try setting your syntax highlighting to Ruby, to help readability. At least +# in Sublime Text that works really well. +ocelot { + sound { + # Volume level for all sounds in Ocelot. Ranges from 0.0 to 1.0 + # Set to 0.0 o disable sound completely. + volumeMaster: 1.0 + + # Volume level for computer case beeps. Ranges from 0.0 to 1.0 + # Set to 0.0 o disable sound completely. + volumeBeep: 0.4 + + # Volume level for environmental sounds (like computer fans or HDD activity). Ranges from 0.0 to 1.0 + # Set to 0.0 o disable sound completely. + volumeEnvironment: 1.0 + } +} diff --git a/src/main/scala/ocelot/desktop/OcelotDesktop.scala b/src/main/scala/ocelot/desktop/OcelotDesktop.scala index 5c1e39f..48235a9 100644 --- a/src/main/scala/ocelot/desktop/OcelotDesktop.scala +++ b/src/main/scala/ocelot/desktop/OcelotDesktop.scala @@ -3,7 +3,7 @@ package ocelot.desktop import li.flor.nativejfilechooser.NativeJFileChooser import ocelot.desktop.audio.{Audio, SoundSource} import ocelot.desktop.ui.UiHandler -import ocelot.desktop.ui.widget.{ExitConfirmationDialog, RootWidget} +import ocelot.desktop.ui.widget.{ExitConfirmationDialog, RootWidget, SettingsDialog} import ocelot.desktop.util._ import org.apache.commons.io.FileUtils import org.apache.logging.log4j.LogManager @@ -36,6 +36,9 @@ object OcelotDesktop extends Logging { logger.info("Starting up Ocelot Desktop") Ocelot.initialize(LogManager.getLogger(Ocelot)) + + val settingsFile = new File("ocelot.conf") + Settings.load(settingsFile) ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt"))) createWorkspace() @@ -72,6 +75,8 @@ object OcelotDesktop extends Logging { Ocelot.shutdown() + Settings.save(settingsFile) + logger.info("Thanks for using Ocelot Desktop") System.exit(0) } @@ -189,6 +194,10 @@ object OcelotDesktop extends Logging { }).start() } + def settings(): Unit = { + UiHandler.root.modalDialogPool.pushDialog(new SettingsDialog()) + } + def cleanup(): Unit = { FileUtils.deleteDirectory(tmpPath.toFile) } diff --git a/src/main/scala/ocelot/desktop/Settings.scala b/src/main/scala/ocelot/desktop/Settings.scala new file mode 100644 index 0000000..9bcaf7d --- /dev/null +++ b/src/main/scala/ocelot/desktop/Settings.scala @@ -0,0 +1,61 @@ +package ocelot.desktop + +import com.typesafe.config.{Config, ConfigFactory, ConfigRenderOptions, ConfigValueFactory} +import ocelot.desktop.util.{Logging, SettingsData} + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import scala.io.{Codec, Source} + +class Settings(val config: Config) extends SettingsData { + volumeMaster = (config.getDouble("ocelot.sound.volumeMaster") max 0 min 1).toFloat + volumeBeep = (config.getDouble("ocelot.sound.volumeBeep") max 0 min 1).toFloat + volumeEnvironment = (config.getDouble("ocelot.sound.volumeEnvironment") max 0 min 1).toFloat +} + +object Settings extends Logging { + private val renderOptions = ConfigRenderOptions.defaults().setJson(false).setOriginComments(false) + private var settings: Settings = _ + def get: Settings = settings + + def load(file: File): Unit = { + import java.lang.System.{lineSeparator => EOL} + val defaults = { + val in = getClass.getResourceAsStream("/ocelot/desktop/ocelot.conf") + val config = Source.fromInputStream(in)(Codec.UTF8).getLines().mkString("", EOL, EOL) + in.close() + ConfigFactory.parseString(config) + } + try { + val source = Source.fromFile(file)(Codec.UTF8) + val plain = source.getLines().mkString("", EOL, EOL) + val config = ConfigFactory.parseString(plain) + settings = new Settings(config) + source.close() + } + catch { + case e: Throwable => + if (file.exists()) { + logger.warn("Failed loading config, using defaults.", e) + } + settings = new Settings(defaults) + } + } + + implicit class ExtendedConfig(val config: Config) { + def withValuePreserveOrigin(path: String, value: Any): Config = { + config.withValue(path, ConfigValueFactory.fromAnyRef(value).withOrigin(config.getValue(path).origin())) + } + } + + def save(file: File): Unit = { + if (settings != null) { + val updatedConfig = settings.config + .withValuePreserveOrigin("ocelot.sound.volumeMaster", settings.volumeMaster) + .withValuePreserveOrigin("ocelot.sound.volumeBeep", settings.volumeBeep) + .withValuePreserveOrigin("ocelot.sound.volumeEnvironment", settings.volumeEnvironment) + Files.write(file.toPath, updatedConfig.root().render(renderOptions).getBytes(StandardCharsets.UTF_8)) + } + } +} diff --git a/src/main/scala/ocelot/desktop/audio/Audio.scala b/src/main/scala/ocelot/desktop/audio/Audio.scala index b3afa0f..3bbedd0 100644 --- a/src/main/scala/ocelot/desktop/audio/Audio.scala +++ b/src/main/scala/ocelot/desktop/audio/Audio.scala @@ -1,5 +1,6 @@ package ocelot.desktop.audio +import ocelot.desktop.Settings import ocelot.desktop.util.Logging import org.lwjgl.openal.AL10 @@ -45,7 +46,7 @@ object Audio extends Logging { private def _beep(pattern: String, frequency: Short, duration: Short): Unit = { val source = AL10.alGenSources() AL10.alSourcef(source, AL10.AL_PITCH, 1) - AL10.alSourcef(source, AL10.AL_GAIN, 0.3f) + AL10.alSourcef(source, AL10.AL_GAIN, Settings.get.volumeBeep * Settings.get.volumeMaster) AL10.alSource3f(source, AL10.AL_POSITION, 0, 0, 0) AL10.alSourcei(source, AL10.AL_LOOPING, AL10.AL_FALSE) diff --git a/src/main/scala/ocelot/desktop/audio/SoundSource.scala b/src/main/scala/ocelot/desktop/audio/SoundSource.scala index 548204f..0acef73 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundSource.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundSource.scala @@ -1,5 +1,6 @@ package ocelot.desktop.audio +import ocelot.desktop.Settings import ocelot.desktop.util.{Logging, Resource, ResourceManager} import org.lwjgl.openal.AL10 @@ -13,7 +14,7 @@ class SoundSource(soundBuffer: SoundBuffer, looping: Boolean = false) extends Re sourceId = AL10.alGenSources() AL10.alSourcei(sourceId, AL10.AL_BUFFER, soundBuffer.getBufferId) AL10.alSourcef(sourceId, AL10.AL_PITCH, 1f) - AL10.alSourcef(sourceId, AL10.AL_GAIN, 1f) + AL10.alSourcef(sourceId, AL10.AL_GAIN, Settings.get.volumeEnvironment * Settings.get.volumeMaster) AL10.alSource3f(sourceId, AL10.AL_POSITION, 0f, 0f, 0f) AL10.alSourcei(sourceId, AL10.AL_LOOPING, if (looping) AL10.AL_TRUE else AL10.AL_FALSE) } else { @@ -32,6 +33,12 @@ class SoundSource(soundBuffer: SoundBuffer, looping: Boolean = false) extends Re def pause(): Unit = if (isPlaying) AL10.alSourcePause(sourceId) def stop(): Unit = if (isPlaying || isPaused) AL10.alSourceStop(sourceId) + def setVolume(value: Float): Unit = { + if (sourceId != -1) { + AL10.alSourcef(sourceId, AL10.AL_GAIN, value) + } + } + override def freeResource(): Unit = { if (sourceId != -1) { stop() diff --git a/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala b/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala index f3a40ec..d325d3b 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala @@ -1,6 +1,5 @@ package ocelot.desktop.ui.widget -import ocelot.desktop.OcelotDesktop import ocelot.desktop.geometry.Padding2D import ocelot.desktop.ui.layout.LinearLayout import ocelot.desktop.ui.widget.modal.ModalDialog diff --git a/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala b/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala index 3f7b154..1de80bb 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala @@ -23,6 +23,8 @@ class MenuBar extends Widget { menu.addEntry(new ContextMenuEntry("Exit", () => OcelotDesktop.exit())) })) + addEntry(new MenuBarButton("Settings", () => OcelotDesktop.settings())) + addEntry(new Widget { override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 1) }) // fill remaining space diff --git a/src/main/scala/ocelot/desktop/ui/widget/SettingsDialog.scala b/src/main/scala/ocelot/desktop/ui/widget/SettingsDialog.scala new file mode 100644 index 0000000..6378f01 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/SettingsDialog.scala @@ -0,0 +1,79 @@ +package ocelot.desktop.ui.widget + +import ocelot.desktop.Settings +import ocelot.desktop.audio.SoundSource +import ocelot.desktop.geometry.{Padding2D, Size2D} +import ocelot.desktop.ui.layout.LinearLayout +import ocelot.desktop.ui.widget.modal.ModalDialog +import ocelot.desktop.ui.widget.verticalmenu.{VerticalMenu, VerticalMenuButton} +import ocelot.desktop.util.{Orientation, ResourceManager, SettingsData} + +class SettingsDialog extends ModalDialog { + private val menu = new VerticalMenu + menu.addEntry(new VerticalMenuButton("Sound", () => {})) + + private val settingsData = new SettingsData() + settingsData.updateWith(Settings.get) + + private def applySettings(): Unit = { + Settings.get.updateWith(settingsData) + ResourceManager.forEach { + case soundSource: SoundSource => soundSource.setVolume(Settings.get.volumeEnvironment * Settings.get.volumeMaster) + case _ => + } + } + + children :+= new PaddingBox(new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Horizontal) + + children :+= new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Vertical) + children :+= menu + } + + children :+= new PaddingBox(new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Vertical) + + children :+= new PaddingBox(new Slider(settingsData.volumeMaster, "Master Volume") { + override def minimumSize: Size2D = Size2D(512, 24) + override def onValueChanged(value: Float): Unit = settingsData.volumeMaster = value + }, Padding2D(bottom = 8)) + + children :+= new PaddingBox(new Slider(settingsData.volumeBeep, "Beep Volume") { + override def minimumSize: Size2D = Size2D(512, 24) + override def onValueChanged(value: Float): Unit = settingsData.volumeBeep = value + }, Padding2D(bottom = 8)) + + children :+= new PaddingBox(new Slider(settingsData.volumeEnvironment, "Environment Volume") { + override def minimumSize: Size2D = Size2D(512, 24) + override def onValueChanged(value: Float): Unit = settingsData.volumeEnvironment = value + }, Padding2D(bottom = 8)) + + children :+= new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Horizontal) + + children :+= new Widget { + override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 1) + } + + children :+= new Button { + override def text: String = "Ok" + override def onClick(): Unit = { + applySettings() + close() + } + } + + children :+= new PaddingBox(new Button { + override def text: String = "Cancel" + override def onClick(): Unit = close() + }, Padding2D(left = 8)) + + children :+= new PaddingBox(new Button { + override def text: String = "Apply" + override def onClick(): Unit = applySettings() + }, Padding2D(left = 8)) + } + }, Padding2D(left = 8)) + }, Padding2D.equal(16)) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/Slider.scala b/src/main/scala/ocelot/desktop/ui/widget/Slider.scala new file mode 100644 index 0000000..5338e57 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/Slider.scala @@ -0,0 +1,45 @@ +package ocelot.desktop.ui.widget + +import ocelot.desktop.ColorScheme +import ocelot.desktop.color.Color +import ocelot.desktop.geometry.Size2D +import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.event.handlers.{ClickHandler, DragHandler} +import ocelot.desktop.ui.event.{ClickEvent, DragEvent, MouseEvent} +import ocelot.desktop.util.DrawUtils +import ocelot.desktop.util.MathUtils.ExtendedFloat + +class Slider(var value: Float, text: String) extends Widget with ClickHandler with DragHandler { + def onValueChanged(value: Float): Unit = {} + + override def receiveMouseEvents: Boolean = true + + private val handleWidth = 8.0f + + private def calculateValue(x: Float): Unit = { + value = ((x - bounds.x - handleWidth / 2f) / (bounds.w - handleWidth)).clamp(0f, 1f) + onValueChanged(value) + } + + eventHandlers += { + case ClickEvent(MouseEvent.Button.Left, pos) => calculateValue(pos.x) + case DragEvent(_, MouseEvent.Button.Left, pos) => calculateValue(pos.x) + } + + override def minimumSize: Size2D = Size2D(24 + text.length * 8, 24) + override def maximumSize: Size2D = minimumSize.copy(width = Float.PositiveInfinity) + + override def draw(g: Graphics): Unit = { + g.rect(bounds, ColorScheme("SliderBackground")) + DrawUtils.ring(g, position.x, position.y, width, height, 2, ColorScheme("SliderBorder")) + + g.rect(position.x + value * (bounds.w - handleWidth), position.y, handleWidth, height, ColorScheme("SliderHandler")) + DrawUtils.ring(g, position.x + value * (bounds.w - handleWidth), position.y, handleWidth, height, 2, ColorScheme("SliderBorder")) + + g.background = Color.Transparent + g.foreground = ColorScheme("SliderForeground") + val fullText = f"$text: ${value * 100}%.0f%%" + val textWidth = fullText.iterator.map(g.font.charWidth(_)).sum + g.text(position.x + ((width - textWidth) / 2).round, position.y + 4, fullText) + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenu.scala b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenu.scala new file mode 100644 index 0000000..5aa3182 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenu.scala @@ -0,0 +1,25 @@ +package ocelot.desktop.ui.widget.verticalmenu + +import ocelot.desktop.geometry.Padding2D +import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.layout.LinearLayout +import ocelot.desktop.ui.widget.{PaddingBox, Widget} +import ocelot.desktop.util.Orientation +import ocelot.desktop.ColorScheme + +class VerticalMenu extends Widget { + override def receiveMouseEvents: Boolean = true + + private val entries: Widget = new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Vertical) + } + + children :+= new PaddingBox(entries, Padding2D.equal(1f)) + + def addEntry(w: Widget): Unit = entries.children :+= w + + override def draw(g: Graphics): Unit = { + g.rect(bounds, ColorScheme("VerticalMenuBackground")) + drawChildren(g) + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala new file mode 100644 index 0000000..69cecf8 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala @@ -0,0 +1,44 @@ +package ocelot.desktop.ui.widget.verticalmenu + +import ocelot.desktop.ColorScheme +import ocelot.desktop.color.Color +import ocelot.desktop.geometry.{Padding2D, Size2D} +import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.event.handlers.{ClickHandler, HoverHandler} +import ocelot.desktop.ui.event.{ClickEvent, HoverEvent, MouseEvent} +import ocelot.desktop.ui.widget.{Label, PaddingBox, Widget} +import ocelot.desktop.util.animation.ColorAnimation + +class VerticalMenuButton(label: String, handler: () => Unit = () => {}) + extends Widget with ClickHandler with HoverHandler { + val colorAnimation: ColorAnimation = new ColorAnimation(ColorScheme("VerticalMenuBackground"), 0.6f) + + children :+= new PaddingBox(new Label { + override def text: String = label + override def color: Color = ColorScheme("VerticalMenuEntryForeground") + override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 16) + }, Padding2D(left = 8, right = 8, top = 1, bottom = 1)) + + override def receiveMouseEvents: Boolean = true + + def onClick(): Unit = handler() + + def onMouseEnter(): Unit = colorAnimation.goto(ColorScheme("VerticalMenuEntryActive")) + + def onMouseLeave(): Unit = colorAnimation.goto(ColorScheme("VerticalMenuBackground")) + + eventHandlers += { + case ClickEvent(MouseEvent.Button.Left, _) => + onClick() + case HoverEvent(HoverEvent.State.Enter) => + onMouseEnter() + case HoverEvent(HoverEvent.State.Leave) => + onMouseLeave() + } + + override def draw(g: Graphics): Unit = { + colorAnimation.update() + g.rect(bounds, colorAnimation.color) + drawChildren(g) + } +} diff --git a/src/main/scala/ocelot/desktop/util/MathUtils.scala b/src/main/scala/ocelot/desktop/util/MathUtils.scala new file mode 100644 index 0000000..e4ac889 --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/MathUtils.scala @@ -0,0 +1,7 @@ +package ocelot.desktop.util + +object MathUtils { + implicit class ExtendedFloat(val x: Float) { + def clamp(a: Float, b: Float): Float = x.max(a).min(b) + } +} diff --git a/src/main/scala/ocelot/desktop/util/ResourceManager.scala b/src/main/scala/ocelot/desktop/util/ResourceManager.scala index 7d5465b..893a77f 100644 --- a/src/main/scala/ocelot/desktop/util/ResourceManager.scala +++ b/src/main/scala/ocelot/desktop/util/ResourceManager.scala @@ -19,6 +19,10 @@ object ResourceManager { } } + def forEach(predicate: Resource => Unit): Unit = { + resources.foreach(predicate) + } + def freeResource(resource: Resource): Unit = { resource.freeResource() resources -= resource diff --git a/src/main/scala/ocelot/desktop/util/SettingsData.scala b/src/main/scala/ocelot/desktop/util/SettingsData.scala new file mode 100644 index 0000000..8cd687e --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/SettingsData.scala @@ -0,0 +1,13 @@ +package ocelot.desktop.util + +class SettingsData { + var volumeMaster: Float = 1f + var volumeBeep: Float = 1f + var volumeEnvironment: Float = 1f + + def updateWith(data: SettingsData): Unit = { + this.volumeMaster = data.volumeMaster + this.volumeBeep = data.volumeBeep + this.volumeEnvironment = data.volumeEnvironment + } +}