Gopher2600/gui/sdlimgui/win_rom_select.go
JetSetIlly 28ae543e36 cartridgeloader commentary and documenation
removed all references to hotloading. will reimplement in the future

made sure then cartridgeloader.Loader instances are closed when no
longer needed. added commentary where required to explain

information pane in the ROM selector window is not disabled when
animation emulation is not running. the load button is still visible
based on the animation emulation
2024-04-16 22:15:39 +01:00

701 lines
18 KiB
Go

// This file is part of Gopher2600.
//
// Gopher2600 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.
//
// Gopher2600 is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Gopher2600. If not, see <https://www.gnu.org/licenses/>.
package sdlimgui
import (
"bytes"
"fmt"
"image"
"image/png"
"os"
"path/filepath"
"strings"
"golang.org/x/image/draw"
"github.com/inkyblackness/imgui-go/v4"
"github.com/jetsetilly/gopher2600/archivefs"
"github.com/jetsetilly/gopher2600/cartridgeloader"
"github.com/jetsetilly/gopher2600/gui/fonts"
"github.com/jetsetilly/gopher2600/hardware/peripherals"
"github.com/jetsetilly/gopher2600/hardware/television/specification"
"github.com/jetsetilly/gopher2600/logger"
"github.com/jetsetilly/gopher2600/properties"
"github.com/jetsetilly/gopher2600/resources"
"github.com/jetsetilly/gopher2600/thumbnailer"
"github.com/sahilm/fuzzy"
)
const winSelectROMID = "Select ROM"
type winSelectROM struct {
playmodeWin
debuggerWin
img *SdlImgui
path archivefs.Path
pathEntries []archivefs.Node
// selectedName is the name of the ROM in a normalised form
selectedName string
// properties of selected
selectedProperties properties.Entry
showAllFiles bool
showHidden bool
scrollToTop bool
centreOnFile bool
informationOpen bool
// height of options line at bottom of window. valid after first frame
controlHeight float32
thmb *thumbnailer.Anim
thmbTexture texture
thmbImage *image.RGBA
thmbDimensions image.Point
// the return channel from the emulation goroutine for the property lookup
// for the selected cartridge
propertyResult chan properties.Entry
// map of normalised ROM titles to box art images
boxart []string
boxartTexture texture
boxartDimensions image.Point
boxartUse bool
}
// boxart from libretro project
// github.com/libretro-thumbnails/Atari_-_2600/tree/4ea759821724d6c7bcf2f46020d79fc4270ed2f6/Named_Boxarts
const namedBoxarts = "Named_Boxarts"
func newSelectROM(img *SdlImgui) (window, error) {
win := &winSelectROM{
img: img,
showAllFiles: false,
showHidden: false,
scrollToTop: true,
centreOnFile: true,
propertyResult: make(chan properties.Entry, 1),
}
win.debuggerGeom.noFocusTracking = true
var err error
// it is assumed in the polling routines that if the file rom selector is
// open then the thumbnailer is open. if we ever decide that the thumbnailer
// should be optional we should change this - we don't want the polling to
// be high if there is no reason
win.thmb, err = thumbnailer.NewAnim(win.img.dbg.VCS().Env.Prefs)
if err != nil {
return nil, err
}
win.thmbTexture = img.rnd.addTexture(textureColor, true, true)
win.thmbImage = image.NewRGBA(image.Rect(0, 0, specification.ClksVisible, specification.AbsoluteMaxScanlines))
win.thmbDimensions = win.thmbImage.Bounds().Size()
// load and normalise box art names
boxartPath, err := resources.JoinPath(namedBoxarts)
if err != nil {
logger.Logf("sdlimgui", err.Error())
} else {
boxartFiles, err := os.ReadDir(boxartPath)
if err != nil {
logger.Logf("sdlimgui", err.Error())
} else {
for _, n := range boxartFiles {
win.boxart = append(win.boxart, n.Name())
}
}
}
// prepare boxart texture
win.boxartTexture = img.rnd.addTexture(textureColor, false, false)
return win, nil
}
func (win *winSelectROM) init() {
}
func (win winSelectROM) id() string {
return winSelectROMID
}
func (win *winSelectROM) setOpen(open bool) {
if !open {
win.path.Close()
return
}
// open at the most recently selected ROM
recent := win.img.dbg.Prefs.RecentROM.String()
err := win.setPath(recent)
if err != nil {
logger.Logf("sdlimgui", err.Error())
}
}
func (win *winSelectROM) playmodeSetOpen(open bool) {
win.playmodeWin.playmodeSetOpen(open)
win.centreOnFile = true
win.setOpen(open)
// set centreOnFile to true, ready for next time window is open
if !open {
win.centreOnFile = true
}
}
func (win *winSelectROM) playmodeDraw() bool {
if !win.playmodeOpen {
win.thmb.EndCreation()
return false
}
win.render()
posFlgs := imgui.ConditionAppearing
winFlgs := imgui.WindowFlagsNoSavedSettings | imgui.WindowFlagsAlwaysAutoResize
imgui.SetNextWindowPosV(imgui.Vec2{75, 75}, posFlgs, imgui.Vec2{0, 0})
if imgui.BeginV(win.playmodeID(win.id()), &win.playmodeOpen, winFlgs) {
win.draw()
}
win.playmodeWin.playmodeGeom.update()
imgui.End()
return true
}
func (win *winSelectROM) debuggerSetOpen(open bool) {
win.debuggerWin.debuggerSetOpen(open)
win.centreOnFile = true
win.setOpen(open)
// set centreOnFile to true, ready for next time window is open
if !open {
win.centreOnFile = true
}
}
func (win *winSelectROM) debuggerDraw() bool {
if !win.debuggerOpen {
win.thmb.EndCreation()
return false
}
win.render()
posFlgs := imgui.ConditionFirstUseEver
winFlgs := imgui.WindowFlagsAlwaysAutoResize
imgui.SetNextWindowPosV(imgui.Vec2{75, 75}, posFlgs, imgui.Vec2{0, 0})
if imgui.BeginV(win.debuggerID(win.id()), &win.debuggerOpen, winFlgs) {
win.draw()
}
win.debuggerWin.debuggerGeom.update()
imgui.End()
return true
}
func (win *winSelectROM) render() {
// receive new thumbnail data and copy to texture
select {
case newImage := <-win.thmb.Render:
if newImage != nil {
// clear image
for i := 0; i < len(win.thmbImage.Pix); i += 4 {
s := win.thmbImage.Pix[i : i+4 : i+4]
s[0] = 10
s[1] = 10
s[2] = 10
s[3] = 255
}
// copy new image so that it is centred in the thumbnail image
sz := newImage.Bounds().Size()
y := ((win.thmbDimensions.Y - sz.Y) / 2)
draw.Copy(win.thmbImage, image.Point{X: 0, Y: y},
newImage, newImage.Bounds(), draw.Over, nil)
// render image
win.thmbTexture.render(win.thmbImage)
}
default:
}
}
func (win *winSelectROM) draw() {
// check for new property information
select {
case win.selectedProperties = <-win.propertyResult:
win.selectedName = win.selectedProperties.Name
if win.selectedName == "" {
win.selectedName = win.path.Base()
win.selectedName = strings.TrimSuffix(win.selectedName, filepath.Ext(win.selectedName))
}
// normalise ROM name for presentation
win.selectedName, _, _ = strings.Cut(win.selectedName, "(")
win.selectedName = strings.TrimSpace(win.selectedName)
// find box art as best we can
err := win.findBoxart()
if err != nil {
logger.Logf("sdlimgui", err.Error())
}
default:
}
// reset centreOnFile at end of draw
defer func() {
win.centreOnFile = false
}()
if imgui.Button("Parent") {
d := filepath.Dir(win.path.Dir())
err := win.setPath(d)
if err != nil {
logger.Logf("sdlimgui", "error setting path (%s)", d)
}
win.scrollToTop = true
}
imgui.SameLine()
imgui.Text(archivefs.RemoveArchiveExt(win.path.Dir()))
if imgui.BeginTable("romSelector", 2) {
imgui.TableSetupColumnV("filelist", imgui.TableColumnFlagsWidthStretch, -1, 0)
imgui.TableSetupColumnV("emulation", imgui.TableColumnFlagsWidthStretch, -1, 1)
imgui.TableNextRow()
imgui.TableNextColumn()
height := imgui.WindowHeight() - imgui.CursorPosY() - win.controlHeight - imgui.CurrentStyle().FramePadding().Y*2 - imgui.CurrentStyle().ItemInnerSpacing().Y
imgui.BeginChildV("##selector", imgui.Vec2{X: 300, Y: height}, true, 0)
if win.scrollToTop {
imgui.SetScrollY(0)
win.scrollToTop = false
}
// list directories
imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.ROMSelectDir)
for _, e := range win.pathEntries {
// ignore dot files
if !win.showHidden && e.Name[0] == '.' {
continue
}
if e.IsDir {
s := strings.Builder{}
if e.IsArchive {
s.WriteString(string(fonts.Paperclip))
s.WriteString(" ")
s.WriteString(archivefs.TrimArchiveExt(e.Name))
} else {
s.WriteString(string(fonts.Directory))
s.WriteString(" ")
s.WriteString(e.Name)
}
if imgui.Selectable(s.String()) {
d := filepath.Join(win.path.Dir(), e.Name)
err := win.setPath(d)
if err != nil {
logger.Logf("sdlimgui", "error setting path (%s)", d)
}
win.scrollToTop = true
}
}
}
imgui.PopStyleColor()
// list files
imgui.PushStyleColor(imgui.StyleColorText, win.img.cols.ROMSelectFile)
for _, e := range win.pathEntries {
// ignore dot files
if !win.showHidden && e.Name[0] == '.' {
continue
}
// ignore invalid file extensions unless showAllFiles flags is set
ext := strings.ToUpper(filepath.Ext(e.Name))
if !win.showAllFiles {
hasExt := false
for _, e := range cartridgeloader.FileExtensions {
if e == ext {
hasExt = true
break
}
}
if !hasExt {
for _, e := range archivefs.ArchiveExtensions {
if e == ext {
hasExt = true
break
}
}
}
if !hasExt {
continue // to next file
}
}
if !e.IsDir {
selected := e.Name == win.path.Base()
if selected && win.centreOnFile {
imgui.SetScrollHereY(0.0)
}
if imgui.SelectableV(e.Name, selected, 0, imgui.Vec2{0, 0}) {
win.setPath(filepath.Join(win.path.Dir(), e.Name))
}
if imgui.IsItemHovered() && imgui.IsMouseDoubleClicked(0) {
win.insertCartridge()
}
}
}
imgui.PopStyleColor()
imgui.EndChild()
imgui.TableNextColumn()
imgui.Image(imgui.TextureID(win.thmbTexture.getID()),
imgui.Vec2{float32(win.thmbDimensions.X) * 2, float32(win.thmbDimensions.Y)})
imgui.EndTable()
}
// control buttons. start controlHeight measurement
win.controlHeight = imguiMeasureHeight(func() {
imgui.SetNextItemOpen(win.informationOpen, imgui.ConditionAlways)
if !imgui.CollapsingHeaderV(win.selectedName, imgui.TreeNodeFlagsNone) {
win.informationOpen = false
} else {
win.informationOpen = true
if imgui.BeginTable("#properties", 3) {
imgui.TableSetupColumnV("#information", imgui.TableColumnFlagsWidthStretch, -1, 0)
imgui.TableSetupColumnV("#spacingA", imgui.TableColumnFlagsWidthFixed, -1, 1)
imgui.TableSetupColumnV("#boxart", imgui.TableColumnFlagsWidthFixed, -1, 2)
// property table. we measure the height of this table to
// help centering the box art image in the next column
imgui.TableNextRow()
imgui.TableNextColumn()
propertyTableTop := imgui.CursorPosY()
if imgui.BeginTable("#properties", 2) {
imgui.TableSetupColumnV("#category", imgui.TableColumnFlagsWidthFixed, -1, 0)
imgui.TableSetupColumnV("#detail", imgui.TableColumnFlagsWidthFixed, -1, 1)
// wrap text
imgui.PushTextWrapPosV(imgui.CursorPosX() + imgui.ContentRegionAvail().X)
defer imgui.PopTextWrapPos()
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.Text("Name")
imgui.TableNextColumn()
if win.selectedProperties.IsValid() {
imgui.Text(win.selectedProperties.Name)
} else {
imgui.Text(win.selectedName)
}
// results of preview emulation from the thumbnailer
selectedFilePreview := win.thmb.PreviewResults()
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.AlignTextToFramePadding()
imgui.Text("Mapper")
imgui.TableNextColumn()
if selectedFilePreview != nil {
imgui.Text(selectedFilePreview.VCS.Mem.Cart.ID())
}
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.PushItemFlag(imgui.ItemFlagsDisabled, true)
imgui.PushStyleVarFloat(imgui.StyleVarAlpha, disabledAlpha)
imgui.AlignTextToFramePadding()
imgui.Text("Television")
imgui.TableNextColumn()
if selectedFilePreview != nil {
imgui.SetNextItemWidth(80)
if imgui.BeginCombo("##tvspec", selectedFilePreview.FrameInfo.Spec.ID) {
for _, s := range specification.SpecList {
if imgui.Selectable(s) {
}
}
imgui.EndCombo()
}
}
imgui.PopStyleVar()
imgui.PopItemFlag()
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.PushItemFlag(imgui.ItemFlagsDisabled, true)
imgui.PushStyleVarFloat(imgui.StyleVarAlpha, disabledAlpha)
imgui.AlignTextToFramePadding()
imgui.Text("Players")
imgui.TableNextColumn()
if selectedFilePreview != nil {
imgui.SetNextItemWidth(100)
if imgui.BeginCombo("##leftplayer", string(selectedFilePreview.VCS.RIOT.Ports.LeftPlayer.ID())) {
for _, s := range peripherals.AvailableLeftPlayer {
if imgui.Selectable(s) {
}
}
imgui.EndCombo()
}
imgui.SameLineV(0, 15)
imgui.Text("&")
imgui.SameLineV(0, 15)
imgui.SetNextItemWidth(100)
if imgui.BeginCombo("##rightplayer", string(selectedFilePreview.VCS.RIOT.Ports.RightPlayer.ID())) {
for _, s := range peripherals.AvailableRightPlayer {
if imgui.Selectable(s) {
}
}
imgui.EndCombo()
}
}
imgui.PopStyleVar()
imgui.PopItemFlag()
if win.selectedProperties.Manufacturer != "" {
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.AlignTextToFramePadding()
imgui.Text("Manufacturer")
imgui.TableNextColumn()
imgui.Text(win.selectedProperties.Manufacturer)
}
if win.selectedProperties.Rarity != "" {
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.AlignTextToFramePadding()
imgui.Text("Rarity")
imgui.TableNextColumn()
imgui.Text(win.selectedProperties.Rarity)
}
if win.selectedProperties.Model != "" {
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.AlignTextToFramePadding()
imgui.Text("Model")
imgui.TableNextColumn()
imgui.Text(win.selectedProperties.Model)
}
if win.selectedProperties.Note != "" {
imgui.TableNextRow()
imgui.TableNextColumn()
imgui.AlignTextToFramePadding()
imgui.Text("Note")
imgui.TableNextColumn()
imgui.Text(win.selectedProperties.Note)
}
imgui.EndTable()
}
propertyTableBottom := imgui.CursorPosY()
propertyTableHeight := propertyTableBottom - propertyTableTop
// spacing
imgui.TableNextColumn()
if win.boxartUse {
imgui.TableNextColumn()
sz := imgui.Vec2{float32(win.boxartDimensions.X), float32(win.boxartDimensions.Y)}
// if thumbnail height is less than height of
// property table then we position the image so that
// it's centered in relation to the property table
p := imgui.CursorPos()
if sz.Y < propertyTableHeight {
p.Y += (propertyTableHeight - sz.Y) / 2
imgui.SetCursorPos(p)
} else {
// if height of thumbnail is greater than or
// equal to height of property table then we add
// a imgui.Spacing(). this may expand the height
// of the property table but that's okay
imgui.Spacing()
}
imgui.Image(imgui.TextureID(win.boxartTexture.getID()), sz)
}
imgui.EndTable()
}
}
imguiSeparator()
if imgui.Button("Cancel") {
// close rom selected in both the debugger and playmode
win.debuggerSetOpen(false)
win.playmodeSetOpen(false)
}
if win.selectedName != "" {
var s string
// load or reload button
if win.path.String() == win.img.cache.VCS.Mem.Cart.Filename {
s = fmt.Sprintf("Reload %s", win.selectedName)
} else {
s = fmt.Sprintf("Load %s", win.selectedName)
}
// only show load cartridge button if the file is being
// emulated by the thumbnailer. if it's not then that's a good
// sign that the file isn't supported
if win.thmb.IsEmulating() {
imgui.SameLine()
if imgui.Button(s) {
win.insertCartridge()
}
}
}
})
}
func (win *winSelectROM) insertCartridge() {
// do not try to load cartridge if the file is not being emulated by the
// thumbnailer. if it's not then that's a good sign that the file isn't
// supported
if !win.thmb.IsEmulating() {
return
}
win.img.dbg.InsertCartridge(win.path.String())
// close rom selected in both the debugger and playmode
win.debuggerSetOpen(false)
win.playmodeSetOpen(false)
}
func (win *winSelectROM) setPath(path string) error {
var err error
win.path.Set(path)
win.pathEntries, err = win.path.List()
if err != nil {
return err
}
if win.path.IsDir() {
win.setSelectedFile("")
} else {
win.setSelectedFile(win.path.String())
}
return nil
}
func (win *winSelectROM) setSelectedFile(filename string) {
// selected name will be
win.selectedName = ""
// return immediately if the filename is empty
if filename == "" {
return
}
// create cartridge loader and start thumbnail emulation
cartload, err := cartridgeloader.NewLoaderFromFilename(filename, "AUTO")
if err != nil {
logger.Logf("ROM Select", err.Error())
return
}
// push function to emulation goroutine. result will be checked for in
// draw() function
win.img.dbg.PushPropertyLookup(cartload.HashMD5, win.propertyResult)
// create thumbnail animation
win.thmb.Create(cartload, thumbnailer.UndefinedNumFrames, true)
// defer boxart lookup to when we receive the property
}
func (win *winSelectROM) findBoxart() error {
// reset boxartUse flag until we are certain we've loaded a suitable image
win.boxartUse = false
// fuzzy find a candidate image
n, _, _ := strings.Cut(win.selectedProperties.Name, "(")
n = strings.TrimSpace(n)
m := fuzzy.Find(n, win.boxart)
if len(m) == 0 {
return nil
}
// load image
p, err := resources.JoinPath(namedBoxarts, m[0].Str)
if err != nil {
return fmt.Errorf("boxart: %w", err)
}
d, err := os.ReadFile(p)
if err != nil {
return fmt.Errorf("boxart: %w", err)
}
// conversion function
render := func(src image.Image) {
if _, ok := src.(*image.RGBA); ok {
return
}
b := src.Bounds()
dst := image.NewRGBA(image.Rect(0, 0, b.Dx()/4, b.Dy()/4))
draw.BiLinear.Scale(dst, dst.Bounds(), src, b, draw.Src, nil)
win.boxartDimensions = dst.Bounds().Max
win.boxartTexture.render(dst)
win.boxartUse = true
}
// convert image and render into texture
ext := filepath.Ext(p)
switch ext {
case ".png":
img, err := png.Decode(bytes.NewReader(d))
if err != nil {
return fmt.Errorf("boxart: %w", err)
}
render(img)
default:
return fmt.Errorf("boxart: unsupported file extension: *%s", ext)
}
return nil
}